@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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/bin/agentflow.mjs +332 -0
  4. package/orchestrator.js +1585 -0
  5. package/package.json +64 -0
  6. package/scripts/scaffold-agent-configs.mjs +100 -0
  7. package/scripts/scaffold-openspec-change.mjs +84 -0
  8. package/scripts/update-skill-registry.mjs +174 -0
  9. package/src/ink/app.mjs +240 -0
  10. package/src/ink/index.mjs +400 -0
  11. package/templates/en/.atl/skill-registry.md +27 -0
  12. package/templates/en/.claude/README.md +7 -0
  13. package/templates/en/.claude/skills/orchestrator-apply/SKILL.md +31 -0
  14. package/templates/en/.claude/skills/orchestrator-archive/SKILL.md +26 -0
  15. package/templates/en/.claude/skills/orchestrator-design/SKILL.md +27 -0
  16. package/templates/en/.claude/skills/orchestrator-explore/SKILL.md +29 -0
  17. package/templates/en/.claude/skills/orchestrator-init/SKILL.md +32 -0
  18. package/templates/en/.claude/skills/orchestrator-memory/SKILL.md +26 -0
  19. package/templates/en/.claude/skills/orchestrator-openspec/SKILL.md +35 -0
  20. package/templates/en/.claude/skills/orchestrator-propose/SKILL.md +26 -0
  21. package/templates/en/.claude/skills/orchestrator-queue-planning/SKILL.md +31 -0
  22. package/templates/en/.claude/skills/orchestrator-spec/SKILL.md +27 -0
  23. package/templates/en/.claude/skills/orchestrator-tasks/SKILL.md +27 -0
  24. package/templates/en/.claude/skills/orchestrator-verify/SKILL.md +27 -0
  25. package/templates/en/.codex/README.md +7 -0
  26. package/templates/en/.opencode/README.md +7 -0
  27. package/templates/en/AGENT-CONFIG.md +75 -0
  28. package/templates/en/CLAUDE.md +91 -0
  29. package/templates/en/ENGRAM.md +50 -0
  30. package/templates/en/ORCHESTRATOR.md +192 -0
  31. package/templates/en/PROJECT.md +70 -0
  32. package/templates/en/QUEUE.md +17 -0
  33. package/templates/en/README.md +188 -0
  34. package/templates/en/agents/ABACUS.md +36 -0
  35. package/templates/en/agents/BACKEND.md +37 -0
  36. package/templates/en/agents/CODEX.md +45 -0
  37. package/templates/en/agents/CURSOR.md +37 -0
  38. package/templates/en/agents/FRONTEND.md +36 -0
  39. package/templates/en/agents/GEMINI.md +37 -0
  40. package/templates/en/agents/OPENCODE.md +41 -0
  41. package/templates/en/docs/README.md +14 -0
  42. package/templates/en/docs/agents.md +33 -0
  43. package/templates/en/docs/architecture.md +43 -0
  44. package/templates/en/docs/components.md +14 -0
  45. package/templates/en/docs/engram.md +16 -0
  46. package/templates/en/docs/openspec.md +32 -0
  47. package/templates/en/docs/usage.md +66 -0
  48. package/templates/en/openspec/FLOW.md +24 -0
  49. package/templates/en/openspec/README.md +29 -0
  50. package/templates/en/openspec/changes/.gitkeep +1 -0
  51. package/templates/en/openspec/changes/archive/.gitkeep +1 -0
  52. package/templates/en/openspec/specs/.gitkeep +1 -0
  53. package/templates/en/openspec/templates/archive-report.md +21 -0
  54. package/templates/en/openspec/templates/change-metadata.yaml +9 -0
  55. package/templates/en/openspec/templates/design.md +26 -0
  56. package/templates/en/openspec/templates/proposal.md +27 -0
  57. package/templates/en/openspec/templates/spec.md +18 -0
  58. package/templates/en/openspec/templates/tasks.md +14 -0
  59. package/templates/en/openspec/templates/verify-report.md +21 -0
  60. package/templates/en/orchestrator.config.json +99 -0
  61. package/templates/es/.atl/skill-registry.md +133 -0
  62. package/templates/es/.claude/README.md +7 -0
  63. package/templates/es/.claude/skills/orchestrator-apply/SKILL.md +32 -0
  64. package/templates/es/.claude/skills/orchestrator-archive/SKILL.md +28 -0
  65. package/templates/es/.claude/skills/orchestrator-design/SKILL.md +32 -0
  66. package/templates/es/.claude/skills/orchestrator-explore/SKILL.md +31 -0
  67. package/templates/es/.claude/skills/orchestrator-init/SKILL.md +32 -0
  68. package/templates/es/.claude/skills/orchestrator-memory/SKILL.md +31 -0
  69. package/templates/es/.claude/skills/orchestrator-openspec/SKILL.md +55 -0
  70. package/templates/es/.claude/skills/orchestrator-propose/SKILL.md +33 -0
  71. package/templates/es/.claude/skills/orchestrator-queue-planning/SKILL.md +35 -0
  72. package/templates/es/.claude/skills/orchestrator-spec/SKILL.md +28 -0
  73. package/templates/es/.claude/skills/orchestrator-tasks/SKILL.md +32 -0
  74. package/templates/es/.claude/skills/orchestrator-verify/SKILL.md +31 -0
  75. package/templates/es/.codex/README.md +7 -0
  76. package/templates/es/.opencode/README.md +7 -0
  77. package/templates/es/AGENT-CONFIG.md +83 -0
  78. package/templates/es/CLAUDE.md +136 -0
  79. package/templates/es/ENGRAM.md +70 -0
  80. package/templates/es/ORCHESTRATOR.md +199 -0
  81. package/templates/es/PROJECT.md +237 -0
  82. package/templates/es/QUEUE.md +17 -0
  83. package/templates/es/README.md +568 -0
  84. package/templates/es/agents/ABACUS.md +25 -0
  85. package/templates/es/agents/BACKEND.md +28 -0
  86. package/templates/es/agents/CODEX.md +37 -0
  87. package/templates/es/agents/CURSOR.md +27 -0
  88. package/templates/es/agents/FRONTEND.md +29 -0
  89. package/templates/es/agents/GEMINI.md +26 -0
  90. package/templates/es/agents/OPENCODE.md +32 -0
  91. package/templates/es/docs/README.md +12 -0
  92. package/templates/es/docs/agents.md +57 -0
  93. package/templates/es/docs/architecture.md +41 -0
  94. package/templates/es/docs/components.md +33 -0
  95. package/templates/es/docs/engram.md +30 -0
  96. package/templates/es/docs/openspec.md +34 -0
  97. package/templates/es/docs/usage.md +54 -0
  98. package/templates/es/openspec/FLOW.md +139 -0
  99. package/templates/es/openspec/README.md +77 -0
  100. package/templates/es/openspec/changes/.gitkeep +1 -0
  101. package/templates/es/openspec/changes/archive/.gitkeep +1 -0
  102. package/templates/es/openspec/specs/.gitkeep +1 -0
  103. package/templates/es/openspec/templates/archive-report.md +23 -0
  104. package/templates/es/openspec/templates/change-metadata.yaml +9 -0
  105. package/templates/es/openspec/templates/design.md +33 -0
  106. package/templates/es/openspec/templates/proposal.md +36 -0
  107. package/templates/es/openspec/templates/spec.md +33 -0
  108. package/templates/es/openspec/templates/tasks.md +22 -0
  109. package/templates/es/openspec/templates/verify-report.md +24 -0
  110. package/templates/es/orchestrator.config.json +99 -0
@@ -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
+ }