@liriraid/agentflow-ai 1.0.10
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/LICENSE +21 -0
- package/README.md +79 -0
- package/bin/agentflow.mjs +332 -0
- package/orchestrator.js +1585 -0
- package/package.json +64 -0
- package/scripts/scaffold-agent-configs.mjs +100 -0
- package/scripts/scaffold-openspec-change.mjs +84 -0
- package/scripts/update-skill-registry.mjs +174 -0
- package/src/ink/app.mjs +240 -0
- package/src/ink/index.mjs +400 -0
- package/templates/en/.atl/skill-registry.md +27 -0
- package/templates/en/.claude/README.md +7 -0
- package/templates/en/.claude/skills/orchestrator-apply/SKILL.md +31 -0
- package/templates/en/.claude/skills/orchestrator-archive/SKILL.md +26 -0
- package/templates/en/.claude/skills/orchestrator-design/SKILL.md +27 -0
- package/templates/en/.claude/skills/orchestrator-explore/SKILL.md +29 -0
- package/templates/en/.claude/skills/orchestrator-init/SKILL.md +32 -0
- package/templates/en/.claude/skills/orchestrator-memory/SKILL.md +26 -0
- package/templates/en/.claude/skills/orchestrator-openspec/SKILL.md +35 -0
- package/templates/en/.claude/skills/orchestrator-propose/SKILL.md +26 -0
- package/templates/en/.claude/skills/orchestrator-queue-planning/SKILL.md +31 -0
- package/templates/en/.claude/skills/orchestrator-spec/SKILL.md +27 -0
- package/templates/en/.claude/skills/orchestrator-tasks/SKILL.md +27 -0
- package/templates/en/.claude/skills/orchestrator-verify/SKILL.md +27 -0
- package/templates/en/.codex/README.md +7 -0
- package/templates/en/.opencode/README.md +7 -0
- package/templates/en/AGENT-CONFIG.md +75 -0
- package/templates/en/CLAUDE.md +91 -0
- package/templates/en/ENGRAM.md +50 -0
- package/templates/en/ORCHESTRATOR.md +192 -0
- package/templates/en/PROJECT.md +70 -0
- package/templates/en/QUEUE.md +17 -0
- package/templates/en/README.md +188 -0
- package/templates/en/agents/ABACUS.md +36 -0
- package/templates/en/agents/BACKEND.md +37 -0
- package/templates/en/agents/CODEX.md +45 -0
- package/templates/en/agents/CURSOR.md +37 -0
- package/templates/en/agents/FRONTEND.md +36 -0
- package/templates/en/agents/GEMINI.md +37 -0
- package/templates/en/agents/OPENCODE.md +41 -0
- package/templates/en/docs/README.md +14 -0
- package/templates/en/docs/agents.md +33 -0
- package/templates/en/docs/architecture.md +43 -0
- package/templates/en/docs/components.md +14 -0
- package/templates/en/docs/engram.md +16 -0
- package/templates/en/docs/openspec.md +32 -0
- package/templates/en/docs/usage.md +66 -0
- package/templates/en/openspec/FLOW.md +24 -0
- package/templates/en/openspec/README.md +29 -0
- package/templates/en/openspec/changes/.gitkeep +1 -0
- package/templates/en/openspec/changes/archive/.gitkeep +1 -0
- package/templates/en/openspec/specs/.gitkeep +1 -0
- package/templates/en/openspec/templates/archive-report.md +21 -0
- package/templates/en/openspec/templates/change-metadata.yaml +9 -0
- package/templates/en/openspec/templates/design.md +26 -0
- package/templates/en/openspec/templates/proposal.md +27 -0
- package/templates/en/openspec/templates/spec.md +18 -0
- package/templates/en/openspec/templates/tasks.md +14 -0
- package/templates/en/openspec/templates/verify-report.md +21 -0
- package/templates/en/orchestrator.config.json +99 -0
- package/templates/es/.atl/skill-registry.md +133 -0
- package/templates/es/.claude/README.md +7 -0
- package/templates/es/.claude/skills/orchestrator-apply/SKILL.md +32 -0
- package/templates/es/.claude/skills/orchestrator-archive/SKILL.md +28 -0
- package/templates/es/.claude/skills/orchestrator-design/SKILL.md +32 -0
- package/templates/es/.claude/skills/orchestrator-explore/SKILL.md +31 -0
- package/templates/es/.claude/skills/orchestrator-init/SKILL.md +32 -0
- package/templates/es/.claude/skills/orchestrator-memory/SKILL.md +31 -0
- package/templates/es/.claude/skills/orchestrator-openspec/SKILL.md +55 -0
- package/templates/es/.claude/skills/orchestrator-propose/SKILL.md +33 -0
- package/templates/es/.claude/skills/orchestrator-queue-planning/SKILL.md +35 -0
- package/templates/es/.claude/skills/orchestrator-spec/SKILL.md +28 -0
- package/templates/es/.claude/skills/orchestrator-tasks/SKILL.md +32 -0
- package/templates/es/.claude/skills/orchestrator-verify/SKILL.md +31 -0
- package/templates/es/.codex/README.md +7 -0
- package/templates/es/.opencode/README.md +7 -0
- package/templates/es/AGENT-CONFIG.md +83 -0
- package/templates/es/CLAUDE.md +136 -0
- package/templates/es/ENGRAM.md +70 -0
- package/templates/es/ORCHESTRATOR.md +199 -0
- package/templates/es/PROJECT.md +237 -0
- package/templates/es/QUEUE.md +17 -0
- package/templates/es/README.md +568 -0
- package/templates/es/agents/ABACUS.md +25 -0
- package/templates/es/agents/BACKEND.md +28 -0
- package/templates/es/agents/CODEX.md +37 -0
- package/templates/es/agents/CURSOR.md +27 -0
- package/templates/es/agents/FRONTEND.md +29 -0
- package/templates/es/agents/GEMINI.md +26 -0
- package/templates/es/agents/OPENCODE.md +32 -0
- package/templates/es/docs/README.md +12 -0
- package/templates/es/docs/agents.md +57 -0
- package/templates/es/docs/architecture.md +41 -0
- package/templates/es/docs/components.md +33 -0
- package/templates/es/docs/engram.md +30 -0
- package/templates/es/docs/openspec.md +34 -0
- package/templates/es/docs/usage.md +54 -0
- package/templates/es/openspec/FLOW.md +139 -0
- package/templates/es/openspec/README.md +77 -0
- package/templates/es/openspec/changes/.gitkeep +1 -0
- package/templates/es/openspec/changes/archive/.gitkeep +1 -0
- package/templates/es/openspec/specs/.gitkeep +1 -0
- package/templates/es/openspec/templates/archive-report.md +23 -0
- package/templates/es/openspec/templates/change-metadata.yaml +9 -0
- package/templates/es/openspec/templates/design.md +33 -0
- package/templates/es/openspec/templates/proposal.md +36 -0
- package/templates/es/openspec/templates/spec.md +33 -0
- package/templates/es/openspec/templates/tasks.md +22 -0
- package/templates/es/openspec/templates/verify-report.md +24 -0
- package/templates/es/orchestrator.config.json +99 -0
package/orchestrator.js
ADDED
|
@@ -0,0 +1,1585 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// Orchestrator MultiAgents TUI
|
|
4
|
+
// Dispatch tasks to multiple AI coding agents from a single dashboard.
|
|
5
|
+
// Supports: Claude Code, Codex, Gemini CLI, OpenCode, Cursor, Abacus AI
|
|
6
|
+
// Usage: node orchestrator.js [options]
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
const blessed = require("blessed");
|
|
10
|
+
const { spawn } = require("child_process");
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
const WORKSPACE = process.env.ORCHESTRATOR_WORKSPACE
|
|
15
|
+
? path.resolve(process.env.ORCHESTRATOR_WORKSPACE)
|
|
16
|
+
: path.resolve(process.cwd());
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// CONFIGURATION — loaded from orchestrator.config.json
|
|
20
|
+
// ============================================================================
|
|
21
|
+
const CONFIG_FILE = path.join(WORKSPACE, "orchestrator.config.json");
|
|
22
|
+
|
|
23
|
+
const CONFIG_TEMPLATE = {
|
|
24
|
+
projectName: "My Project",
|
|
25
|
+
maxConcurrent: 5,
|
|
26
|
+
pollIntervalSeconds: 30,
|
|
27
|
+
taskTimeoutMinutes: 30,
|
|
28
|
+
repos: {
|
|
29
|
+
backend: "/path/to/backend",
|
|
30
|
+
frontend: "/path/to/frontend",
|
|
31
|
+
},
|
|
32
|
+
agents: {
|
|
33
|
+
Backend: {
|
|
34
|
+
cli: "claude",
|
|
35
|
+
defaultRepo: "backend",
|
|
36
|
+
model: "sonnet",
|
|
37
|
+
instructionsFile: "agents/BACKEND.md",
|
|
38
|
+
},
|
|
39
|
+
Frontend: {
|
|
40
|
+
cli: "claude",
|
|
41
|
+
defaultRepo: "frontend",
|
|
42
|
+
model: "sonnet",
|
|
43
|
+
instructionsFile: "agents/FRONTEND.md",
|
|
44
|
+
},
|
|
45
|
+
Codex: {
|
|
46
|
+
cli: "codex",
|
|
47
|
+
defaultRepo: "backend",
|
|
48
|
+
instructionsFile: "agents/CODEX.md",
|
|
49
|
+
},
|
|
50
|
+
Gemini: {
|
|
51
|
+
cli: "gemini",
|
|
52
|
+
defaultRepo: "backend",
|
|
53
|
+
instructionsFile: "agents/GEMINI.md",
|
|
54
|
+
},
|
|
55
|
+
OpenCode: {
|
|
56
|
+
cli: "opencode",
|
|
57
|
+
defaultRepo: "backend",
|
|
58
|
+
instructionsFile: "agents/OPENCODE.md",
|
|
59
|
+
},
|
|
60
|
+
Cursor: {
|
|
61
|
+
cli: "cursor",
|
|
62
|
+
defaultRepo: "backend",
|
|
63
|
+
instructionsFile: "agents/CURSOR.md",
|
|
64
|
+
},
|
|
65
|
+
Abacus: {
|
|
66
|
+
cli: "abacusai",
|
|
67
|
+
defaultRepo: "backend",
|
|
68
|
+
instructionsFile: "agents/ABACUS.md",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Handle --init flag BEFORE the existence check, otherwise `--init` on a fresh
|
|
74
|
+
// checkout hits the "config not found" exit and can never create the file.
|
|
75
|
+
if (process.argv.includes("--init")) {
|
|
76
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
77
|
+
console.log(
|
|
78
|
+
"La configuración ya existe. Elimina orchestrator.config.json para reinicializar.",
|
|
79
|
+
);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
CONFIG_FILE,
|
|
84
|
+
JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n",
|
|
85
|
+
"utf-8",
|
|
86
|
+
);
|
|
87
|
+
console.log(
|
|
88
|
+
`Se creó ${CONFIG_FILE}\nEdítalo para que coincida con tus repos y agentes, luego ejecuta: node orchestrator.js`,
|
|
89
|
+
);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
94
|
+
console.log(`
|
|
95
|
+
Orchestrator Multi-Agents TUI
|
|
96
|
+
|
|
97
|
+
Usage: node orchestrator.js [options]
|
|
98
|
+
|
|
99
|
+
Options:
|
|
100
|
+
--init Generate the default orchestrator.config.json
|
|
101
|
+
--headless Run only the engine, without the Blessed UI
|
|
102
|
+
--paused Start paused
|
|
103
|
+
--yolo Enable explicit bypass/aggressive mode for this session
|
|
104
|
+
--max-budget=N Stop after spending $N
|
|
105
|
+
--help Show this help
|
|
106
|
+
|
|
107
|
+
Keyboard:
|
|
108
|
+
S Start/resume P Pause/resume
|
|
109
|
+
R Reload queue Q Quit
|
|
110
|
+
`);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
115
|
+
console.error(
|
|
116
|
+
`No se encontró la configuración: ${CONFIG_FILE}\nEjecuta: node orchestrator.js --init`,
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
122
|
+
|
|
123
|
+
const QUEUE_FILE = path.join(WORKSPACE, "QUEUE.md");
|
|
124
|
+
const LOG_DIR = path.join(WORKSPACE, "logs");
|
|
125
|
+
|
|
126
|
+
const REPOS = config.repos || {};
|
|
127
|
+
const AGENTS = config.agents || {};
|
|
128
|
+
const PROJECT_NAME = config.projectName || "Orchestrator Multi-Agents";
|
|
129
|
+
const WORKSPACE_LANGUAGE = ["en", "es"].includes(config.workspaceLanguage)
|
|
130
|
+
? config.workspaceLanguage
|
|
131
|
+
: "es";
|
|
132
|
+
const TEXT = {
|
|
133
|
+
es: {
|
|
134
|
+
configExists:
|
|
135
|
+
"La configuración ya existe. Elimina orchestrator.config.json para reinicializar.",
|
|
136
|
+
configCreated: (file) =>
|
|
137
|
+
`Se creó ${file}\nEdítalo para que coincida con tus repos y agentes, luego ejecuta: node orchestrator.js`,
|
|
138
|
+
configMissing: (file) =>
|
|
139
|
+
`No se encontró la configuración: ${file}\nEjecuta: node orchestrator.js --init`,
|
|
140
|
+
usage: "Uso",
|
|
141
|
+
options: "Opciones",
|
|
142
|
+
keyboard: "Teclado",
|
|
143
|
+
headlessHelp: "Ejecuta solo el motor, sin la UI blessed",
|
|
144
|
+
pausedHelp: "Inicia en pausa (presiona S para comenzar)",
|
|
145
|
+
yoloHelp: "Activa bypass/agresivo para una sesión explícita",
|
|
146
|
+
budgetHelp: "Se detiene al gastar $N",
|
|
147
|
+
helpHelp: "Muestra esta ayuda",
|
|
148
|
+
initHelp: "Genera orchestrator.config.json por defecto",
|
|
149
|
+
startResume: "Iniciar/reanudar",
|
|
150
|
+
pauseResume: "Pausar/reanudar",
|
|
151
|
+
reloadQueue: "Recargar cola",
|
|
152
|
+
quit: "Salir",
|
|
153
|
+
summary: "Resumen de sesión",
|
|
154
|
+
duration: "Duración",
|
|
155
|
+
completed: "Completadas",
|
|
156
|
+
tasks: "tareas",
|
|
157
|
+
cost: "Costo",
|
|
158
|
+
resumed: "REANUDADO",
|
|
159
|
+
paused: "PAUSADO",
|
|
160
|
+
running: "EJECUTANDO",
|
|
161
|
+
busy: "OCUPADO",
|
|
162
|
+
idle: "EN ESPERA",
|
|
163
|
+
queueReloaded: (count) => `Cola recargada: ${count} tareas`,
|
|
164
|
+
quitRequested: "Cierre solicitado desde Ink",
|
|
165
|
+
starting: (name) => `${name} iniciando`,
|
|
166
|
+
loadedCompleted: (count) =>
|
|
167
|
+
`Se cargaron ${count} tareas completadas desde QUEUE.md`,
|
|
168
|
+
queue: "COLA",
|
|
169
|
+
pending: "pendientes",
|
|
170
|
+
empty: "(vacía)",
|
|
171
|
+
after: "después de",
|
|
172
|
+
quotaLimit: "LÍMITE DE CUOTA",
|
|
173
|
+
retryAt: (time, remaining) =>
|
|
174
|
+
`reintenta a las ${time} (${remaining} min)`,
|
|
175
|
+
log: "REGISTRO",
|
|
176
|
+
controls: "Seguir Pausa Recargar Quitar",
|
|
177
|
+
},
|
|
178
|
+
en: {
|
|
179
|
+
configExists:
|
|
180
|
+
"Configuration already exists. Delete orchestrator.config.json to reinitialize.",
|
|
181
|
+
configCreated: (file) =>
|
|
182
|
+
`Created ${file}\nEdit it to match your repos and agents, then run: node orchestrator.js`,
|
|
183
|
+
configMissing: (file) =>
|
|
184
|
+
`Configuration not found: ${file}\nRun: node orchestrator.js --init`,
|
|
185
|
+
usage: "Usage",
|
|
186
|
+
options: "Options",
|
|
187
|
+
keyboard: "Keyboard",
|
|
188
|
+
headlessHelp: "Run only the engine, without the Blessed UI",
|
|
189
|
+
pausedHelp: "Start paused (press S to begin)",
|
|
190
|
+
yoloHelp: "Enable explicit bypass/aggressive mode for this session",
|
|
191
|
+
budgetHelp: "Stop after spending $N",
|
|
192
|
+
helpHelp: "Show this help",
|
|
193
|
+
initHelp: "Generate the default orchestrator.config.json",
|
|
194
|
+
startResume: "Start/resume",
|
|
195
|
+
pauseResume: "Pause/resume",
|
|
196
|
+
reloadQueue: "Reload queue",
|
|
197
|
+
quit: "Quit",
|
|
198
|
+
summary: "Session summary",
|
|
199
|
+
duration: "Duration",
|
|
200
|
+
completed: "Completed",
|
|
201
|
+
tasks: "tasks",
|
|
202
|
+
cost: "Cost",
|
|
203
|
+
resumed: "RESUMED",
|
|
204
|
+
paused: "PAUSED",
|
|
205
|
+
running: "RUNNING",
|
|
206
|
+
busy: "BUSY",
|
|
207
|
+
idle: "IDLE",
|
|
208
|
+
queueReloaded: (count) => `Queue reloaded: ${count} tasks`,
|
|
209
|
+
quitRequested: "Quit requested from Ink",
|
|
210
|
+
starting: (name) => `${name} starting`,
|
|
211
|
+
loadedCompleted: (count) => `Loaded ${count} completed tasks from QUEUE.md`,
|
|
212
|
+
queue: "QUEUE",
|
|
213
|
+
pending: "pending",
|
|
214
|
+
empty: "(empty)",
|
|
215
|
+
after: "after",
|
|
216
|
+
quotaLimit: "QUOTA LIMIT",
|
|
217
|
+
retryAt: (time, remaining) => `retry at ${time} (${remaining} min)`,
|
|
218
|
+
log: "LOG",
|
|
219
|
+
controls: "Start Pause Reload Quit",
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const L = TEXT[WORKSPACE_LANGUAGE];
|
|
223
|
+
|
|
224
|
+
// CLI args
|
|
225
|
+
const argv = process.argv.slice(2);
|
|
226
|
+
const CLI = {
|
|
227
|
+
paused: argv.includes("--paused"),
|
|
228
|
+
headless: argv.includes("--headless"),
|
|
229
|
+
yolo: argv.includes("--yolo"),
|
|
230
|
+
help: argv.includes("--help") || argv.includes("-h"),
|
|
231
|
+
maxBudget:
|
|
232
|
+
parseFloat(
|
|
233
|
+
argv.find((a) => a.startsWith("--max-budget="))?.split("=")[1] || "0",
|
|
234
|
+
) || 0,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (CLI.help) {
|
|
238
|
+
console.log(`
|
|
239
|
+
${PROJECT_NAME} TUI
|
|
240
|
+
|
|
241
|
+
${L.usage}: node orchestrator.js [options]
|
|
242
|
+
|
|
243
|
+
${L.options}:
|
|
244
|
+
--init ${L.initHelp}
|
|
245
|
+
--headless ${L.headlessHelp}
|
|
246
|
+
--paused ${L.pausedHelp}
|
|
247
|
+
--yolo ${L.yoloHelp}
|
|
248
|
+
--max-budget=N ${L.budgetHelp}
|
|
249
|
+
--help ${L.helpHelp}
|
|
250
|
+
|
|
251
|
+
${L.keyboard}:
|
|
252
|
+
S ${L.startResume} P ${L.pauseResume}
|
|
253
|
+
R ${L.reloadQueue} Q ${L.quit}
|
|
254
|
+
`);
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const MAX_CONCURRENT = config.maxConcurrent || Object.keys(AGENTS).length;
|
|
259
|
+
const POLL_INTERVAL_MS = (config.pollIntervalSeconds || 30) * 1000;
|
|
260
|
+
const TASK_TIMEOUT_MS = (config.taskTimeoutMinutes || 30) * 60 * 1000;
|
|
261
|
+
const SKIP_PERMISSIONS =
|
|
262
|
+
process.env.SKIP_PERMISSIONS === "true" || CLI.yolo;
|
|
263
|
+
const PERMISSION_FLAGS = SKIP_PERMISSIONS
|
|
264
|
+
? ["--dangerously-skip-permissions"]
|
|
265
|
+
: ["--permission-mode", "default"];
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// LOCK FILE
|
|
269
|
+
// ============================================================================
|
|
270
|
+
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
271
|
+
const LOCK_FILE = path.join(LOG_DIR, "orchestrator.lock");
|
|
272
|
+
const STATE_FILE = path.join(LOG_DIR, "orchestrator-state.json");
|
|
273
|
+
const CONTROL_FILE = path.join(LOG_DIR, "orchestrator-control.json");
|
|
274
|
+
|
|
275
|
+
// Limpiar control.json orphan al iniciar (si el proceso anterior fechou mal)
|
|
276
|
+
if (fs.existsSync(CONTROL_FILE)) {
|
|
277
|
+
try {
|
|
278
|
+
const content = JSON.parse(fs.readFileSync(CONTROL_FILE, "utf-8"));
|
|
279
|
+
const age = Date.now() - (content.requestedAt || 0);
|
|
280
|
+
if (age > 5000) {
|
|
281
|
+
fs.unlinkSync(CONTROL_FILE);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
fs.unlinkSync(CONTROL_FILE);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (fs.existsSync(LOCK_FILE)) {
|
|
289
|
+
const lockPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10);
|
|
290
|
+
let running = false;
|
|
291
|
+
try {
|
|
292
|
+
process.kill(lockPid, 0);
|
|
293
|
+
running = true;
|
|
294
|
+
} catch {}
|
|
295
|
+
if (running) {
|
|
296
|
+
console.error(
|
|
297
|
+
`Orchestrator ya está ejecutándose (PID ${lockPid}). Ciérralo o elimina ${LOCK_FILE}`,
|
|
298
|
+
);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
fs.unlinkSync(LOCK_FILE);
|
|
302
|
+
}
|
|
303
|
+
fs.writeFileSync(LOCK_FILE, String(process.pid), "utf-8");
|
|
304
|
+
const cleanupLock = () => {
|
|
305
|
+
try {
|
|
306
|
+
fs.unlinkSync(LOCK_FILE);
|
|
307
|
+
} catch {}
|
|
308
|
+
};
|
|
309
|
+
const cleanupState = () => {
|
|
310
|
+
try {
|
|
311
|
+
fs.unlinkSync(STATE_FILE);
|
|
312
|
+
} catch {}
|
|
313
|
+
};
|
|
314
|
+
const cleanupControl = () => {
|
|
315
|
+
try {
|
|
316
|
+
fs.unlinkSync(CONTROL_FILE);
|
|
317
|
+
} catch {}
|
|
318
|
+
};
|
|
319
|
+
process.on("exit", cleanupLock);
|
|
320
|
+
process.on("exit", cleanupState);
|
|
321
|
+
process.on("exit", cleanupControl);
|
|
322
|
+
// Windows: process.on('exit') no siempre corre con Ctrl+C
|
|
323
|
+
// Usar handle uncaught para asegurar limpieza
|
|
324
|
+
process.on("uncaughtException", () => {
|
|
325
|
+
cleanupLock();
|
|
326
|
+
cleanupState();
|
|
327
|
+
cleanupControl();
|
|
328
|
+
});
|
|
329
|
+
// Signal handlers con limpieza sincrona forzada
|
|
330
|
+
const doCleanup = () => {
|
|
331
|
+
cleanupLock();
|
|
332
|
+
cleanupState();
|
|
333
|
+
cleanupControl();
|
|
334
|
+
};
|
|
335
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
336
|
+
process.on(sig, () => {
|
|
337
|
+
doCleanup();
|
|
338
|
+
// Forzar exit sincrono para Windows
|
|
339
|
+
process.exit(0);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ============================================================================
|
|
344
|
+
// STATE
|
|
345
|
+
// ============================================================================
|
|
346
|
+
const state = {
|
|
347
|
+
agents: {},
|
|
348
|
+
queue: [],
|
|
349
|
+
completed: [],
|
|
350
|
+
inProgress: [],
|
|
351
|
+
logs: [],
|
|
352
|
+
paused: CLI.paused,
|
|
353
|
+
startTime: Date.now(),
|
|
354
|
+
totalCost: 0,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
for (const name of Object.keys(AGENTS)) {
|
|
358
|
+
state.agents[name] = {
|
|
359
|
+
status: "idle",
|
|
360
|
+
task: null,
|
|
361
|
+
process: null,
|
|
362
|
+
startTime: null,
|
|
363
|
+
logFile: null,
|
|
364
|
+
output: "",
|
|
365
|
+
lastLine: "",
|
|
366
|
+
exitCode: null,
|
|
367
|
+
cost: null,
|
|
368
|
+
turns: 0,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// BLESSED SCREEN
|
|
374
|
+
// ============================================================================
|
|
375
|
+
const screen = CLI.headless
|
|
376
|
+
? null
|
|
377
|
+
: blessed.screen({
|
|
378
|
+
smartCSR: true,
|
|
379
|
+
title: PROJECT_NAME,
|
|
380
|
+
fullUnicode: true,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const dashboard =
|
|
384
|
+
CLI.headless || !screen
|
|
385
|
+
? null
|
|
386
|
+
: blessed.box({
|
|
387
|
+
parent: screen,
|
|
388
|
+
top: 0,
|
|
389
|
+
left: 0,
|
|
390
|
+
width: "100%",
|
|
391
|
+
height: "40%",
|
|
392
|
+
border: { type: "line" },
|
|
393
|
+
style: { border: { fg: "cyan" } },
|
|
394
|
+
label: ` {bold}{cyan-fg}${PROJECT_NAME.toUpperCase()}{/cyan-fg}{/bold} `,
|
|
395
|
+
tags: true,
|
|
396
|
+
scrollable: true,
|
|
397
|
+
alwaysScroll: true,
|
|
398
|
+
keys: true,
|
|
399
|
+
scrollbar: { style: { bg: "cyan" } },
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const agentNames = Object.keys(AGENTS);
|
|
403
|
+
const agentBoxes = {};
|
|
404
|
+
const panelWidth = Math.max(1, Math.floor(100 / Math.max(1, agentNames.length)));
|
|
405
|
+
|
|
406
|
+
if (!CLI.headless && screen) {
|
|
407
|
+
agentNames.forEach((name, i) => {
|
|
408
|
+
const isLast = i === agentNames.length - 1;
|
|
409
|
+
agentBoxes[name] = blessed.box({
|
|
410
|
+
parent: screen,
|
|
411
|
+
top: "40%",
|
|
412
|
+
left: `${i * panelWidth}%`,
|
|
413
|
+
width: isLast ? `${100 - i * panelWidth}%` : `${panelWidth}%`,
|
|
414
|
+
height: "60%",
|
|
415
|
+
border: { type: "line" },
|
|
416
|
+
style: { border: { fg: "gray" } },
|
|
417
|
+
label: ` {bold}${name}{/bold} {gray-fg}EN ESPERA{/gray-fg} `,
|
|
418
|
+
tags: true,
|
|
419
|
+
scrollable: true,
|
|
420
|
+
alwaysScroll: true,
|
|
421
|
+
scrollbar: { style: { bg: "gray" } },
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ============================================================================
|
|
427
|
+
// HELPERS
|
|
428
|
+
// ============================================================================
|
|
429
|
+
function timestamp() {
|
|
430
|
+
return new Date().toLocaleTimeString("es-HN", { hour12: false });
|
|
431
|
+
}
|
|
432
|
+
function datestamp() {
|
|
433
|
+
return new Date().toISOString().slice(0, 10);
|
|
434
|
+
}
|
|
435
|
+
function formatDuration(s) {
|
|
436
|
+
if (s < 60) return `${s}s`;
|
|
437
|
+
const m = Math.floor(s / 60);
|
|
438
|
+
if (m < 60) return `${m}m${s % 60 > 0 ? (s % 60) + "s" : ""}`;
|
|
439
|
+
return `${Math.floor(m / 60)}h${m % 60}m`;
|
|
440
|
+
}
|
|
441
|
+
function elapsedSince(t) {
|
|
442
|
+
return t ? formatDuration(Math.round((Date.now() - t) / 1000)) : "--";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function log(tag, msg) {
|
|
446
|
+
const entry = `[${timestamp()}] [${tag}] ${msg}`;
|
|
447
|
+
state.logs.push(entry);
|
|
448
|
+
if (state.logs.length > 100) state.logs.shift();
|
|
449
|
+
fs.appendFileSync(
|
|
450
|
+
path.join(LOG_DIR, `orchestrator-${datestamp()}.log`),
|
|
451
|
+
entry + "\n",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function persistState() {
|
|
456
|
+
const snapshot = {
|
|
457
|
+
projectName: PROJECT_NAME,
|
|
458
|
+
workspaceLanguage: WORKSPACE_LANGUAGE,
|
|
459
|
+
paused: state.paused,
|
|
460
|
+
startTime: state.startTime,
|
|
461
|
+
totalCost: state.totalCost,
|
|
462
|
+
queue: state.queue,
|
|
463
|
+
completed: state.completed,
|
|
464
|
+
inProgress: state.inProgress,
|
|
465
|
+
logs: state.logs.slice(-20),
|
|
466
|
+
updatedAt: Date.now(),
|
|
467
|
+
pid: process.pid,
|
|
468
|
+
agents: Object.fromEntries(
|
|
469
|
+
Object.entries(state.agents).map(([name, ag]) => [
|
|
470
|
+
name,
|
|
471
|
+
{
|
|
472
|
+
status: ag.status,
|
|
473
|
+
task: ag.task,
|
|
474
|
+
startTime: ag.startTime,
|
|
475
|
+
lastLine: ag.lastLine,
|
|
476
|
+
exitCode: ag.exitCode,
|
|
477
|
+
cost: ag.cost,
|
|
478
|
+
turns: ag.turns,
|
|
479
|
+
},
|
|
480
|
+
]),
|
|
481
|
+
),
|
|
482
|
+
};
|
|
483
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function consumeControlCommand() {
|
|
487
|
+
if (!fs.existsSync(CONTROL_FILE)) return null;
|
|
488
|
+
try {
|
|
489
|
+
const payload = JSON.parse(fs.readFileSync(CONTROL_FILE, "utf-8"));
|
|
490
|
+
fs.unlinkSync(CONTROL_FILE);
|
|
491
|
+
return payload;
|
|
492
|
+
} catch {
|
|
493
|
+
try {
|
|
494
|
+
fs.unlinkSync(CONTROL_FILE);
|
|
495
|
+
} catch {}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function stopAllAgents() {
|
|
501
|
+
for (const ag of Object.values(state.agents)) {
|
|
502
|
+
if (ag.process)
|
|
503
|
+
try {
|
|
504
|
+
ag.process.kill("SIGTERM");
|
|
505
|
+
} catch {}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function exitWithSummary() {
|
|
510
|
+
stopAllAgents();
|
|
511
|
+
if (!CLI.headless && screen) screen.destroy();
|
|
512
|
+
console.log(`\n${PROJECT_NAME} — ${L.summary}`);
|
|
513
|
+
console.log(` ${L.duration}: ${elapsedSince(state.startTime)}`);
|
|
514
|
+
console.log(` ${L.completed}: ${state.completed.length} ${L.tasks}`);
|
|
515
|
+
console.log(` ${L.cost}: $${state.totalCost.toFixed(2)}`);
|
|
516
|
+
for (const t of state.completed)
|
|
517
|
+
console.log(
|
|
518
|
+
` ✓ ${t.id} ${t.title} (${t.agent}, ${formatDuration(t.elapsed)})`,
|
|
519
|
+
);
|
|
520
|
+
process.exit(0);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function applyControlCommand(command) {
|
|
524
|
+
if (!command?.type) return;
|
|
525
|
+
switch (command.type) {
|
|
526
|
+
case "start":
|
|
527
|
+
if (state.paused) {
|
|
528
|
+
state.paused = false;
|
|
529
|
+
log("INFO", L.resumed);
|
|
530
|
+
}
|
|
531
|
+
scheduleNext();
|
|
532
|
+
renderDashboard();
|
|
533
|
+
break;
|
|
534
|
+
case "pause":
|
|
535
|
+
if (!state.paused) {
|
|
536
|
+
state.paused = true;
|
|
537
|
+
log("INFO", L.paused);
|
|
538
|
+
}
|
|
539
|
+
renderDashboard();
|
|
540
|
+
break;
|
|
541
|
+
case "reload":
|
|
542
|
+
reloadQueue();
|
|
543
|
+
log("INFO", L.queueReloaded(state.queue.length));
|
|
544
|
+
renderDashboard();
|
|
545
|
+
break;
|
|
546
|
+
case "quit":
|
|
547
|
+
log("INFO", L.quitRequested);
|
|
548
|
+
exitWithSummary();
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function escBl(s) {
|
|
554
|
+
return String(s == null ? "" : s)
|
|
555
|
+
.replace(/\{/g, "\\{")
|
|
556
|
+
.replace(/\}/g, "\\}");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function appendToAgent(name, text, raw = false) {
|
|
560
|
+
if (CLI.headless) return;
|
|
561
|
+
const box = agentBoxes[name];
|
|
562
|
+
if (!box) return;
|
|
563
|
+
const content = raw ? text : escBl(text);
|
|
564
|
+
try {
|
|
565
|
+
box.pushLine(content);
|
|
566
|
+
} catch {
|
|
567
|
+
// Blessed's tag parser can crash on malformed content that slipped past
|
|
568
|
+
// escaping (e.g. unclosed tags from agent output with `raw=true`). Fall
|
|
569
|
+
// back to fully-escaped content so the TUI stays alive.
|
|
570
|
+
try {
|
|
571
|
+
box.pushLine(escBl(String(text == null ? "" : text)));
|
|
572
|
+
} catch {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
while (box.getLines().length > 500) box.shiftLine(0);
|
|
577
|
+
box.setScrollPerc(100);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ============================================================================
|
|
581
|
+
// DASHBOARD RENDER
|
|
582
|
+
// ============================================================================
|
|
583
|
+
function renderDashboard() {
|
|
584
|
+
persistState();
|
|
585
|
+
if (CLI.headless || !dashboard || !screen) return;
|
|
586
|
+
const lines = [];
|
|
587
|
+
const up = elapsedSince(state.startTime);
|
|
588
|
+
const cost = state.totalCost > 0 ? `$${state.totalCost.toFixed(2)}` : "";
|
|
589
|
+
const mode = state.paused
|
|
590
|
+
? `{yellow-fg}${L.paused}{/yellow-fg}`
|
|
591
|
+
: `{green-fg}${L.running}{/green-fg}`;
|
|
592
|
+
|
|
593
|
+
lines.push(` ${datestamp()} ${timestamp()} ${WORKSPACE_LANGUAGE === "es" ? "activo" : "active"} ${up} ${cost} ${mode}`);
|
|
594
|
+
lines.push("");
|
|
595
|
+
|
|
596
|
+
for (const [name, ag] of Object.entries(state.agents)) {
|
|
597
|
+
const cfg = AGENTS[name];
|
|
598
|
+
let status, detail;
|
|
599
|
+
if (ag.status === "busy") {
|
|
600
|
+
status = `{yellow-fg}${L.busy}{/yellow-fg}`;
|
|
601
|
+
detail = `${ag.task?.id || "?"} ${(ag.task?.title || "").slice(0, 35)} (${elapsedSince(ag.startTime)})`;
|
|
602
|
+
} else {
|
|
603
|
+
status = `{gray-fg}${L.idle}{/gray-fg}`;
|
|
604
|
+
detail = ag.lastLine || "";
|
|
605
|
+
}
|
|
606
|
+
const dot =
|
|
607
|
+
ag.status === "busy" ? "{green-fg}●{/green-fg}" : "{gray-fg}○{/gray-fg}";
|
|
608
|
+
lines.push(` ${dot} {bold}${name}{/bold} ${status} ${detail}`);
|
|
609
|
+
}
|
|
610
|
+
lines.push("");
|
|
611
|
+
|
|
612
|
+
lines.push(
|
|
613
|
+
` {bold}${L.queue}{/bold} {gray-fg}(${state.queue.length} ${L.pending}){/gray-fg}`,
|
|
614
|
+
);
|
|
615
|
+
for (let i = 0; i < Math.min(state.queue.length, 5); i++) {
|
|
616
|
+
const t = state.queue[i];
|
|
617
|
+
const pri =
|
|
618
|
+
t.priority === "P1"
|
|
619
|
+
? "{red-fg}P1{/red-fg}"
|
|
620
|
+
: t.priority === "P2"
|
|
621
|
+
? "{yellow-fg}P2{/yellow-fg}"
|
|
622
|
+
: "{gray-fg}P3{/gray-fg}";
|
|
623
|
+
const dep = t.dependsOn
|
|
624
|
+
? ` {gray-fg}[${L.after} ${t.dependsOn}]{/gray-fg}`
|
|
625
|
+
: "";
|
|
626
|
+
lines.push(
|
|
627
|
+
` ${i + 1}. {bold}${escBl(t.id)}{/bold} ${escBl(String(t.title || "").slice(0, 35))} | ${escBl(t.agent)} | ${pri}${dep}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
if (state.queue.length === 0) lines.push(` {gray-fg}${L.empty}{/gray-fg}`);
|
|
631
|
+
|
|
632
|
+
const rlEntries = [...rateLimitedAgents.entries()].filter(
|
|
633
|
+
([, t]) => Date.now() < t,
|
|
634
|
+
);
|
|
635
|
+
if (rlEntries.length > 0) {
|
|
636
|
+
lines.push("");
|
|
637
|
+
lines.push(` {bold}${L.quotaLimit}{/bold}`);
|
|
638
|
+
for (const [name, cooldown] of rlEntries) {
|
|
639
|
+
const remaining = Math.ceil((cooldown - Date.now()) / 60000);
|
|
640
|
+
const retryAt = new Date(cooldown).toLocaleTimeString("es-HN", {
|
|
641
|
+
hour: "2-digit",
|
|
642
|
+
minute: "2-digit",
|
|
643
|
+
hour12: false,
|
|
644
|
+
});
|
|
645
|
+
lines.push(
|
|
646
|
+
` {yellow-fg}⏳{/yellow-fg} ${name} — ${L.retryAt(retryAt, remaining)}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
for (const [name, t] of rateLimitedAgents) {
|
|
651
|
+
if (Date.now() >= t) rateLimitedAgents.delete(name);
|
|
652
|
+
}
|
|
653
|
+
lines.push("");
|
|
654
|
+
|
|
655
|
+
lines.push(
|
|
656
|
+
` {bold}${L.completed.toUpperCase()}{/bold} {gray-fg}(${state.completed.length}){/gray-fg}`,
|
|
657
|
+
);
|
|
658
|
+
for (const t of state.completed.slice(-4)) {
|
|
659
|
+
const c = t.cost ? ` $${t.cost.toFixed(2)}` : "";
|
|
660
|
+
lines.push(
|
|
661
|
+
` {green-fg}✓{/green-fg} {bold}${escBl(t.id)}{/bold} ${escBl(String(t.title || "").slice(0, 30))} | ${escBl(t.agent)} | ${escBl(t.completedAt)} (${formatDuration(t.elapsed)})${escBl(c)}`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
lines.push("");
|
|
665
|
+
|
|
666
|
+
lines.push(` {bold}${L.log}{/bold}`);
|
|
667
|
+
for (const entry of state.logs.slice(-4)) {
|
|
668
|
+
lines.push(` {gray-fg}${escBl(entry)}{/gray-fg}`);
|
|
669
|
+
}
|
|
670
|
+
lines.push("");
|
|
671
|
+
lines.push(
|
|
672
|
+
` {cyan-fg}S{/cyan-fg} ${L.controls}`,
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
dashboard.setContent(lines.join("\n"));
|
|
676
|
+
|
|
677
|
+
for (const [name, ag] of Object.entries(state.agents)) {
|
|
678
|
+
const box = agentBoxes[name];
|
|
679
|
+
if (ag.status === "busy") {
|
|
680
|
+
box.style.border.fg = "yellow";
|
|
681
|
+
box.setLabel(
|
|
682
|
+
` {bold}${escBl(name)}{/bold} {yellow-fg}OCUPADO{/yellow-fg} ${escBl(ag.task?.id || "")} `,
|
|
683
|
+
);
|
|
684
|
+
} else {
|
|
685
|
+
box.style.border.fg = "gray";
|
|
686
|
+
box.setLabel(
|
|
687
|
+
` {bold}${escBl(name)}{/bold} {gray-fg}EN ESPERA{/gray-fg} `,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
screen.render();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ============================================================================
|
|
695
|
+
// QUEUE PARSER
|
|
696
|
+
// ============================================================================
|
|
697
|
+
function parseQueue() {
|
|
698
|
+
if (!fs.existsSync(QUEUE_FILE)) return [];
|
|
699
|
+
const content = fs.readFileSync(QUEUE_FILE, "utf-8");
|
|
700
|
+
const tasks = [];
|
|
701
|
+
let section = "";
|
|
702
|
+
for (const rawLine of content.split("\n")) {
|
|
703
|
+
const line = rawLine.trim();
|
|
704
|
+
if (line.startsWith("## Pending")) {
|
|
705
|
+
section = "pending";
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (line.startsWith("## In Progress")) {
|
|
709
|
+
section = "inprogress";
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (line.startsWith("## Completed")) {
|
|
713
|
+
section = "completed";
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (!line || line.startsWith("#") || line.startsWith(">")) continue;
|
|
717
|
+
if (section !== "pending") continue;
|
|
718
|
+
const parts = line.split("|").map((s) => s.trim());
|
|
719
|
+
if (parts.length < 5) continue;
|
|
720
|
+
const [id, title, agent, priority, repo, ...descParts] = parts;
|
|
721
|
+
const description = descParts.join("|").trim();
|
|
722
|
+
let dependsOn = null;
|
|
723
|
+
const depMatch = description.match(/>\s*after:(TASK-\d+)/i);
|
|
724
|
+
if (depMatch) dependsOn = depMatch[1];
|
|
725
|
+
tasks.push({
|
|
726
|
+
id: id.trim(),
|
|
727
|
+
title: title.trim(),
|
|
728
|
+
agent: agent.trim(),
|
|
729
|
+
priority: priority.trim(),
|
|
730
|
+
repo: repo.trim(),
|
|
731
|
+
description: description.replace(/>\s*after:TASK-\d+/i, "").trim(),
|
|
732
|
+
dependsOn,
|
|
733
|
+
status: "pending",
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return tasks;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function parseCompletedFromFile() {
|
|
740
|
+
if (!fs.existsSync(QUEUE_FILE)) return [];
|
|
741
|
+
const content = fs.readFileSync(QUEUE_FILE, "utf-8");
|
|
742
|
+
const ids = [];
|
|
743
|
+
let section = "";
|
|
744
|
+
for (const rawLine of content.split("\n")) {
|
|
745
|
+
const line = rawLine.trim();
|
|
746
|
+
if (line.startsWith("## Pending")) {
|
|
747
|
+
section = "pending";
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (line.startsWith("## In Progress")) {
|
|
751
|
+
section = "inprogress";
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (line.startsWith("## Completed")) {
|
|
755
|
+
section = "completed";
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (section !== "completed" || !line) continue;
|
|
759
|
+
const match = line.match(/^(TASK-\d+)/);
|
|
760
|
+
if (match)
|
|
761
|
+
ids.push({
|
|
762
|
+
id: match[1],
|
|
763
|
+
status: "completed",
|
|
764
|
+
title: "",
|
|
765
|
+
agent: "",
|
|
766
|
+
elapsed: 0,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return ids;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const loggedUnknownAgents = new Set();
|
|
773
|
+
function reloadQueue() {
|
|
774
|
+
state.queue = parseQueue();
|
|
775
|
+
const activeIds = new Set([
|
|
776
|
+
...state.inProgress.map((t) => t.id),
|
|
777
|
+
...state.completed.map((t) => t.id),
|
|
778
|
+
]);
|
|
779
|
+
state.queue = state.queue.filter((t) => {
|
|
780
|
+
if (activeIds.has(t.id)) return false;
|
|
781
|
+
if (!AGENTS[t.agent]) {
|
|
782
|
+
const key = `${t.id}:${t.agent}`;
|
|
783
|
+
if (!loggedUnknownAgents.has(key)) {
|
|
784
|
+
log(
|
|
785
|
+
"SKIP",
|
|
786
|
+
`${t.id} skipped — agent "${t.agent}" not in orchestrator.config.json`,
|
|
787
|
+
);
|
|
788
|
+
loggedUnknownAgents.add(key);
|
|
789
|
+
}
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
const fails = failedTasks.get(t.id) || 0;
|
|
793
|
+
if (fails >= MAX_RETRIES) {
|
|
794
|
+
log("SKIP", `${t.id} skipped (permanently failed)`);
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
return true;
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ============================================================================
|
|
802
|
+
// BRIEF GENERATOR
|
|
803
|
+
// ============================================================================
|
|
804
|
+
function generateBrief(task) {
|
|
805
|
+
const agentCfg = AGENTS[task.agent];
|
|
806
|
+
const briefFile = path.join(WORKSPACE, "briefs", `${task.id}-BRIEF.md`);
|
|
807
|
+
let existingBrief = "";
|
|
808
|
+
if (fs.existsSync(briefFile))
|
|
809
|
+
existingBrief = fs.readFileSync(briefFile, "utf-8");
|
|
810
|
+
|
|
811
|
+
let agentInstructions = "";
|
|
812
|
+
if (agentCfg.instructionsFile) {
|
|
813
|
+
const instrFile = path.join(WORKSPACE, agentCfg.instructionsFile);
|
|
814
|
+
if (fs.existsSync(instrFile))
|
|
815
|
+
agentInstructions = fs.readFileSync(instrFile, "utf-8");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
let protocolRules = "";
|
|
819
|
+
const protocolFile = path.join(WORKSPACE, "AGENT-PROTOCOL.md");
|
|
820
|
+
if (fs.existsSync(protocolFile)) {
|
|
821
|
+
const content = fs.readFileSync(protocolFile, "utf-8");
|
|
822
|
+
const match = content.match(
|
|
823
|
+
/## \d+\.\s*(?:Rules|Reglas)[\s\S]*?(?=\n## \d|$)/i,
|
|
824
|
+
);
|
|
825
|
+
if (match) protocolRules = match[0];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let taskEntry = "";
|
|
829
|
+
const tasksFile = path.join(WORKSPACE, "TASKS.md");
|
|
830
|
+
if (fs.existsSync(tasksFile)) {
|
|
831
|
+
const content = fs.readFileSync(tasksFile, "utf-8");
|
|
832
|
+
const taskMatch = content.match(
|
|
833
|
+
new RegExp(`### ${task.id}[\\s\\S]*?(?=\\n### TASK-|\\n---\\n|$)`),
|
|
834
|
+
);
|
|
835
|
+
if (taskMatch) taskEntry = taskMatch[0];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Project plan — if `<projectName>-plan.md` or `PLAN.md` exists in the workspace,
|
|
839
|
+
// inject it as shared context so every agent sees the big-picture plan.
|
|
840
|
+
let projectPlan = "";
|
|
841
|
+
const planCandidates = [
|
|
842
|
+
path.join(
|
|
843
|
+
WORKSPACE,
|
|
844
|
+
`${PROJECT_NAME.toLowerCase().replace(/\s+/g, "-")}-plan.md`,
|
|
845
|
+
),
|
|
846
|
+
path.join(WORKSPACE, "PLAN.md"),
|
|
847
|
+
path.join(WORKSPACE, "plan.md"),
|
|
848
|
+
];
|
|
849
|
+
for (const p of planCandidates) {
|
|
850
|
+
if (fs.existsSync(p)) {
|
|
851
|
+
projectPlan = fs.readFileSync(p, "utf-8");
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const repoDir = REPOS[task.repo] || REPOS[agentCfg.defaultRepo] || ".";
|
|
857
|
+
const progressFile = path.join(
|
|
858
|
+
WORKSPACE,
|
|
859
|
+
"progress",
|
|
860
|
+
`PROGRESS-${task.agent}.md`,
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
return `
|
|
864
|
+
# Agent: ${task.agent}
|
|
865
|
+
# Task: ${task.id} — ${task.title}
|
|
866
|
+
# Repository: ${task.repo}
|
|
867
|
+
# CWD: ${repoDir}
|
|
868
|
+
# Priority: ${task.priority}
|
|
869
|
+
# Workspace: ${WORKSPACE}
|
|
870
|
+
# Progress file: ${progressFile}
|
|
871
|
+
|
|
872
|
+
${projectPlan ? `## Project Plan (big picture — use as context, don't try to do everything)\n${projectPlan}\n` : ""}
|
|
873
|
+
${agentInstructions ? `## Agent Instructions\n${agentInstructions}` : ""}
|
|
874
|
+
${protocolRules ? `## Protocol Rules\n${protocolRules}` : ""}
|
|
875
|
+
|
|
876
|
+
## Task Description
|
|
877
|
+
${task.title}
|
|
878
|
+
${task.description}
|
|
879
|
+
|
|
880
|
+
${taskEntry ? `## Full Task Spec\n${taskEntry}` : ""}
|
|
881
|
+
${existingBrief ? `## Detailed Brief\n${existingBrief}` : ""}
|
|
882
|
+
|
|
883
|
+
## Rules
|
|
884
|
+
1. NEVER run git commit or git push. Source control is handled manually by the user outside this task.
|
|
885
|
+
2. Focus ONLY on this task
|
|
886
|
+
3. Update your progress file at ${progressFile} when done
|
|
887
|
+
|
|
888
|
+
## Completion Report (MANDATORY)
|
|
889
|
+
Your LAST message MUST include:
|
|
890
|
+
\`\`\`
|
|
891
|
+
TASK_REPORT
|
|
892
|
+
status: completed | failed | blocked
|
|
893
|
+
files_modified: list or "none"
|
|
894
|
+
files_created: list or "none"
|
|
895
|
+
files_deleted: list or "none"
|
|
896
|
+
summary: 1-3 sentences
|
|
897
|
+
issues: problems or "none"
|
|
898
|
+
TASK_REPORT_END
|
|
899
|
+
\`\`\`
|
|
900
|
+
`.trim();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ============================================================================
|
|
904
|
+
// CLI BUILDERS — maps agent CLI type to spawn command + args
|
|
905
|
+
// ============================================================================
|
|
906
|
+
function buildCliCommand(agentCfg, task, prompt) {
|
|
907
|
+
const cli = agentCfg.cli;
|
|
908
|
+
|
|
909
|
+
// Custom command override — for agents with non-standard CLIs
|
|
910
|
+
if (agentCfg.command) {
|
|
911
|
+
const parts = agentCfg.command.split(" ");
|
|
912
|
+
return { cmd: parts[0], args: parts.slice(1) };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
switch (cli) {
|
|
916
|
+
case "claude":
|
|
917
|
+
return {
|
|
918
|
+
cmd: "claude",
|
|
919
|
+
args: [
|
|
920
|
+
"-p",
|
|
921
|
+
"--output-format",
|
|
922
|
+
"stream-json",
|
|
923
|
+
"--verbose",
|
|
924
|
+
...PERMISSION_FLAGS,
|
|
925
|
+
...(agentCfg.model ? ["--model", agentCfg.model] : []),
|
|
926
|
+
"--add-dir",
|
|
927
|
+
WORKSPACE,
|
|
928
|
+
"--name",
|
|
929
|
+
`${task.agent}-${task.id}`,
|
|
930
|
+
],
|
|
931
|
+
};
|
|
932
|
+
case "codex":
|
|
933
|
+
return {
|
|
934
|
+
cmd: "codex",
|
|
935
|
+
args: [
|
|
936
|
+
"exec",
|
|
937
|
+
...(agentCfg.model ? ["--model", agentCfg.model] : []),
|
|
938
|
+
...(CLI.yolo ? ["--dangerously-bypass-approvals-and-sandbox"] : []),
|
|
939
|
+
"--add-dir",
|
|
940
|
+
WORKSPACE,
|
|
941
|
+
"-",
|
|
942
|
+
],
|
|
943
|
+
};
|
|
944
|
+
case "opencode":
|
|
945
|
+
return {
|
|
946
|
+
cmd: "opencode",
|
|
947
|
+
args: [
|
|
948
|
+
"run",
|
|
949
|
+
...(agentCfg.model ? ["--model", agentCfg.model] : []),
|
|
950
|
+
"--format",
|
|
951
|
+
"json",
|
|
952
|
+
"--pure",
|
|
953
|
+
...(CLI.yolo ? ["--dangerously-skip-permissions"] : []),
|
|
954
|
+
],
|
|
955
|
+
};
|
|
956
|
+
case "gemini":
|
|
957
|
+
return {
|
|
958
|
+
cmd: "gemini",
|
|
959
|
+
args: [
|
|
960
|
+
...(agentCfg.model ? ["--model", agentCfg.model] : []),
|
|
961
|
+
...(CLI.yolo ? ["--approval-mode=yolo"] : []),
|
|
962
|
+
"--include-directories",
|
|
963
|
+
WORKSPACE,
|
|
964
|
+
"--output-format",
|
|
965
|
+
"stream-json",
|
|
966
|
+
"-p",
|
|
967
|
+
"execute",
|
|
968
|
+
],
|
|
969
|
+
};
|
|
970
|
+
case "cursor":
|
|
971
|
+
return {
|
|
972
|
+
cmd: "agent",
|
|
973
|
+
args: [
|
|
974
|
+
...(agentCfg.model ? ["--model", agentCfg.model] : []),
|
|
975
|
+
...(CLI.yolo ? ["--yolo"] : []),
|
|
976
|
+
],
|
|
977
|
+
};
|
|
978
|
+
case "abacusai": {
|
|
979
|
+
const promptFile = path.join(LOG_DIR, `abacus-prompt-${task.id}.txt`);
|
|
980
|
+
fs.writeFileSync(promptFile, prompt, "utf-8");
|
|
981
|
+
const isWin = process.platform === "win32";
|
|
982
|
+
if (isWin) {
|
|
983
|
+
return {
|
|
984
|
+
cmd: "cmd",
|
|
985
|
+
args: [
|
|
986
|
+
"/c",
|
|
987
|
+
`type "${promptFile}" | abacusai -p --output-format stream-json --permission-mode ${CLI.yolo ? "yolo" : "default"} ${CLI.yolo ? "--dangerously-skip-permissions --auto-accept-edits" : ""} --add-dir "${WORKSPACE}"`,
|
|
988
|
+
],
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
cmd: "sh",
|
|
993
|
+
args: [
|
|
994
|
+
"-c",
|
|
995
|
+
`cat "${promptFile}" | abacusai -p --output-format stream-json --permission-mode ${CLI.yolo ? "yolo" : "default"} ${CLI.yolo ? "--dangerously-skip-permissions --auto-accept-edits" : ""} --add-dir "${WORKSPACE}"`,
|
|
996
|
+
],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
default:
|
|
1000
|
+
// Generic: assume CLI accepts prompt via stdin
|
|
1001
|
+
return { cmd: cli, args: agentCfg.args || [] };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ============================================================================
|
|
1006
|
+
// AGENT LAUNCHER
|
|
1007
|
+
// ============================================================================
|
|
1008
|
+
function launchAgent(task) {
|
|
1009
|
+
const agentName = task.agent;
|
|
1010
|
+
const ag = state.agents[agentName];
|
|
1011
|
+
const agentCfg = AGENTS[agentName];
|
|
1012
|
+
|
|
1013
|
+
if (!ag || !agentCfg) {
|
|
1014
|
+
log(
|
|
1015
|
+
"ERROR",
|
|
1016
|
+
`Agente desconocido en QUEUE: "${agentName}" — no está definido en orchestrator.config.json`,
|
|
1017
|
+
);
|
|
1018
|
+
failedTasks.set(task.id, MAX_RETRIES); // don't retry — config bug, not transient
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const repoDir = REPOS[task.repo] || REPOS[agentCfg.defaultRepo];
|
|
1023
|
+
|
|
1024
|
+
if (!repoDir || !fs.existsSync(repoDir)) {
|
|
1025
|
+
log("ERROR", `Repo not found: ${task.repo || agentCfg.defaultRepo}`);
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const prompt = generateBrief(task);
|
|
1030
|
+
const logFile = path.join(
|
|
1031
|
+
LOG_DIR,
|
|
1032
|
+
`${task.id}-${agentName}-${Date.now()}.log`,
|
|
1033
|
+
);
|
|
1034
|
+
const { cmd: cliCmd, args } = buildCliCommand(agentCfg, task, prompt);
|
|
1035
|
+
|
|
1036
|
+
log("START", `${agentName} (${cliCmd}) → ${task.id}: ${task.title}`);
|
|
1037
|
+
appendToAgent(
|
|
1038
|
+
agentName,
|
|
1039
|
+
`{cyan-fg}=== ${escBl(task.id)}: ${escBl(task.title)} ==={/cyan-fg}`,
|
|
1040
|
+
true,
|
|
1041
|
+
);
|
|
1042
|
+
appendToAgent(agentName, `{gray-fg}CWD: ${escBl(repoDir)}{/gray-fg}`, true);
|
|
1043
|
+
appendToAgent(agentName, "", true);
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
const proc = spawn(cliCmd, args, {
|
|
1047
|
+
cwd: repoDir,
|
|
1048
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1049
|
+
shell: true,
|
|
1050
|
+
env: { ...process.env },
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
proc.stdin.write(prompt);
|
|
1054
|
+
proc.stdin.end();
|
|
1055
|
+
|
|
1056
|
+
const timeout = setTimeout(() => {
|
|
1057
|
+
log("WARN", `${agentName} timed out on ${task.id}`);
|
|
1058
|
+
appendToAgent(agentName, "{red-fg}=== TIMEOUT ==={/red-fg}", true);
|
|
1059
|
+
try {
|
|
1060
|
+
proc.kill("SIGTERM");
|
|
1061
|
+
} catch {}
|
|
1062
|
+
}, TASK_TIMEOUT_MS);
|
|
1063
|
+
|
|
1064
|
+
const logStream = fs.createWriteStream(logFile, { flags: "a" });
|
|
1065
|
+
let lineBuffer = "";
|
|
1066
|
+
|
|
1067
|
+
proc.stdout.on("data", (data) => {
|
|
1068
|
+
const text = data.toString();
|
|
1069
|
+
logStream.write(text);
|
|
1070
|
+
lineBuffer += text;
|
|
1071
|
+
const lines = lineBuffer.split("\n");
|
|
1072
|
+
lineBuffer = lines.pop() || "";
|
|
1073
|
+
|
|
1074
|
+
for (const line of lines) {
|
|
1075
|
+
if (!line.trim()) continue;
|
|
1076
|
+
try {
|
|
1077
|
+
const event = JSON.parse(line);
|
|
1078
|
+
// Claude / AbacusAI stream-json events
|
|
1079
|
+
if (event.type === "assistant" && event.message?.content) {
|
|
1080
|
+
for (const block of event.message.content) {
|
|
1081
|
+
if (block.type === "text" && block.text) {
|
|
1082
|
+
const txt = block.text.trim();
|
|
1083
|
+
if (txt) {
|
|
1084
|
+
for (const l of txt.split("\n").slice(-3))
|
|
1085
|
+
appendToAgent(agentName, l.slice(0, 120));
|
|
1086
|
+
ag.lastLine = txt.split("\n").pop().slice(0, 80);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (block.type === "tool_use") {
|
|
1090
|
+
const detail =
|
|
1091
|
+
block.input?.command ||
|
|
1092
|
+
block.input?.file_path ||
|
|
1093
|
+
block.input?.pattern ||
|
|
1094
|
+
"";
|
|
1095
|
+
appendToAgent(
|
|
1096
|
+
agentName,
|
|
1097
|
+
`{yellow-fg}[${escBl(block.name)}]{/yellow-fg} ${escBl(detail.toString().slice(0, 80))}`,
|
|
1098
|
+
true,
|
|
1099
|
+
);
|
|
1100
|
+
ag.lastLine = `[${block.name}] ${detail.toString().slice(0, 60)}`;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (event.type === "result" && event.total_cost_usd)
|
|
1105
|
+
ag.cost = event.total_cost_usd;
|
|
1106
|
+
// OpenCode events
|
|
1107
|
+
if (event.type === "text" && event.part?.text)
|
|
1108
|
+
appendToAgent(agentName, event.part.text.slice(0, 120));
|
|
1109
|
+
if (event.type === "tool_use" && event.part?.tool)
|
|
1110
|
+
appendToAgent(
|
|
1111
|
+
agentName,
|
|
1112
|
+
`{yellow-fg}[${escBl(event.part.tool)}]{/yellow-fg}`,
|
|
1113
|
+
true,
|
|
1114
|
+
);
|
|
1115
|
+
} catch {
|
|
1116
|
+
const trimmed = line.trim();
|
|
1117
|
+
if (trimmed.length > 2) {
|
|
1118
|
+
appendToAgent(agentName, trimmed.slice(0, 120));
|
|
1119
|
+
ag.lastLine = trimmed.slice(0, 80);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
renderDashboard();
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
proc.stderr.on("data", (data) => {
|
|
1127
|
+
logStream.write(`[STDERR] ${data}`);
|
|
1128
|
+
const errText = data.toString().trim();
|
|
1129
|
+
if (errText)
|
|
1130
|
+
appendToAgent(
|
|
1131
|
+
agentName,
|
|
1132
|
+
`{red-fg}${escBl(errText.slice(0, 100))}{/red-fg}`,
|
|
1133
|
+
true,
|
|
1134
|
+
);
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
proc.on("close", (code) => {
|
|
1138
|
+
clearTimeout(timeout);
|
|
1139
|
+
logStream.end();
|
|
1140
|
+
ag.exitCode = code;
|
|
1141
|
+
code === 0
|
|
1142
|
+
? completeTask(task, agentName)
|
|
1143
|
+
: failTask(task, agentName, code);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
proc.on("error", (err) => {
|
|
1147
|
+
clearTimeout(timeout);
|
|
1148
|
+
logStream.end();
|
|
1149
|
+
log("ERROR", `${agentName}: ${err.message}`);
|
|
1150
|
+
appendToAgent(
|
|
1151
|
+
agentName,
|
|
1152
|
+
`{red-fg}ERROR: ${escBl(err.message)}{/red-fg}`,
|
|
1153
|
+
true,
|
|
1154
|
+
);
|
|
1155
|
+
state.inProgress = state.inProgress.filter((t) => t.id !== task.id);
|
|
1156
|
+
ag.status = "idle";
|
|
1157
|
+
ag.task = null;
|
|
1158
|
+
ag.process = null;
|
|
1159
|
+
ag.startTime = null;
|
|
1160
|
+
setTimeout(() => {
|
|
1161
|
+
scheduleNext();
|
|
1162
|
+
renderDashboard();
|
|
1163
|
+
}, 3000);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
ag.status = "busy";
|
|
1167
|
+
ag.task = task;
|
|
1168
|
+
ag.process = proc;
|
|
1169
|
+
ag.startTime = Date.now();
|
|
1170
|
+
ag.logFile = logFile;
|
|
1171
|
+
ag.cost = null;
|
|
1172
|
+
ag.turns = 0;
|
|
1173
|
+
task.status = "running";
|
|
1174
|
+
state.inProgress.push(task);
|
|
1175
|
+
renderDashboard();
|
|
1176
|
+
return true;
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
log("ERROR", `Failed to launch ${agentName}: ${err.message}`);
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ============================================================================
|
|
1184
|
+
// TASK LIFECYCLE
|
|
1185
|
+
// ============================================================================
|
|
1186
|
+
const MAX_RETRIES = 2;
|
|
1187
|
+
const MAX_RETRIES_RATE_LIMIT = 10;
|
|
1188
|
+
const failedTasks = new Map();
|
|
1189
|
+
const rateLimitedAgents = new Map();
|
|
1190
|
+
|
|
1191
|
+
function completeTask(task, agentName) {
|
|
1192
|
+
const ag = state.agents[agentName];
|
|
1193
|
+
const elapsed = ag.startTime
|
|
1194
|
+
? Math.round((Date.now() - ag.startTime) / 1000)
|
|
1195
|
+
: 0;
|
|
1196
|
+
if (ag.cost) state.totalCost += ag.cost;
|
|
1197
|
+
task.status = "completed";
|
|
1198
|
+
task.completedAt = timestamp();
|
|
1199
|
+
task.elapsed = elapsed;
|
|
1200
|
+
task.cost = ag.cost;
|
|
1201
|
+
state.inProgress = state.inProgress.filter((t) => t.id !== task.id);
|
|
1202
|
+
state.completed.push(task);
|
|
1203
|
+
const costStr = ag.cost ? ` ($${ag.cost.toFixed(2)})` : "";
|
|
1204
|
+
log(
|
|
1205
|
+
"DONE",
|
|
1206
|
+
`${agentName} completó ${task.id} en ${formatDuration(elapsed)}${costStr}`,
|
|
1207
|
+
);
|
|
1208
|
+
appendToAgent(agentName, "", true);
|
|
1209
|
+
appendToAgent(
|
|
1210
|
+
agentName,
|
|
1211
|
+
`{green-fg}=== COMPLETADA en ${formatDuration(elapsed)}${escBl(costStr)} ==={/green-fg}`,
|
|
1212
|
+
true,
|
|
1213
|
+
);
|
|
1214
|
+
ag.status = "idle";
|
|
1215
|
+
ag.task = null;
|
|
1216
|
+
ag.process = null;
|
|
1217
|
+
ag.startTime = null;
|
|
1218
|
+
ag.lastLine = `Última: ${task.id} completada`;
|
|
1219
|
+
updateQueueFile(task);
|
|
1220
|
+
scheduleNext();
|
|
1221
|
+
renderDashboard();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function detectRateLimit(agentName) {
|
|
1225
|
+
const ag = state.agents[agentName];
|
|
1226
|
+
let content = ag.output || ag.lastLine || "";
|
|
1227
|
+
if (ag.logFile && fs.existsSync(ag.logFile)) {
|
|
1228
|
+
try {
|
|
1229
|
+
const stat = fs.statSync(ag.logFile);
|
|
1230
|
+
const buf = Buffer.alloc(Math.min(5000, stat.size));
|
|
1231
|
+
const fd = fs.openSync(ag.logFile, "r");
|
|
1232
|
+
fs.readSync(fd, buf, 0, buf.length, Math.max(0, stat.size - buf.length));
|
|
1233
|
+
fs.closeSync(fd);
|
|
1234
|
+
content += buf.toString("utf-8");
|
|
1235
|
+
} catch {}
|
|
1236
|
+
}
|
|
1237
|
+
const isRateLimit =
|
|
1238
|
+
content.includes("rate_limit") ||
|
|
1239
|
+
content.includes("429") ||
|
|
1240
|
+
content.includes("out of extra usage") ||
|
|
1241
|
+
content.includes("resets") ||
|
|
1242
|
+
content.includes("RESOURCE_EXHAUSTED") ||
|
|
1243
|
+
content.includes("rateLimitExceeded");
|
|
1244
|
+
if (!isRateLimit) return { isRateLimit: false, resetsAt: null };
|
|
1245
|
+
let resetsAt = null;
|
|
1246
|
+
const tsMatch = content.match(/"resetsAt"\s*:\s*(\d{10,13})/);
|
|
1247
|
+
if (tsMatch) {
|
|
1248
|
+
let ts = parseInt(tsMatch[1], 10);
|
|
1249
|
+
if (ts < 1e12) ts *= 1000;
|
|
1250
|
+
resetsAt = new Date(ts);
|
|
1251
|
+
}
|
|
1252
|
+
if (!resetsAt) {
|
|
1253
|
+
const timeMatch = content.match(
|
|
1254
|
+
/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i,
|
|
1255
|
+
);
|
|
1256
|
+
if (timeMatch) {
|
|
1257
|
+
let hours = parseInt(timeMatch[1], 10);
|
|
1258
|
+
const mins = parseInt(timeMatch[2] || "0", 10);
|
|
1259
|
+
const ampm = (timeMatch[3] || "").toLowerCase();
|
|
1260
|
+
if (ampm === "pm" && hours < 12) hours += 12;
|
|
1261
|
+
if (ampm === "am" && hours === 12) hours = 0;
|
|
1262
|
+
resetsAt = new Date();
|
|
1263
|
+
resetsAt.setHours(hours, mins, 0, 0);
|
|
1264
|
+
if (resetsAt.getTime() <= Date.now())
|
|
1265
|
+
resetsAt.setDate(resetsAt.getDate() + 1);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return { isRateLimit: true, resetsAt };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function failTask(task, agentName, code) {
|
|
1272
|
+
const ag = state.agents[agentName];
|
|
1273
|
+
const retries = (failedTasks.get(task.id) || 0) + 1;
|
|
1274
|
+
failedTasks.set(task.id, retries);
|
|
1275
|
+
const rl = detectRateLimit(agentName);
|
|
1276
|
+
const failureFlags = detectSupportAgentFailure(agentName);
|
|
1277
|
+
const maxRetries = rl.isRateLimit ? MAX_RETRIES_RATE_LIMIT : MAX_RETRIES;
|
|
1278
|
+
if (rl.isRateLimit) {
|
|
1279
|
+
const resetStr = rl.resetsAt
|
|
1280
|
+
? `resets ${rl.resetsAt.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true })}`
|
|
1281
|
+
: "reintento en 10 min";
|
|
1282
|
+
log("RATE", `${agentName} alcanzó el límite en ${task.id} (${resetStr})`);
|
|
1283
|
+
appendToAgent(
|
|
1284
|
+
agentName,
|
|
1285
|
+
`{yellow-fg}=== LÍMITE DE CUOTA (${escBl(resetStr)}) ==={/yellow-fg}`,
|
|
1286
|
+
true,
|
|
1287
|
+
);
|
|
1288
|
+
} else {
|
|
1289
|
+
log(
|
|
1290
|
+
"FAIL",
|
|
1291
|
+
`${agentName} falló ${task.id} (salida ${code}, ${retries}/${maxRetries})`,
|
|
1292
|
+
);
|
|
1293
|
+
appendToAgent(
|
|
1294
|
+
agentName,
|
|
1295
|
+
`{red-fg}=== FALLÓ (salida ${code}, intento ${retries}) ==={/red-fg}`,
|
|
1296
|
+
true,
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
state.inProgress = state.inProgress.filter((t) => t.id !== task.id);
|
|
1300
|
+
ag.status = "idle";
|
|
1301
|
+
ag.task = null;
|
|
1302
|
+
ag.process = null;
|
|
1303
|
+
ag.startTime = null;
|
|
1304
|
+
|
|
1305
|
+
const shouldFallbackToClaude =
|
|
1306
|
+
["Codex", "OpenCode"].includes(agentName) &&
|
|
1307
|
+
(failureFlags.exhaustedQuota ||
|
|
1308
|
+
failureFlags.providerUnavailable ||
|
|
1309
|
+
retries >= maxRetries);
|
|
1310
|
+
|
|
1311
|
+
if (shouldFallbackToClaude) {
|
|
1312
|
+
const reason = failureFlags.exhaustedQuota
|
|
1313
|
+
? "cuota o límite agotado"
|
|
1314
|
+
: failureFlags.providerUnavailable
|
|
1315
|
+
? "proveedor o sesión no disponibles"
|
|
1316
|
+
: "fallo persistente";
|
|
1317
|
+
if (tryFallbackToClaude(task, agentName, reason)) {
|
|
1318
|
+
setTimeout(() => {
|
|
1319
|
+
scheduleNext();
|
|
1320
|
+
renderDashboard();
|
|
1321
|
+
}, 3000);
|
|
1322
|
+
renderDashboard();
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if (retries >= maxRetries) {
|
|
1328
|
+
task.status = "failed";
|
|
1329
|
+
ag.lastLine = `FALLÓ: ${task.id}`;
|
|
1330
|
+
log("ERROR", `${task.id} falló definitivamente tras ${retries} intentos`);
|
|
1331
|
+
} else {
|
|
1332
|
+
task.status = "pending";
|
|
1333
|
+
let retryDelay = rl.isRateLimit
|
|
1334
|
+
? rl.resetsAt
|
|
1335
|
+
? Math.max(60_000, rl.resetsAt.getTime() - Date.now() + 60_000)
|
|
1336
|
+
: 600_000
|
|
1337
|
+
: 15_000;
|
|
1338
|
+
task._retryAfter = Date.now() + retryDelay;
|
|
1339
|
+
state.queue.push(task);
|
|
1340
|
+
const retryTime = new Date(task._retryAfter).toLocaleTimeString("en-US", {
|
|
1341
|
+
hour: "2-digit",
|
|
1342
|
+
minute: "2-digit",
|
|
1343
|
+
hour12: true,
|
|
1344
|
+
});
|
|
1345
|
+
ag.lastLine = rl.isRateLimit
|
|
1346
|
+
? `LÍMITE: ${task.id} (reintento a las ${retryTime})`
|
|
1347
|
+
: `REINTENTO: ${task.id}`;
|
|
1348
|
+
if (rl.isRateLimit) rateLimitedAgents.set(agentName, task._retryAfter);
|
|
1349
|
+
}
|
|
1350
|
+
if (rl.isRateLimit && task._retryAfter) {
|
|
1351
|
+
setTimeout(
|
|
1352
|
+
() => {
|
|
1353
|
+
scheduleNext();
|
|
1354
|
+
renderDashboard();
|
|
1355
|
+
},
|
|
1356
|
+
Math.max(
|
|
1357
|
+
Math.min(task._retryAfter - Date.now() + 5000, 3600_000),
|
|
1358
|
+
60_000,
|
|
1359
|
+
),
|
|
1360
|
+
);
|
|
1361
|
+
} else {
|
|
1362
|
+
setTimeout(() => {
|
|
1363
|
+
scheduleNext();
|
|
1364
|
+
renderDashboard();
|
|
1365
|
+
}, 3000);
|
|
1366
|
+
}
|
|
1367
|
+
renderDashboard();
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// ============================================================================
|
|
1371
|
+
// SCHEDULER
|
|
1372
|
+
// ============================================================================
|
|
1373
|
+
function scheduleNext() {
|
|
1374
|
+
if (state.paused) return;
|
|
1375
|
+
if (CLI.maxBudget > 0 && state.totalCost >= CLI.maxBudget) return;
|
|
1376
|
+
const busyCount = Object.values(state.agents).filter(
|
|
1377
|
+
(a) => a.status === "busy",
|
|
1378
|
+
).length;
|
|
1379
|
+
if (busyCount >= MAX_CONCURRENT) return;
|
|
1380
|
+
reloadQueue();
|
|
1381
|
+
const completedIds = new Set(state.completed.map((t) => t.id));
|
|
1382
|
+
for (const task of [...state.queue]) {
|
|
1383
|
+
if (
|
|
1384
|
+
Object.values(state.agents).filter((a) => a.status === "busy").length >=
|
|
1385
|
+
MAX_CONCURRENT
|
|
1386
|
+
)
|
|
1387
|
+
break;
|
|
1388
|
+
const ag = state.agents[task.agent];
|
|
1389
|
+
if (!ag || ag.status !== "idle") continue;
|
|
1390
|
+
if (task.dependsOn && !completedIds.has(task.dependsOn)) continue;
|
|
1391
|
+
if (task._retryAfter && Date.now() < task._retryAfter) continue;
|
|
1392
|
+
if (task.status === "failed") continue;
|
|
1393
|
+
if (launchAgent(task))
|
|
1394
|
+
state.queue = state.queue.filter((t) => t.id !== task.id);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// ============================================================================
|
|
1399
|
+
// QUEUE FILE UPDATER
|
|
1400
|
+
// ============================================================================
|
|
1401
|
+
function updateQueueFile(completedTask) {
|
|
1402
|
+
if (!fs.existsSync(QUEUE_FILE)) return;
|
|
1403
|
+
const lines = fs.readFileSync(QUEUE_FILE, "utf-8").split("\n");
|
|
1404
|
+
// Use a word-boundary match so TASK-1 does NOT also remove TASK-10, TASK-11, etc.
|
|
1405
|
+
const idMatcher = new RegExp(
|
|
1406
|
+
`^${completedTask.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$|\\|)`,
|
|
1407
|
+
);
|
|
1408
|
+
const filtered = lines.filter((l) => !idMatcher.test(l.trim()));
|
|
1409
|
+
const idx = filtered.findIndex((l) => l.trim().startsWith("## Completed"));
|
|
1410
|
+
if (idx >= 0)
|
|
1411
|
+
filtered.splice(
|
|
1412
|
+
idx + 1,
|
|
1413
|
+
0,
|
|
1414
|
+
`${completedTask.id} | ${completedTask.title} | ${completedTask.agent} | ${completedTask.completedAt}`,
|
|
1415
|
+
);
|
|
1416
|
+
fs.writeFileSync(QUEUE_FILE, filtered.join("\n"), "utf-8");
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function updateQueueTaskAgent(taskId, newAgent) {
|
|
1420
|
+
if (!fs.existsSync(QUEUE_FILE)) return false;
|
|
1421
|
+
const lines = fs.readFileSync(QUEUE_FILE, "utf-8").split("\n");
|
|
1422
|
+
let updated = false;
|
|
1423
|
+
|
|
1424
|
+
const rewritten = lines.map((line) => {
|
|
1425
|
+
const trimmed = line.trim();
|
|
1426
|
+
if (updated || !trimmed.startsWith(`${taskId} |`)) return line;
|
|
1427
|
+
const parts = line.split("|");
|
|
1428
|
+
if (parts.length < 5) return line;
|
|
1429
|
+
parts[2] = ` ${newAgent} `;
|
|
1430
|
+
updated = true;
|
|
1431
|
+
return parts.join("|");
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
if (updated) fs.writeFileSync(QUEUE_FILE, rewritten.join("\n"), "utf-8");
|
|
1435
|
+
return updated;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function detectSupportAgentFailure(agentName) {
|
|
1439
|
+
const ag = state.agents[agentName];
|
|
1440
|
+
let content = ag.output || ag.lastLine || "";
|
|
1441
|
+
if (ag.logFile && fs.existsSync(ag.logFile)) {
|
|
1442
|
+
try {
|
|
1443
|
+
const stat = fs.statSync(ag.logFile);
|
|
1444
|
+
const buf = Buffer.alloc(Math.min(8000, stat.size));
|
|
1445
|
+
const fd = fs.openSync(ag.logFile, "r");
|
|
1446
|
+
fs.readSync(fd, buf, 0, buf.length, Math.max(0, stat.size - buf.length));
|
|
1447
|
+
fs.closeSync(fd);
|
|
1448
|
+
content += buf.toString("utf-8");
|
|
1449
|
+
} catch {}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const lower = content.toLowerCase();
|
|
1453
|
+
return {
|
|
1454
|
+
exhaustedQuota:
|
|
1455
|
+
lower.includes("out of extra usage") ||
|
|
1456
|
+
lower.includes("resource_exhausted") ||
|
|
1457
|
+
lower.includes("quota") ||
|
|
1458
|
+
lower.includes("rate_limit") ||
|
|
1459
|
+
lower.includes("ratelimitexceeded") ||
|
|
1460
|
+
lower.includes("429"),
|
|
1461
|
+
providerUnavailable:
|
|
1462
|
+
lower.includes("session expired") ||
|
|
1463
|
+
lower.includes("authentication") ||
|
|
1464
|
+
lower.includes("unauthorized") ||
|
|
1465
|
+
lower.includes("forbidden") ||
|
|
1466
|
+
lower.includes("service unavailable") ||
|
|
1467
|
+
lower.includes("internal server error") ||
|
|
1468
|
+
lower.includes("connection reset") ||
|
|
1469
|
+
lower.includes("econnreset") ||
|
|
1470
|
+
lower.includes("timed out") ||
|
|
1471
|
+
lower.includes("timeout") ||
|
|
1472
|
+
lower.includes("network error"),
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function getClaudeFallbackAgent(task) {
|
|
1477
|
+
const preferred = task.repo === "frontend" ? "Frontend" : "Backend";
|
|
1478
|
+
if (AGENTS[preferred]?.cli === "claude") return preferred;
|
|
1479
|
+
return (
|
|
1480
|
+
Object.keys(AGENTS).find((name) => AGENTS[name]?.cli === "claude") || null
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function tryFallbackToClaude(task, failedAgentName, reason) {
|
|
1485
|
+
if (!["Codex", "OpenCode"].includes(failedAgentName)) return false;
|
|
1486
|
+
const fallbackAgent = getClaudeFallbackAgent(task);
|
|
1487
|
+
if (!fallbackAgent || fallbackAgent === failedAgentName) return false;
|
|
1488
|
+
|
|
1489
|
+
const queueUpdated = updateQueueTaskAgent(task.id, fallbackAgent);
|
|
1490
|
+
task.agent = fallbackAgent;
|
|
1491
|
+
task.status = "pending";
|
|
1492
|
+
task._retryAfter = Date.now() + 3000;
|
|
1493
|
+
failedTasks.set(task.id, 0);
|
|
1494
|
+
state.queue.push(task);
|
|
1495
|
+
|
|
1496
|
+
log(
|
|
1497
|
+
"FALLBACK",
|
|
1498
|
+
`${task.id} fue reasignada de ${failedAgentName} a ${fallbackAgent} (${reason})`,
|
|
1499
|
+
);
|
|
1500
|
+
appendToAgent(
|
|
1501
|
+
failedAgentName,
|
|
1502
|
+
`{yellow-fg}=== REASIGNADA A ${escBl(fallbackAgent)} (${escBl(reason)}) ==={/yellow-fg}`,
|
|
1503
|
+
true,
|
|
1504
|
+
);
|
|
1505
|
+
if (!queueUpdated) {
|
|
1506
|
+
log(
|
|
1507
|
+
"WARN",
|
|
1508
|
+
`${task.id} fue reasignada a ${fallbackAgent}, pero QUEUE.md no pudo actualizarse automáticamente`,
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// ============================================================================
|
|
1515
|
+
// KEYBOARD
|
|
1516
|
+
// ============================================================================
|
|
1517
|
+
if (!CLI.headless && screen) {
|
|
1518
|
+
screen.key(["q", "C-c"], () => {
|
|
1519
|
+
exitWithSummary();
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
screen.key("s", () => {
|
|
1523
|
+
if (state.paused) {
|
|
1524
|
+
state.paused = false;
|
|
1525
|
+
log("INFO", "Reanudado");
|
|
1526
|
+
}
|
|
1527
|
+
scheduleNext();
|
|
1528
|
+
renderDashboard();
|
|
1529
|
+
});
|
|
1530
|
+
screen.key("p", () => {
|
|
1531
|
+
state.paused = !state.paused;
|
|
1532
|
+
log("INFO", state.paused ? L.paused : L.resumed);
|
|
1533
|
+
renderDashboard();
|
|
1534
|
+
});
|
|
1535
|
+
screen.key("r", () => {
|
|
1536
|
+
reloadQueue();
|
|
1537
|
+
log("INFO", L.queueReloaded(state.queue.length));
|
|
1538
|
+
renderDashboard();
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// ============================================================================
|
|
1543
|
+
// MAIN
|
|
1544
|
+
// ============================================================================
|
|
1545
|
+
log("INFO", L.starting(PROJECT_NAME));
|
|
1546
|
+
state.completed = parseCompletedFromFile();
|
|
1547
|
+
log(
|
|
1548
|
+
"INFO",
|
|
1549
|
+
L.loadedCompleted(state.completed.length),
|
|
1550
|
+
);
|
|
1551
|
+
reloadQueue();
|
|
1552
|
+
log("INFO", `${L.queue}: ${state.queue.length} ${L.tasks}`);
|
|
1553
|
+
renderDashboard();
|
|
1554
|
+
if (!state.paused) {
|
|
1555
|
+
scheduleNext();
|
|
1556
|
+
renderDashboard();
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
setInterval(() => {
|
|
1560
|
+
const command = consumeControlCommand();
|
|
1561
|
+
if (command) applyControlCommand(command);
|
|
1562
|
+
}, 1000);
|
|
1563
|
+
|
|
1564
|
+
setInterval(() => {
|
|
1565
|
+
reloadQueue();
|
|
1566
|
+
if (!state.paused) scheduleNext();
|
|
1567
|
+
renderDashboard();
|
|
1568
|
+
}, POLL_INTERVAL_MS);
|
|
1569
|
+
setInterval(() => {
|
|
1570
|
+
for (const [name, ag] of Object.entries(state.agents)) {
|
|
1571
|
+
if (ag.status !== "busy" || !ag.process) continue;
|
|
1572
|
+
try {
|
|
1573
|
+
process.kill(ag.process.pid, 0);
|
|
1574
|
+
} catch {
|
|
1575
|
+
log("WARN", `${name} died silently on ${ag.task?.id}`);
|
|
1576
|
+
appendToAgent(name, "{red-fg}=== PROCESS DIED ==={/red-fg}", true);
|
|
1577
|
+
ag.process = null;
|
|
1578
|
+
failTask(ag.task, name, -1);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}, 15_000);
|
|
1582
|
+
|
|
1583
|
+
if (!CLI.headless && screen) {
|
|
1584
|
+
screen.render();
|
|
1585
|
+
}
|