@liriraid/agentflow-ai 1.0.14 → 1.0.16
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/bin/agentflow.mjs +3 -2
- package/orchestrator.js +590 -80
- package/package.json +1 -5
- package/src/ink/app.mjs +22 -14
- package/src/ink/index.mjs +34 -4
- package/templates/en/ORCHESTRATOR.md +44 -26
- package/templates/es/ORCHESTRATOR.md +55 -23
- package/scripts/scaffold-agent-configs.mjs +0 -100
- package/scripts/scaffold-openspec-change.mjs +0 -84
- package/scripts/update-skill-registry.mjs +0 -174
package/orchestrator.js
CHANGED
|
@@ -121,6 +121,8 @@ if (!fs.existsSync(CONFIG_FILE)) {
|
|
|
121
121
|
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
122
122
|
|
|
123
123
|
const QUEUE_FILE = path.join(WORKSPACE, "QUEUE.md");
|
|
124
|
+
const INBOX_FILE = path.join(WORKSPACE, "INBOX.md");
|
|
125
|
+
const AWAY_MODE_FILE = path.join(WORKSPACE, ".away-mode");
|
|
124
126
|
const LOG_DIR = path.join(WORKSPACE, "logs");
|
|
125
127
|
|
|
126
128
|
const REPOS = config.repos || {};
|
|
@@ -129,6 +131,36 @@ const PROJECT_NAME = config.projectName || "Orchestrator Multi-Agents";
|
|
|
129
131
|
const WORKSPACE_LANGUAGE = ["en", "es"].includes(config.workspaceLanguage)
|
|
130
132
|
? config.workspaceLanguage
|
|
131
133
|
: "es";
|
|
134
|
+
|
|
135
|
+
// OpenAI model pricing ($ per 1M tokens) — update when prices change
|
|
136
|
+
const OPENAI_MODEL_PRICING = {
|
|
137
|
+
"gpt-4.1": { input: 2.0, output: 8.0 },
|
|
138
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6 },
|
|
139
|
+
"gpt-4.1-nano": { input: 0.1, output: 0.4 },
|
|
140
|
+
"gpt-4o": { input: 2.5, output: 10.0 },
|
|
141
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
142
|
+
"o4-mini": { input: 1.1, output: 4.4 },
|
|
143
|
+
"o3": { input: 10.0, output: 40.0 },
|
|
144
|
+
"o3-mini": { input: 1.1, output: 4.4 },
|
|
145
|
+
"o1": { input: 15.0, output: 60.0 },
|
|
146
|
+
"o1-mini": { input: 3.0, output: 12.0 },
|
|
147
|
+
"gpt-5": { input: 5.0, output: 20.0 },
|
|
148
|
+
"gpt-5.5": { input: 5.0, output: 20.0 },
|
|
149
|
+
"codex-mini-latest": { input: 1.5, output: 6.0 },
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function calcOpenAICost(model, usage) {
|
|
153
|
+
if (!model || !usage) return null;
|
|
154
|
+
const modelLower = String(model).toLowerCase();
|
|
155
|
+
const key = Object.keys(OPENAI_MODEL_PRICING).find(k =>
|
|
156
|
+
modelLower === k || modelLower.startsWith(k) || modelLower.includes(k)
|
|
157
|
+
);
|
|
158
|
+
if (!key) return null;
|
|
159
|
+
const price = OPENAI_MODEL_PRICING[key];
|
|
160
|
+
const inputTokens = usage.input_tokens || usage.prompt_tokens || 0;
|
|
161
|
+
const outputTokens = usage.output_tokens || usage.completion_tokens || 0;
|
|
162
|
+
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
|
|
163
|
+
}
|
|
132
164
|
const TEXT = {
|
|
133
165
|
es: {
|
|
134
166
|
configExists:
|
|
@@ -160,6 +192,8 @@ const TEXT = {
|
|
|
160
192
|
running: "EJECUTANDO",
|
|
161
193
|
busy: "OCUPADO",
|
|
162
194
|
idle: "EN ESPERA",
|
|
195
|
+
failed: "FALLÓ",
|
|
196
|
+
retrying: "REINTENTANDO",
|
|
163
197
|
queueReloaded: (count) => `Cola recargada: ${count} tareas`,
|
|
164
198
|
quitRequested: "Cierre solicitado desde Ink",
|
|
165
199
|
starting: (name) => `${name} iniciando`,
|
|
@@ -173,6 +207,65 @@ const TEXT = {
|
|
|
173
207
|
retryAt: (time, remaining) => `reintenta a las ${time} (${remaining} min)`,
|
|
174
208
|
log: "REGISTRO",
|
|
175
209
|
controls: "Seguir Pausa Recargar Quitar",
|
|
210
|
+
// QUEUE.md section headers
|
|
211
|
+
sectionPending: "## Pendientes",
|
|
212
|
+
sectionInProgress: "## En progreso",
|
|
213
|
+
sectionCompleted: "## Completadas",
|
|
214
|
+
// appendToAgent messages
|
|
215
|
+
agentTaskHeader: (id, title) => `=== ${id}: ${title} ===`,
|
|
216
|
+
agentCwd: (dir) => `CWD: ${dir}`,
|
|
217
|
+
agentCompleted: (elapsed, cost) => `=== COMPLETADA en ${elapsed}${cost} ===`,
|
|
218
|
+
agentFailed: (code, attempt) => `=== FALLÓ (salida ${code}, intento ${attempt}) ===`,
|
|
219
|
+
agentRateLimit: (resetStr) => `=== LÍMITE DE CUOTA (${resetStr}) ===`,
|
|
220
|
+
agentTimeout: "=== TIMEOUT ===",
|
|
221
|
+
agentDied: "=== PROCESS DIED ===",
|
|
222
|
+
agentReassigned: (agent, reason) => `=== REASIGNADA A ${agent} (${reason}) ===`,
|
|
223
|
+
// ag.lastLine state machine prefixes (used for status detection)
|
|
224
|
+
lastCompleted: (id) => `Última: ${id} completada`,
|
|
225
|
+
lastRetry: (id) => `REINTENTO: ${id}`,
|
|
226
|
+
lastLimit: (id, time) => `LÍMITE: ${id} (reintento a las ${time})`,
|
|
227
|
+
lastFailed: (id) => `FALLÓ: ${id}`,
|
|
228
|
+
// Fallback reasons
|
|
229
|
+
reasonQuota: "cuota o límite agotado",
|
|
230
|
+
reasonProvider: "proveedor o sesión no disponibles",
|
|
231
|
+
reasonNoWork: "el agente no trabajó nada (sin cambios)",
|
|
232
|
+
reasonPersistent: "fallo persistente",
|
|
233
|
+
// Log messages
|
|
234
|
+
logRateLimit: (agent, id, resetStr) => `${agent} alcanzó el límite en ${id} (${resetStr})`,
|
|
235
|
+
logFail: (agent, id, code, retries, max) => `${agent} falló ${id} (salida ${code}, ${retries}/${max})`,
|
|
236
|
+
logDone: (agent, id, elapsed, cost) => `${agent} completó ${id} en ${elapsed}${cost}`,
|
|
237
|
+
logFallback: (id, from, to, reason) => `${id} reasignada de ${from} a ${to} (${reason})`,
|
|
238
|
+
logReassignWarn: (id, agent) => `${id} reasignada a ${agent}, pero QUEUE.md no pudo actualizarse`,
|
|
239
|
+
logTimeout: (agent, id) => `${agent} timed out on ${id}`,
|
|
240
|
+
logUnknownAgent: (id, agent) => `${id} skipped — agente "${agent}" no definido en orchestrator.config.json`,
|
|
241
|
+
logPermanentFail: (id, retries) => `${id} falló definitivamente tras ${retries} intentos`,
|
|
242
|
+
logDied: (agent, id) => `${agent} terminó silenciosamente en ${id}`,
|
|
243
|
+
// STATUS.md
|
|
244
|
+
statusTitle: (ts) => `# Estado del Orquestador - ${ts}`,
|
|
245
|
+
statusProject: "**Proyecto:**",
|
|
246
|
+
statusState: "**Estado:**",
|
|
247
|
+
statusRunning: "EJECUTANDO",
|
|
248
|
+
statusPausedLabel: "PAUSADO",
|
|
249
|
+
statusActiveTime: "**Activo:**",
|
|
250
|
+
statusSectionAgents: "## Agentes",
|
|
251
|
+
statusSectionQueue: "## Cola",
|
|
252
|
+
statusPendingLabel: "Pendientes:",
|
|
253
|
+
statusCompletedLabel: "Completadas:",
|
|
254
|
+
statusInProgressLabel: "En progreso:",
|
|
255
|
+
statusInProgressHeader: "### En progreso",
|
|
256
|
+
statusAgentBusy: "🟡 OCUPADO",
|
|
257
|
+
statusAgentIdle: "⚪ EN ESPERA",
|
|
258
|
+
statusNoTask: "Sin tarea",
|
|
259
|
+
// INBOX.md
|
|
260
|
+
inboxDone: (ts, id, agent) => `## [${ts}] ${id} completada — ${agent}`,
|
|
261
|
+
inboxTaskLabel: "- **Tarea:**",
|
|
262
|
+
inboxDurationLabel: "- **Duración:**",
|
|
263
|
+
inboxReportLabel: "- **Reporte:**",
|
|
264
|
+
inboxActionLabel: (file) => `- **Acción:** Lee \`${file}\` y crea las siguientes TASKs si corresponde.`,
|
|
265
|
+
inboxFailed: (ts, id, from, to) => `## [${ts}] ${id} falló — ${from} → reasignada a ${to}`,
|
|
266
|
+
inboxReasonLabel: "- **Motivo:**",
|
|
267
|
+
inboxNewAgentLabel: "- **Nuevo agente:**",
|
|
268
|
+
inboxFailAction: "- **Acción:** La TUI reasignó automáticamente. Verifica en QUEUE.md o espera la siguiente notificación de completada.",
|
|
176
269
|
},
|
|
177
270
|
en: {
|
|
178
271
|
configExists:
|
|
@@ -204,6 +297,8 @@ const TEXT = {
|
|
|
204
297
|
running: "RUNNING",
|
|
205
298
|
busy: "BUSY",
|
|
206
299
|
idle: "IDLE",
|
|
300
|
+
failed: "FAILED",
|
|
301
|
+
retrying: "RETRYING",
|
|
207
302
|
queueReloaded: (count) => `Queue reloaded: ${count} tasks`,
|
|
208
303
|
quitRequested: "Quit requested from Ink",
|
|
209
304
|
starting: (name) => `${name} starting`,
|
|
@@ -216,6 +311,65 @@ const TEXT = {
|
|
|
216
311
|
retryAt: (time, remaining) => `retry at ${time} (${remaining} min)`,
|
|
217
312
|
log: "LOG",
|
|
218
313
|
controls: "Start Pause Reload Quit",
|
|
314
|
+
// QUEUE.md section headers
|
|
315
|
+
sectionPending: "## Pending",
|
|
316
|
+
sectionInProgress: "## In Progress",
|
|
317
|
+
sectionCompleted: "## Completed",
|
|
318
|
+
// appendToAgent messages
|
|
319
|
+
agentTaskHeader: (id, title) => `=== ${id}: ${title} ===`,
|
|
320
|
+
agentCwd: (dir) => `CWD: ${dir}`,
|
|
321
|
+
agentCompleted: (elapsed, cost) => `=== COMPLETED in ${elapsed}${cost} ===`,
|
|
322
|
+
agentFailed: (code, attempt) => `=== FAILED (exit ${code}, attempt ${attempt}) ===`,
|
|
323
|
+
agentRateLimit: (resetStr) => `=== QUOTA LIMIT (${resetStr}) ===`,
|
|
324
|
+
agentTimeout: "=== TIMEOUT ===",
|
|
325
|
+
agentDied: "=== PROCESS DIED ===",
|
|
326
|
+
agentReassigned: (agent, reason) => `=== REASSIGNED TO ${agent} (${reason}) ===`,
|
|
327
|
+
// ag.lastLine state machine prefixes
|
|
328
|
+
lastCompleted: (id) => `Last: ${id} completed`,
|
|
329
|
+
lastRetry: (id) => `RETRY: ${id}`,
|
|
330
|
+
lastLimit: (id, time) => `LIMIT: ${id} (retry at ${time})`,
|
|
331
|
+
lastFailed: (id) => `FAILED: ${id}`,
|
|
332
|
+
// Fallback reasons
|
|
333
|
+
reasonQuota: "quota or rate limit exhausted",
|
|
334
|
+
reasonProvider: "provider or session unavailable",
|
|
335
|
+
reasonNoWork: "agent did no real work (no changes)",
|
|
336
|
+
reasonPersistent: "persistent failure",
|
|
337
|
+
// Log messages
|
|
338
|
+
logRateLimit: (agent, id, resetStr) => `${agent} hit rate limit on ${id} (${resetStr})`,
|
|
339
|
+
logFail: (agent, id, code, retries, max) => `${agent} failed ${id} (exit ${code}, ${retries}/${max})`,
|
|
340
|
+
logDone: (agent, id, elapsed, cost) => `${agent} completed ${id} in ${elapsed}${cost}`,
|
|
341
|
+
logFallback: (id, from, to, reason) => `${id} reassigned from ${from} to ${to} (${reason})`,
|
|
342
|
+
logReassignWarn: (id, agent) => `${id} reassigned to ${agent}, but QUEUE.md could not be updated`,
|
|
343
|
+
logTimeout: (agent, id) => `${agent} timed out on ${id}`,
|
|
344
|
+
logUnknownAgent: (id, agent) => `${id} skipped — agent "${agent}" not in orchestrator.config.json`,
|
|
345
|
+
logPermanentFail: (id, retries) => `${id} permanently failed after ${retries} attempts`,
|
|
346
|
+
logDied: (agent, id) => `${agent} died silently on ${id}`,
|
|
347
|
+
// STATUS.md
|
|
348
|
+
statusTitle: (ts) => `# Orchestrator Status - ${ts}`,
|
|
349
|
+
statusProject: "**Project:**",
|
|
350
|
+
statusState: "**State:**",
|
|
351
|
+
statusRunning: "RUNNING",
|
|
352
|
+
statusPausedLabel: "PAUSED",
|
|
353
|
+
statusActiveTime: "**Active:**",
|
|
354
|
+
statusSectionAgents: "## Agents",
|
|
355
|
+
statusSectionQueue: "## Queue",
|
|
356
|
+
statusPendingLabel: "Pending:",
|
|
357
|
+
statusCompletedLabel: "Completed:",
|
|
358
|
+
statusInProgressLabel: "In progress:",
|
|
359
|
+
statusInProgressHeader: "### In progress",
|
|
360
|
+
statusAgentBusy: "🟡 BUSY",
|
|
361
|
+
statusAgentIdle: "⚪ IDLE",
|
|
362
|
+
statusNoTask: "No task",
|
|
363
|
+
// INBOX.md
|
|
364
|
+
inboxDone: (ts, id, agent) => `## [${ts}] ${id} completed — ${agent}`,
|
|
365
|
+
inboxTaskLabel: "- **Task:**",
|
|
366
|
+
inboxDurationLabel: "- **Duration:**",
|
|
367
|
+
inboxReportLabel: "- **Report:**",
|
|
368
|
+
inboxActionLabel: (file) => `- **Action:** Read \`${file}\` and create next TASKs if applicable.`,
|
|
369
|
+
inboxFailed: (ts, id, from, to) => `## [${ts}] ${id} failed — ${from} → reassigned to ${to}`,
|
|
370
|
+
inboxReasonLabel: "- **Reason:**",
|
|
371
|
+
inboxNewAgentLabel: "- **New agent:**",
|
|
372
|
+
inboxFailAction: "- **Action:** TUI reassigned automatically. Check QUEUE.md or wait for the next completion notification.",
|
|
219
373
|
},
|
|
220
374
|
};
|
|
221
375
|
const L = TEXT[WORKSPACE_LANGUAGE];
|
|
@@ -279,6 +433,38 @@ if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
|
279
433
|
const LOCK_FILE = path.join(LOG_DIR, "orchestrator.lock");
|
|
280
434
|
const STATE_FILE = path.join(LOG_DIR, "orchestrator-state.json");
|
|
281
435
|
const CONTROL_FILE = path.join(LOG_DIR, "orchestrator-control.json");
|
|
436
|
+
const STATUS_FILE = path.join(WORKSPACE, "STATUS.md");
|
|
437
|
+
|
|
438
|
+
function updateStatusFile() {
|
|
439
|
+
const statusLines = [
|
|
440
|
+
L.statusTitle(timestamp()),
|
|
441
|
+
``,
|
|
442
|
+
`${L.statusProject} ${PROJECT_NAME}`,
|
|
443
|
+
`${L.statusState} ${state.paused ? L.statusPausedLabel : L.statusRunning}`,
|
|
444
|
+
`${L.statusActiveTime} ${formatDuration(Math.round((Date.now() - state.startTime) / 1000))}`,
|
|
445
|
+
``,
|
|
446
|
+
L.statusSectionAgents,
|
|
447
|
+
``,
|
|
448
|
+
];
|
|
449
|
+
for (const [name, ag] of Object.entries(state.agents)) {
|
|
450
|
+
const status = ag.status === "busy" ? L.statusAgentBusy : L.statusAgentIdle;
|
|
451
|
+
const task = ag.task ? `${ag.task.id}: ${ag.task.title}` : L.statusNoTask;
|
|
452
|
+
statusLines.push(`- **${name}**: ${status} - ${task}`);
|
|
453
|
+
}
|
|
454
|
+
statusLines.push("", L.statusSectionQueue);
|
|
455
|
+
statusLines.push("", `${L.statusPendingLabel} ${state.queue.length}`);
|
|
456
|
+
statusLines.push("", `${L.statusCompletedLabel} ${state.completed.length}`);
|
|
457
|
+
statusLines.push("", `${L.statusInProgressLabel} ${state.inProgress.length}`);
|
|
458
|
+
if (state.inProgress.length > 0) {
|
|
459
|
+
statusLines.push("", L.statusInProgressHeader);
|
|
460
|
+
for (const t of state.inProgress) {
|
|
461
|
+
statusLines.push(`- ${t.id}: ${t.title} (${t.agent})`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
fs.writeFileSync(STATUS_FILE, statusLines.join("\n"), "utf-8");
|
|
466
|
+
} catch {}
|
|
467
|
+
}
|
|
282
468
|
|
|
283
469
|
// Limpiar control.json orphan al iniciar (si el proceso anterior fechou mal)
|
|
284
470
|
if (fs.existsSync(CONTROL_FILE)) {
|
|
@@ -373,6 +559,7 @@ for (const name of Object.keys(AGENTS)) {
|
|
|
373
559
|
lastLine: "",
|
|
374
560
|
exitCode: null,
|
|
375
561
|
cost: null,
|
|
562
|
+
totalCost: 0,
|
|
376
563
|
turns: 0,
|
|
377
564
|
};
|
|
378
565
|
}
|
|
@@ -486,6 +673,7 @@ function persistState() {
|
|
|
486
673
|
lastLine: ag.lastLine,
|
|
487
674
|
exitCode: ag.exitCode,
|
|
488
675
|
cost: ag.cost,
|
|
676
|
+
totalCost: ag.totalCost || 0,
|
|
489
677
|
turns: ag.turns,
|
|
490
678
|
},
|
|
491
679
|
]),
|
|
@@ -611,18 +799,28 @@ function renderDashboard() {
|
|
|
611
799
|
lines.push("");
|
|
612
800
|
|
|
613
801
|
for (const [name, ag] of Object.entries(state.agents)) {
|
|
614
|
-
const
|
|
615
|
-
|
|
802
|
+
const lastLine = ag.lastLine || "";
|
|
803
|
+
const isFailed = lastLine.startsWith("FALLÓ:") || lastLine.startsWith("FAILED:");
|
|
804
|
+
const isRetrying = lastLine.startsWith("REINTENTO:") || lastLine.startsWith("LÍMITE:") || lastLine.startsWith("RETRY:") || lastLine.startsWith("LIMIT:");
|
|
805
|
+
let status, detail, dot;
|
|
616
806
|
if (ag.status === "busy") {
|
|
617
807
|
status = `{yellow-fg}${L.busy}{/yellow-fg}`;
|
|
618
808
|
detail = `${ag.task?.id || "?"} ${(ag.task?.title || "").slice(0, 35)} (${elapsedSince(ag.startTime)})`;
|
|
809
|
+
dot = "{green-fg}●{/green-fg}";
|
|
810
|
+
} else if (isFailed) {
|
|
811
|
+
status = `{red-fg}${L.failed}{/red-fg}`;
|
|
812
|
+
detail = lastLine;
|
|
813
|
+
dot = "{red-fg}✕{/red-fg}";
|
|
814
|
+
} else if (isRetrying) {
|
|
815
|
+
status = `{yellow-fg}${L.retrying}{/yellow-fg}`;
|
|
816
|
+
detail = lastLine;
|
|
817
|
+
dot = "{yellow-fg}⟳{/yellow-fg}";
|
|
619
818
|
} else {
|
|
620
819
|
status = `{gray-fg}${L.idle}{/gray-fg}`;
|
|
621
|
-
detail =
|
|
820
|
+
detail = lastLine;
|
|
821
|
+
dot = "{gray-fg}○{/gray-fg}";
|
|
622
822
|
}
|
|
623
|
-
|
|
624
|
-
ag.status === "busy" ? "{green-fg}●{/green-fg}" : "{gray-fg}○{/gray-fg}";
|
|
625
|
-
lines.push(` ${dot} {bold}${name}{/bold} ${status} ${detail}`);
|
|
823
|
+
lines.push(` ${dot} {bold}${name}{/bold} ${status} ${escBl(detail)}`);
|
|
626
824
|
}
|
|
627
825
|
lines.push("");
|
|
628
826
|
|
|
@@ -691,15 +889,25 @@ function renderDashboard() {
|
|
|
691
889
|
|
|
692
890
|
for (const [name, ag] of Object.entries(state.agents)) {
|
|
693
891
|
const box = agentBoxes[name];
|
|
892
|
+
const lastLine = ag.lastLine || "";
|
|
893
|
+
const isFailed = lastLine.startsWith("FALLÓ:") || lastLine.startsWith("FAILED:");
|
|
894
|
+
const isRetrying = lastLine.startsWith("REINTENTO:") || lastLine.startsWith("LÍMITE:") || lastLine.startsWith("RETRY:") || lastLine.startsWith("LIMIT:");
|
|
694
895
|
if (ag.status === "busy") {
|
|
695
896
|
box.style.border.fg = "yellow";
|
|
696
897
|
box.setLabel(
|
|
697
|
-
` {bold}${escBl(name)}{/bold} {yellow-fg}
|
|
898
|
+
` {bold}${escBl(name)}{/bold} {yellow-fg}${L.busy}{/yellow-fg} ${escBl(ag.task?.id || "")} `,
|
|
698
899
|
);
|
|
900
|
+
} else if (isFailed) {
|
|
901
|
+
box.style.border.fg = "red";
|
|
902
|
+
box.setLabel(` {bold}${escBl(name)}{/bold} {red-fg}${L.failed}{/red-fg} `);
|
|
903
|
+
} else if (isRetrying) {
|
|
904
|
+
box.style.border.fg = "yellow";
|
|
905
|
+
box.setLabel(` {bold}${escBl(name)}{/bold} {yellow-fg}${L.retrying}{/yellow-fg} `);
|
|
699
906
|
} else {
|
|
700
907
|
box.style.border.fg = "gray";
|
|
908
|
+
const agCostLabel = ag.totalCost > 0 ? ` {gray-fg}$${ag.totalCost.toFixed(2)}{/gray-fg}` : "";
|
|
701
909
|
box.setLabel(
|
|
702
|
-
` {bold}${escBl(name)}{/bold} {gray-fg}
|
|
910
|
+
` {bold}${escBl(name)}{/bold} {gray-fg}${L.idle}{/gray-fg}${agCostLabel} `,
|
|
703
911
|
);
|
|
704
912
|
}
|
|
705
913
|
}
|
|
@@ -824,37 +1032,64 @@ function reloadQueue() {
|
|
|
824
1032
|
// session can detect it on next interaction without Modo Ausencia active.
|
|
825
1033
|
// ============================================================================
|
|
826
1034
|
function writeInboxNotification(task, agentName, elapsed) {
|
|
827
|
-
const inboxFile = path.join(WORKSPACE, "INBOX.md");
|
|
828
1035
|
const progressFile = `progress/PROGRESS-${agentName}.md`;
|
|
829
1036
|
const entry = [
|
|
830
1037
|
``,
|
|
831
|
-
|
|
1038
|
+
L.inboxDone(timestamp(), task.id, agentName),
|
|
832
1039
|
``,
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1040
|
+
`${L.inboxTaskLabel} ${task.title}`,
|
|
1041
|
+
`${L.inboxDurationLabel} ${formatDuration(elapsed)}`,
|
|
1042
|
+
`${L.inboxReportLabel} ${progressFile}`,
|
|
1043
|
+
L.inboxActionLabel(progressFile),
|
|
837
1044
|
``,
|
|
838
1045
|
].join("\n");
|
|
839
1046
|
try {
|
|
840
|
-
fs.appendFileSync(
|
|
1047
|
+
fs.appendFileSync(INBOX_FILE, entry, "utf-8");
|
|
841
1048
|
} catch {}
|
|
842
1049
|
}
|
|
843
1050
|
|
|
844
1051
|
function writeInboxFailureNotification(task, failedAgent, newAgent, reason) {
|
|
845
|
-
const inboxFile = path.join(WORKSPACE, "INBOX.md");
|
|
846
1052
|
const entry = [
|
|
847
1053
|
``,
|
|
848
|
-
|
|
1054
|
+
L.inboxFailed(timestamp(), task.id, failedAgent, newAgent),
|
|
849
1055
|
``,
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1056
|
+
`${L.inboxTaskLabel} ${task.title}`,
|
|
1057
|
+
`${L.inboxReasonLabel} ${reason}`,
|
|
1058
|
+
`${L.inboxNewAgentLabel} ${newAgent}`,
|
|
1059
|
+
L.inboxFailAction,
|
|
854
1060
|
``,
|
|
855
1061
|
].join("\n");
|
|
856
1062
|
try {
|
|
857
|
-
fs.appendFileSync(
|
|
1063
|
+
fs.appendFileSync(INBOX_FILE, entry, "utf-8");
|
|
1064
|
+
} catch {}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// GAP 2 — Move a task line from ## Pending to ## In Progress when it starts
|
|
1068
|
+
function moveTaskToInProgress(task) {
|
|
1069
|
+
if (!fs.existsSync(QUEUE_FILE)) return;
|
|
1070
|
+
try {
|
|
1071
|
+
const lines = fs.readFileSync(QUEUE_FILE, "utf-8").split("\n");
|
|
1072
|
+
const idMatcher = new RegExp(
|
|
1073
|
+
`^${task.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$|\\|)`,
|
|
1074
|
+
);
|
|
1075
|
+
// Remove from Pending (wherever it is) — keep line for insertion
|
|
1076
|
+
let taskLine = null;
|
|
1077
|
+
const without = lines.filter((l) => {
|
|
1078
|
+
if (idMatcher.test(l.trim())) {
|
|
1079
|
+
taskLine = l;
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
return true;
|
|
1083
|
+
});
|
|
1084
|
+
if (!taskLine) return; // already moved or not found
|
|
1085
|
+
// Find In Progress section and insert after its header
|
|
1086
|
+
const idx = without.findIndex(
|
|
1087
|
+
(l) =>
|
|
1088
|
+
l.trim().startsWith("## In Progress") ||
|
|
1089
|
+
l.trim().startsWith("## En progreso"),
|
|
1090
|
+
);
|
|
1091
|
+
if (idx >= 0) without.splice(idx + 1, 0, taskLine);
|
|
1092
|
+
fs.writeFileSync(QUEUE_FILE, without.join("\n"), "utf-8");
|
|
858
1093
|
} catch {}
|
|
859
1094
|
}
|
|
860
1095
|
|
|
@@ -1069,10 +1304,7 @@ function launchAgent(task) {
|
|
|
1069
1304
|
const agentCfg = AGENTS[agentName];
|
|
1070
1305
|
|
|
1071
1306
|
if (!ag || !agentCfg) {
|
|
1072
|
-
log(
|
|
1073
|
-
"ERROR",
|
|
1074
|
-
`Agente desconocido en QUEUE: "${agentName}" — no está definido en orchestrator.config.json`,
|
|
1075
|
-
);
|
|
1307
|
+
log("ERROR", L.logUnknownAgent(task.id, agentName));
|
|
1076
1308
|
failedTasks.set(task.id, MAX_RETRIES); // don't retry — config bug, not transient
|
|
1077
1309
|
return false;
|
|
1078
1310
|
}
|
|
@@ -1094,10 +1326,10 @@ function launchAgent(task) {
|
|
|
1094
1326
|
log("START", `${agentName} (${cliCmd}) → ${task.id}: ${task.title}`);
|
|
1095
1327
|
appendToAgent(
|
|
1096
1328
|
agentName,
|
|
1097
|
-
`{cyan-fg}
|
|
1329
|
+
`{cyan-fg}${escBl(L.agentTaskHeader(task.id, task.title))}{/cyan-fg}`,
|
|
1098
1330
|
true,
|
|
1099
1331
|
);
|
|
1100
|
-
appendToAgent(agentName, `{gray-fg}
|
|
1332
|
+
appendToAgent(agentName, `{gray-fg}${escBl(L.agentCwd(repoDir))}{/gray-fg}`, true);
|
|
1101
1333
|
appendToAgent(agentName, "", true);
|
|
1102
1334
|
|
|
1103
1335
|
try {
|
|
@@ -1112,8 +1344,8 @@ function launchAgent(task) {
|
|
|
1112
1344
|
proc.stdin.end();
|
|
1113
1345
|
|
|
1114
1346
|
const timeout = setTimeout(() => {
|
|
1115
|
-
log("WARN",
|
|
1116
|
-
appendToAgent(agentName,
|
|
1347
|
+
log("WARN", L.logTimeout(agentName, task.id));
|
|
1348
|
+
appendToAgent(agentName, `{red-fg}${escBl(L.agentTimeout)}{/red-fg}`, true);
|
|
1117
1349
|
try {
|
|
1118
1350
|
proc.kill("SIGTERM");
|
|
1119
1351
|
} catch {}
|
|
@@ -1159,8 +1391,22 @@ function launchAgent(task) {
|
|
|
1159
1391
|
}
|
|
1160
1392
|
}
|
|
1161
1393
|
}
|
|
1394
|
+
// Claude: result event with total_cost_usd
|
|
1162
1395
|
if (event.type === "result" && event.total_cost_usd)
|
|
1163
1396
|
ag.cost = event.total_cost_usd;
|
|
1397
|
+
// Codex: direct cost_usd field (any event)
|
|
1398
|
+
if (event.cost_usd != null && ag.cost == null)
|
|
1399
|
+
ag.cost = event.cost_usd;
|
|
1400
|
+
if (event.usage?.cost_usd != null && ag.cost == null)
|
|
1401
|
+
ag.cost = event.usage.cost_usd;
|
|
1402
|
+
// Codex: token-based cost calculation from usage event
|
|
1403
|
+
if (ag.cost == null && event.usage &&
|
|
1404
|
+
(event.type === "usage" || event.type === "result" || event.type === "response.completed")) {
|
|
1405
|
+
const usageObj = event.usage.usage || event.usage;
|
|
1406
|
+
const model = agentCfg.model || "";
|
|
1407
|
+
const calc = calcOpenAICost(model, usageObj);
|
|
1408
|
+
if (calc != null) ag.cost = calc;
|
|
1409
|
+
}
|
|
1164
1410
|
// OpenCode events
|
|
1165
1411
|
if (event.type === "text" && event.part?.text)
|
|
1166
1412
|
appendToAgent(agentName, event.part.text.slice(0, 120));
|
|
@@ -1230,6 +1476,7 @@ function launchAgent(task) {
|
|
|
1230
1476
|
ag.turns = 0;
|
|
1231
1477
|
task.status = "running";
|
|
1232
1478
|
state.inProgress.push(task);
|
|
1479
|
+
moveTaskToInProgress(task); // GAP 2: reflect in QUEUE.md
|
|
1233
1480
|
renderDashboard();
|
|
1234
1481
|
return true;
|
|
1235
1482
|
} catch (err) {
|
|
@@ -1251,7 +1498,10 @@ function completeTask(task, agentName) {
|
|
|
1251
1498
|
const elapsed = ag.startTime
|
|
1252
1499
|
? Math.round((Date.now() - ag.startTime) / 1000)
|
|
1253
1500
|
: 0;
|
|
1254
|
-
if (ag.cost)
|
|
1501
|
+
if (ag.cost) {
|
|
1502
|
+
state.totalCost += ag.cost;
|
|
1503
|
+
ag.totalCost = (ag.totalCost || 0) + ag.cost;
|
|
1504
|
+
}
|
|
1255
1505
|
task.status = "completed";
|
|
1256
1506
|
task.completedAt = timestamp();
|
|
1257
1507
|
task.elapsed = elapsed;
|
|
@@ -1259,21 +1509,18 @@ function completeTask(task, agentName) {
|
|
|
1259
1509
|
state.inProgress = state.inProgress.filter((t) => t.id !== task.id);
|
|
1260
1510
|
state.completed.push(task);
|
|
1261
1511
|
const costStr = ag.cost ? ` ($${ag.cost.toFixed(2)})` : "";
|
|
1262
|
-
log(
|
|
1263
|
-
"DONE",
|
|
1264
|
-
`${agentName} completó ${task.id} en ${formatDuration(elapsed)}${costStr}`,
|
|
1265
|
-
);
|
|
1512
|
+
log("DONE", L.logDone(agentName, task.id, formatDuration(elapsed), costStr));
|
|
1266
1513
|
appendToAgent(agentName, "", true);
|
|
1267
1514
|
appendToAgent(
|
|
1268
1515
|
agentName,
|
|
1269
|
-
`{green-fg}
|
|
1516
|
+
`{green-fg}${escBl(L.agentCompleted(formatDuration(elapsed), costStr))}{/green-fg}`,
|
|
1270
1517
|
true,
|
|
1271
1518
|
);
|
|
1272
1519
|
ag.status = "idle";
|
|
1273
1520
|
ag.task = null;
|
|
1274
1521
|
ag.process = null;
|
|
1275
1522
|
ag.startTime = null;
|
|
1276
|
-
ag.lastLine =
|
|
1523
|
+
ag.lastLine = L.lastCompleted(task.id);
|
|
1277
1524
|
updateQueueFile(task);
|
|
1278
1525
|
writeInboxNotification(task, agentName, elapsed);
|
|
1279
1526
|
scheduleNext();
|
|
@@ -1337,21 +1584,18 @@ function failTask(task, agentName, code) {
|
|
|
1337
1584
|
if (rl.isRateLimit) {
|
|
1338
1585
|
const resetStr = rl.resetsAt
|
|
1339
1586
|
? `resets ${rl.resetsAt.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true })}`
|
|
1340
|
-
: "
|
|
1341
|
-
log("RATE",
|
|
1587
|
+
: "retry in 10 min";
|
|
1588
|
+
log("RATE", L.logRateLimit(agentName, task.id, resetStr));
|
|
1342
1589
|
appendToAgent(
|
|
1343
1590
|
agentName,
|
|
1344
|
-
`{yellow-fg}
|
|
1591
|
+
`{yellow-fg}${escBl(L.agentRateLimit(resetStr))}{/yellow-fg}`,
|
|
1345
1592
|
true,
|
|
1346
1593
|
);
|
|
1347
1594
|
} else {
|
|
1348
|
-
log(
|
|
1349
|
-
"FAIL",
|
|
1350
|
-
`${agentName} falló ${task.id} (salida ${code}, ${retries}/${maxRetries})`,
|
|
1351
|
-
);
|
|
1595
|
+
log("FAIL", L.logFail(agentName, task.id, code, retries, maxRetries));
|
|
1352
1596
|
appendToAgent(
|
|
1353
1597
|
agentName,
|
|
1354
|
-
`{red-fg}
|
|
1598
|
+
`{red-fg}${escBl(L.agentFailed(code, retries))}{/red-fg}`,
|
|
1355
1599
|
true,
|
|
1356
1600
|
);
|
|
1357
1601
|
}
|
|
@@ -1365,14 +1609,17 @@ function failTask(task, agentName, code) {
|
|
|
1365
1609
|
["Codex", "OpenCode"].includes(agentName) &&
|
|
1366
1610
|
(failureFlags.exhaustedQuota ||
|
|
1367
1611
|
failureFlags.providerUnavailable ||
|
|
1612
|
+
failureFlags.noRealWork ||
|
|
1368
1613
|
retries >= maxRetries);
|
|
1369
1614
|
|
|
1370
1615
|
if (shouldFallback) {
|
|
1371
1616
|
const reason = failureFlags.exhaustedQuota
|
|
1372
|
-
?
|
|
1617
|
+
? L.reasonQuota
|
|
1373
1618
|
: failureFlags.providerUnavailable
|
|
1374
|
-
?
|
|
1375
|
-
:
|
|
1619
|
+
? L.reasonProvider
|
|
1620
|
+
: failureFlags.noRealWork
|
|
1621
|
+
? L.reasonNoWork
|
|
1622
|
+
: L.reasonPersistent;
|
|
1376
1623
|
if (tryFallbackToAlternative(task, agentName, reason)) {
|
|
1377
1624
|
writeInboxFailureNotification(task, agentName, task.agent, reason);
|
|
1378
1625
|
setTimeout(() => {
|
|
@@ -1385,8 +1632,8 @@ function failTask(task, agentName, code) {
|
|
|
1385
1632
|
|
|
1386
1633
|
if (retries >= maxRetries) {
|
|
1387
1634
|
task.status = "failed";
|
|
1388
|
-
ag.lastLine =
|
|
1389
|
-
log("ERROR",
|
|
1635
|
+
ag.lastLine = L.lastFailed(task.id);
|
|
1636
|
+
log("ERROR", L.logPermanentFail(task.id, retries));
|
|
1390
1637
|
} else {
|
|
1391
1638
|
task.status = "pending";
|
|
1392
1639
|
let retryDelay = rl.isRateLimit
|
|
@@ -1402,8 +1649,8 @@ function failTask(task, agentName, code) {
|
|
|
1402
1649
|
hour12: true,
|
|
1403
1650
|
});
|
|
1404
1651
|
ag.lastLine = rl.isRateLimit
|
|
1405
|
-
?
|
|
1406
|
-
:
|
|
1652
|
+
? L.lastLimit(task.id, retryTime)
|
|
1653
|
+
: L.lastRetry(task.id);
|
|
1407
1654
|
if (rl.isRateLimit) rateLimitedAgents.set(agentName, task._retryAfter);
|
|
1408
1655
|
}
|
|
1409
1656
|
if (rl.isRateLimit && task._retryAfter) {
|
|
@@ -1459,11 +1706,13 @@ function scheduleNext() {
|
|
|
1459
1706
|
function updateQueueFile(completedTask) {
|
|
1460
1707
|
if (!fs.existsSync(QUEUE_FILE)) return;
|
|
1461
1708
|
const lines = fs.readFileSync(QUEUE_FILE, "utf-8").split("\n");
|
|
1462
|
-
//
|
|
1709
|
+
// Word-boundary match so TASK-1 does NOT also remove TASK-10, TASK-11, etc.
|
|
1463
1710
|
const idMatcher = new RegExp(
|
|
1464
1711
|
`^${completedTask.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$|\\|)`,
|
|
1465
1712
|
);
|
|
1713
|
+
// Remove task from both Pending and In Progress sections
|
|
1466
1714
|
const filtered = lines.filter((l) => !idMatcher.test(l.trim()));
|
|
1715
|
+
// Find Completed section and insert entry
|
|
1467
1716
|
const idx = filtered.findIndex(
|
|
1468
1717
|
(l) =>
|
|
1469
1718
|
l.trim().startsWith("## Completed") ||
|
|
@@ -1512,6 +1761,14 @@ function detectSupportAgentFailure(agentName) {
|
|
|
1512
1761
|
}
|
|
1513
1762
|
|
|
1514
1763
|
const lower = content.toLowerCase();
|
|
1764
|
+
|
|
1765
|
+
const hasToolUses = content.includes("Write") || content.includes("Read") ||
|
|
1766
|
+
content.includes("Bash") || content.includes("Edit") || content.includes("ToolUse");
|
|
1767
|
+
const hasFilesModified = content.includes("files_modified") &&
|
|
1768
|
+
!content.includes("files_modified: list") && !content.includes("files_modified: none");
|
|
1769
|
+
const outputTooShort = ag.output && ag.output.length < 200;
|
|
1770
|
+
const noRealWork = outputTooShort && !hasToolUses && !hasFilesModified;
|
|
1771
|
+
|
|
1515
1772
|
return {
|
|
1516
1773
|
exhaustedQuota:
|
|
1517
1774
|
lower.includes("out of extra usage") ||
|
|
@@ -1519,7 +1776,9 @@ function detectSupportAgentFailure(agentName) {
|
|
|
1519
1776
|
lower.includes("quota") ||
|
|
1520
1777
|
lower.includes("rate_limit") ||
|
|
1521
1778
|
lower.includes("ratelimitexceeded") ||
|
|
1522
|
-
lower.includes("429")
|
|
1779
|
+
lower.includes("429") ||
|
|
1780
|
+
lower.includes("insufficient credits") ||
|
|
1781
|
+
lower.includes("no credits"),
|
|
1523
1782
|
providerUnavailable:
|
|
1524
1783
|
lower.includes("session expired") ||
|
|
1525
1784
|
lower.includes("authentication") ||
|
|
@@ -1532,6 +1791,7 @@ function detectSupportAgentFailure(agentName) {
|
|
|
1532
1791
|
lower.includes("timed out") ||
|
|
1533
1792
|
lower.includes("timeout") ||
|
|
1534
1793
|
lower.includes("network error"),
|
|
1794
|
+
noRealWork: noRealWork || lower.includes("no files") || lower.includes("nothing to"),
|
|
1535
1795
|
};
|
|
1536
1796
|
}
|
|
1537
1797
|
|
|
@@ -1586,24 +1846,55 @@ function tryFallbackToAlternative(task, failedAgentName, reason) {
|
|
|
1586
1846
|
failedTasks.set(task.id, 0);
|
|
1587
1847
|
state.queue.push(task);
|
|
1588
1848
|
|
|
1589
|
-
log(
|
|
1590
|
-
"FALLBACK",
|
|
1591
|
-
`${task.id} reasignada de ${failedAgentName} a ${targetAgent} (${reason})`,
|
|
1592
|
-
);
|
|
1849
|
+
log("FALLBACK", L.logFallback(task.id, failedAgentName, targetAgent, reason));
|
|
1593
1850
|
appendToAgent(
|
|
1594
1851
|
failedAgentName,
|
|
1595
|
-
`{yellow-fg}
|
|
1852
|
+
`{yellow-fg}${escBl(L.agentReassigned(targetAgent, reason))}{/yellow-fg}`,
|
|
1596
1853
|
true,
|
|
1597
1854
|
);
|
|
1598
1855
|
if (!queueUpdated) {
|
|
1599
|
-
log(
|
|
1600
|
-
"WARN",
|
|
1601
|
-
`${task.id} reasignada a ${targetAgent}, pero QUEUE.md no pudo actualizarse`,
|
|
1602
|
-
);
|
|
1856
|
+
log("WARN", L.logReassignWarn(task.id, targetAgent));
|
|
1603
1857
|
}
|
|
1858
|
+
|
|
1859
|
+
// Notificar a Claude (sesión principal) cuando hay fallback
|
|
1860
|
+
notifyClaudeOfFallback(task, failedAgentName, targetAgent, reason);
|
|
1604
1861
|
return true;
|
|
1605
1862
|
}
|
|
1606
1863
|
|
|
1864
|
+
// ============================================================================
|
|
1865
|
+
// CLAUDE FALLBACK NOTIFIER — avisa a Claude principal cuando hay reasignación
|
|
1866
|
+
// ============================================================================
|
|
1867
|
+
function notifyClaudeOfFallback(task, fromAgent, toAgent, reason) {
|
|
1868
|
+
const lang = WORKSPACE_LANGUAGE;
|
|
1869
|
+
const prompt = lang === 'es'
|
|
1870
|
+
? `⚠️ FALLBACK: La tarea "${task.id}: ${task.title}" falló en ${fromAgent} (${reason}) y fue reasignada a ${toAgent}.
|
|
1871
|
+
|
|
1872
|
+
Estado actual:
|
|
1873
|
+
- QUEUE.md tiene ahora la tarea asignada a ${toAgent}
|
|
1874
|
+
- El agente ${toAgent} está procediendo automáticamente
|
|
1875
|
+
|
|
1876
|
+
Acción: No necesitas hacer nada — solo toma nota del cambio. El orquestador将继续 automáticamente.
|
|
1877
|
+
Si quieres revisar el progreso, lee INBOX.md o STATUS.md.`
|
|
1878
|
+
: `⚠️ FALLBACK: Task "${task.id}: ${task.title}" failed on ${fromAgent} (${reason}) and was reassigned to ${toAgent}.
|
|
1879
|
+
|
|
1880
|
+
Current state:
|
|
1881
|
+
- QUEUE.md now has the task assigned to ${toAgent}
|
|
1882
|
+
- Agent ${toAgent} is proceeding automatically
|
|
1883
|
+
|
|
1884
|
+
Action: You don't need to do anything — just take note of the change. The orchestrator will continue automatically.
|
|
1885
|
+
If you want to check progress, read INBOX.md or STATUS.md.`;
|
|
1886
|
+
|
|
1887
|
+
const logPath = path.join(LOG_DIR, `fallback-notify-${Date.now()}.log`);
|
|
1888
|
+
try {
|
|
1889
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1890
|
+
const child = spawn('claude', ['-p', prompt, '--add-dir', WORKSPACE, '--dangerously-skip-permissions'], {
|
|
1891
|
+
cwd: WORKSPACE, stdio: ['ignore', logFd, logFd], shell: true, windowsHide: true, detached: true
|
|
1892
|
+
});
|
|
1893
|
+
fs.closeSync(logFd);
|
|
1894
|
+
child.unref();
|
|
1895
|
+
} catch {}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1607
1898
|
// ============================================================================
|
|
1608
1899
|
// KEYBOARD
|
|
1609
1900
|
// ============================================================================
|
|
@@ -1612,23 +1903,11 @@ if (!CLI.headless && screen) {
|
|
|
1612
1903
|
exitWithSummary();
|
|
1613
1904
|
});
|
|
1614
1905
|
|
|
1615
|
-
screen.key("s", () => {
|
|
1616
|
-
if (state.paused) {
|
|
1617
|
-
state.paused = false;
|
|
1618
|
-
log("INFO", "Reanudado");
|
|
1619
|
-
}
|
|
1620
|
-
scheduleNext();
|
|
1621
|
-
safeRenderDashboard();
|
|
1622
|
-
});
|
|
1623
1906
|
screen.key("p", () => {
|
|
1624
1907
|
state.paused = !state.paused;
|
|
1625
1908
|
log("INFO", state.paused ? L.paused : L.resumed);
|
|
1626
1909
|
safeRenderDashboard();
|
|
1627
|
-
|
|
1628
|
-
screen.key("r", () => {
|
|
1629
|
-
reloadQueue();
|
|
1630
|
-
log("INFO", L.queueReloaded(state.queue.length));
|
|
1631
|
-
safeRenderDashboard();
|
|
1910
|
+
updateStatusFile();
|
|
1632
1911
|
});
|
|
1633
1912
|
}
|
|
1634
1913
|
|
|
@@ -1641,6 +1920,7 @@ log("INFO", L.loadedCompleted(state.completed.length));
|
|
|
1641
1920
|
reloadQueue();
|
|
1642
1921
|
log("INFO", `${L.queue}: ${state.queue.length} ${L.tasks}`);
|
|
1643
1922
|
renderDashboard();
|
|
1923
|
+
updateStatusFile();
|
|
1644
1924
|
if (!state.paused) {
|
|
1645
1925
|
scheduleNext();
|
|
1646
1926
|
renderDashboard();
|
|
@@ -1651,19 +1931,249 @@ setInterval(() => {
|
|
|
1651
1931
|
if (command) applyControlCommand(command);
|
|
1652
1932
|
}, 1000);
|
|
1653
1933
|
|
|
1934
|
+
// Real-time queue detection via fs.watch — fires immediately when QUEUE.md changes
|
|
1935
|
+
// (e.g. Claude writes a new task). No more 30s delay.
|
|
1936
|
+
let _queueWatchDebounce = null;
|
|
1937
|
+
function startQueueWatcher() {
|
|
1938
|
+
if (!fs.existsSync(QUEUE_FILE)) return;
|
|
1939
|
+
try {
|
|
1940
|
+
const watcher = fs.watch(QUEUE_FILE, {persistent: false}, (eventType) => {
|
|
1941
|
+
if (eventType !== 'change') return;
|
|
1942
|
+
if (_queueWatchDebounce) clearTimeout(_queueWatchDebounce);
|
|
1943
|
+
_queueWatchDebounce = setTimeout(() => {
|
|
1944
|
+
const prevCount = state.queue.length;
|
|
1945
|
+
reloadQueue();
|
|
1946
|
+
if (!state.paused) scheduleNext();
|
|
1947
|
+
renderDashboard();
|
|
1948
|
+
if (state.queue.length > prevCount)
|
|
1949
|
+
log("INFO", WORKSPACE_LANGUAGE === "es"
|
|
1950
|
+
? `Nueva tarea detectada en QUEUE.md`
|
|
1951
|
+
: `New task detected in QUEUE.md`);
|
|
1952
|
+
}, 400);
|
|
1953
|
+
});
|
|
1954
|
+
watcher.on('error', () => {});
|
|
1955
|
+
} catch {}
|
|
1956
|
+
}
|
|
1957
|
+
startQueueWatcher();
|
|
1958
|
+
|
|
1959
|
+
// Slow fallback (5 min) — only runs if there is actually pending work or busy agents
|
|
1960
|
+
// fs.watch handles real-time; this is just a safety net
|
|
1654
1961
|
setInterval(() => {
|
|
1962
|
+
const busy = Object.values(state.agents).some(a => a.status === 'busy');
|
|
1963
|
+
if (state.paused || (state.queue.length === 0 && !busy)) return;
|
|
1655
1964
|
reloadQueue();
|
|
1656
|
-
|
|
1965
|
+
scheduleNext();
|
|
1657
1966
|
renderDashboard();
|
|
1658
|
-
},
|
|
1967
|
+
}, 5 * 60 * 1000);
|
|
1968
|
+
|
|
1969
|
+
// ============================================================================
|
|
1970
|
+
// INBOX WATCHER — reacts immediately when a task completion is written to INBOX.md
|
|
1971
|
+
// Spawns headless Claude to check if a new implementation task needs to be created
|
|
1972
|
+
// ============================================================================
|
|
1973
|
+
let _inboxDebounce = null;
|
|
1974
|
+
let _lastInboxContent = '';
|
|
1975
|
+
let _inboxDispatching = false;
|
|
1976
|
+
|
|
1977
|
+
function dispatchInboxClaude() {
|
|
1978
|
+
if (_inboxDispatching) return;
|
|
1979
|
+
let content = '';
|
|
1980
|
+
try { content = fs.existsSync(INBOX_FILE) ? fs.readFileSync(INBOX_FILE, 'utf-8') : ''; } catch {}
|
|
1981
|
+
if (!content.trim() || content === _lastInboxContent) return;
|
|
1982
|
+
|
|
1983
|
+
_lastInboxContent = content;
|
|
1984
|
+
_inboxDispatching = true;
|
|
1985
|
+
|
|
1986
|
+
const lang = WORKSPACE_LANGUAGE;
|
|
1987
|
+
const prompt = lang === 'es'
|
|
1988
|
+
? `Eres el orquestador de este workspace. Tu única misión ahora es procesar el INBOX.
|
|
1989
|
+
|
|
1990
|
+
Pasos:
|
|
1991
|
+
1. Lee INBOX.md en ${WORKSPACE}
|
|
1992
|
+
2. Lee QUEUE.md en ${WORKSPACE} para ver las tareas existentes (secciones Pendientes, En progreso, Completadas)
|
|
1993
|
+
|
|
1994
|
+
Si en INBOX.md hay análisis completados de un agente (especialmente OpenCode) que aún NO tienen su tarea de implementación en la sección ## Pendientes de QUEUE.md:
|
|
1995
|
+
- Determina el siguiente TASK ID disponible leyendo QUEUE.md
|
|
1996
|
+
- Crea la nueva TASK en QUEUE.md con el formato exacto:
|
|
1997
|
+
TASK-NNN | título corto | Codex | P1 | repo | descripción basada en el análisis
|
|
1998
|
+
|
|
1999
|
+
Si ya existe la tarea correspondiente, o el análisis no está completo, responde solo: "Sin acción necesaria."
|
|
2000
|
+
|
|
2001
|
+
Reglas: No hagas commit ni push. No analices código del proyecto. Solo lee INBOX.md y QUEUE.md, y edita QUEUE.md si hace falta.`
|
|
2002
|
+
: `You are the orchestrator for this workspace. Your only mission now is to process the INBOX.
|
|
2003
|
+
|
|
2004
|
+
Steps:
|
|
2005
|
+
1. Read INBOX.md in ${WORKSPACE}
|
|
2006
|
+
2. Read QUEUE.md in ${WORKSPACE} to see existing tasks (sections Pending, In Progress, Completed)
|
|
2007
|
+
|
|
2008
|
+
If INBOX.md contains completed analyses from an agent (especially OpenCode) that do NOT yet have a corresponding implementation task in the ## Pending section of QUEUE.md:
|
|
2009
|
+
- Determine the next available TASK ID by reading QUEUE.md
|
|
2010
|
+
- Create the new TASK in QUEUE.md with the exact format:
|
|
2011
|
+
TASK-NNN | short title | Codex | P1 | repo | description based on the analysis
|
|
2012
|
+
|
|
2013
|
+
If the corresponding task already exists, or the analysis is not complete, reply only: "No action needed."
|
|
2014
|
+
|
|
2015
|
+
Rules: Do not commit or push. Do not analyze project code. Only read INBOX.md and QUEUE.md, and edit QUEUE.md if necessary.`;
|
|
2016
|
+
|
|
2017
|
+
const logPath = path.join(LOG_DIR, `inbox-trigger-${Date.now()}.log`);
|
|
2018
|
+
try {
|
|
2019
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
2020
|
+
const child = spawn('claude', [
|
|
2021
|
+
'-p', prompt,
|
|
2022
|
+
'--add-dir', WORKSPACE,
|
|
2023
|
+
'--dangerously-skip-permissions'
|
|
2024
|
+
], {
|
|
2025
|
+
cwd: WORKSPACE,
|
|
2026
|
+
stdio: ['ignore', logFd, logFd],
|
|
2027
|
+
shell: true,
|
|
2028
|
+
windowsHide: true,
|
|
2029
|
+
detached: true
|
|
2030
|
+
});
|
|
2031
|
+
fs.closeSync(logFd);
|
|
2032
|
+
child.unref();
|
|
2033
|
+
log('INFO', lang === 'es'
|
|
2034
|
+
? 'INBOX: Claude despachado para procesar notificación'
|
|
2035
|
+
: 'INBOX: Claude dispatched to process notification');
|
|
2036
|
+
} catch {}
|
|
2037
|
+
setTimeout(() => { _inboxDispatching = false; }, 3 * 60 * 1000);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function startInboxWatcher() {
|
|
2041
|
+
if (!fs.existsSync(INBOX_FILE)) {
|
|
2042
|
+
try { fs.writeFileSync(INBOX_FILE, '', 'utf-8'); } catch {}
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
const watcher = fs.watch(INBOX_FILE, {persistent: false}, (eventType) => {
|
|
2046
|
+
if (eventType !== 'change') return;
|
|
2047
|
+
if (_inboxDebounce) clearTimeout(_inboxDebounce);
|
|
2048
|
+
_inboxDebounce = setTimeout(dispatchInboxClaude, 600);
|
|
2049
|
+
});
|
|
2050
|
+
watcher.on('error', () => {});
|
|
2051
|
+
} catch {}
|
|
2052
|
+
}
|
|
2053
|
+
startInboxWatcher();
|
|
2054
|
+
|
|
2055
|
+
// ============================================================================
|
|
2056
|
+
// AWAY MODE WATCHER — monitors .away-mode file; when active runs periodic
|
|
2057
|
+
// health checks via headless Claude; auto-deactivates when all tasks are done
|
|
2058
|
+
// ============================================================================
|
|
2059
|
+
let _awayModeTimer = null;
|
|
2060
|
+
let _awayModeActive = false;
|
|
2061
|
+
|
|
2062
|
+
function runAwayModeCheck() {
|
|
2063
|
+
if (!fs.existsSync(AWAY_MODE_FILE)) {
|
|
2064
|
+
deactivateAwayMode();
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
const lang = WORKSPACE_LANGUAGE;
|
|
2069
|
+
const pendingTasks = state.queue.filter(t => !t.status || t.status === 'pending');
|
|
2070
|
+
const inProgressTasks = state.inProgress || [];
|
|
2071
|
+
const busy = Object.values(state.agents).some(a => a.status === 'busy');
|
|
2072
|
+
const completedCount = (state.completed || []).length;
|
|
2073
|
+
const hasWork = pendingTasks.length > 0 || inProgressTasks.length > 0 || busy;
|
|
2074
|
+
|
|
2075
|
+
if (!hasWork && completedCount > 0) {
|
|
2076
|
+
try { fs.unlinkSync(AWAY_MODE_FILE); } catch {}
|
|
2077
|
+
deactivateAwayMode();
|
|
2078
|
+
|
|
2079
|
+
const donePrompt = lang === 'es'
|
|
2080
|
+
? `Modo Ausencia terminado. Todas las tareas se completaron mientras estabas ausente.\n\nLee QUEUE.md en ${WORKSPACE} y dame un resumen de todo lo que se logró durante la sesión.\nLuego dime si hay algo que podamos continuar o integrar a partir de lo que ya se hizo, o pregúntame qué quiero priorizar a continuación.`
|
|
2081
|
+
: `Away Mode ended. All tasks were completed while you were away.\n\nRead QUEUE.md in ${WORKSPACE} and give me a summary of everything accomplished during the session.\nThen tell me if there is anything we can continue or integrate from what was done, or ask me what I want to prioritize next.`;
|
|
2082
|
+
|
|
2083
|
+
const logPath = path.join(LOG_DIR, `away-done-${Date.now()}.log`);
|
|
2084
|
+
try {
|
|
2085
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
2086
|
+
const child = spawn('claude', ['-p', donePrompt, '--add-dir', WORKSPACE, '--dangerously-skip-permissions'], {
|
|
2087
|
+
cwd: WORKSPACE, stdio: ['ignore', logFd, logFd], shell: true, windowsHide: true, detached: true
|
|
2088
|
+
});
|
|
2089
|
+
fs.closeSync(logFd);
|
|
2090
|
+
child.unref();
|
|
2091
|
+
log('INFO', lang === 'es' ? 'Modo Ausencia: todo completado — resumen final enviado.' : 'Away Mode: all done — final summary dispatched.');
|
|
2092
|
+
} catch {}
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (!hasWork) return;
|
|
2097
|
+
|
|
2098
|
+
const lines = [];
|
|
2099
|
+
if (pendingTasks.length > 0) {
|
|
2100
|
+
lines.push(lang === 'es' ? `Tareas pendientes: ${pendingTasks.length}` : `Pending tasks: ${pendingTasks.length}`);
|
|
2101
|
+
pendingTasks.slice(0, 5).forEach(t => lines.push(` - ${t.id}: ${t.title}`));
|
|
2102
|
+
}
|
|
2103
|
+
if (inProgressTasks.length > 0) {
|
|
2104
|
+
lines.push(lang === 'es'
|
|
2105
|
+
? `En progreso: ${inProgressTasks.map(t => `${t.id} (${t.agent})`).join(', ')}`
|
|
2106
|
+
: `In progress: ${inProgressTasks.map(t => `${t.id} (${t.agent})`).join(', ')}`);
|
|
2107
|
+
}
|
|
2108
|
+
const failedAgents = Object.entries(state.agents)
|
|
2109
|
+
.filter(([, a]) => /^(FALLÓ|FAILED):/.test(a.lastLine || ''))
|
|
2110
|
+
.map(([n, a]) => `${n}: ${a.lastLine}`);
|
|
2111
|
+
if (failedAgents.length > 0) {
|
|
2112
|
+
lines.push(lang === 'es' ? `Agentes con fallo: ${failedAgents.join(' | ')}` : `Failed agents: ${failedAgents.join(' | ')}`);
|
|
2113
|
+
}
|
|
2114
|
+
if (completedCount > 0) {
|
|
2115
|
+
lines.push(lang === 'es' ? `Completadas: ${completedCount}` : `Completed: ${completedCount}`);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const stateCtx = lines.join('\n');
|
|
2119
|
+
const monitorPrompt = lang === 'es'
|
|
2120
|
+
? `Modo Ausencia activo — revisión automática cada 5 minutos.\n\nEstado del orquestador:\n${stateCtx}\n\nInstrucciones:\n1. Lee INBOX.md — si hay análisis completados sin tarea de implementación en QUEUE.md, créala\n2. Lee QUEUE.md — si hay tareas fallidas no reasignadas, reasígnalas al siguiente agente\n3. Si hay tareas pendientes sin asignar a ningún agente (agent = >0 o vacío), asígnalas a un agente idle (Codex u OpenCode)\n4. Si hay agentes idle y tareas pendientes sin procesar, revisa bloqueos y resuélvelos\n5. Si todo avanza, no hagas nada y responde brevemente "TodoOK"\n\nNo hagas commit ni push. No inventes tareas nuevas.`
|
|
2121
|
+
: `Away Mode active — automatic check every 5 minutes.\n\nOrchestrator state:\n${stateCtx}\n\nInstructions:\n1. Read INBOX.md — if there are completed analyses without implementation tasks in QUEUE.md, create them\n2. Read QUEUE.md — if there are failed tasks not reassigned, reassign to next available agent\n3. If there are pending tasks with no agent assigned (agent = >0 or empty), assign them to an idle agent (Codex or OpenCode)\n4. If there are idle agents and pending tasks not being processed, check for blocking issues\n5. If everything is progressing, do nothing and respond briefly "AllGood"\n\nDo not commit or push. Do not invent new tasks.`;
|
|
2122
|
+
|
|
2123
|
+
const logPath = path.join(LOG_DIR, `away-check-${Date.now()}.log`);
|
|
2124
|
+
try {
|
|
2125
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
2126
|
+
const child = spawn('claude', ['-p', monitorPrompt, '--add-dir', WORKSPACE, '--dangerously-skip-permissions'], {
|
|
2127
|
+
cwd: WORKSPACE, stdio: ['ignore', logFd, logFd], shell: true, windowsHide: true, detached: true
|
|
2128
|
+
});
|
|
2129
|
+
fs.closeSync(logFd);
|
|
2130
|
+
child.unref();
|
|
2131
|
+
log('INFO', lang === 'es' ? 'Modo Ausencia: revisión automática disparada.' : 'Away Mode: automatic check dispatched.');
|
|
2132
|
+
} catch {}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function activateAwayMode() {
|
|
2136
|
+
if (_awayModeActive) return;
|
|
2137
|
+
_awayModeActive = true;
|
|
2138
|
+
log('INFO', WORKSPACE_LANGUAGE === 'es' ? 'Modo Ausencia activado.' : 'Away Mode activated.');
|
|
2139
|
+
runAwayModeCheck();
|
|
2140
|
+
_awayModeTimer = setInterval(runAwayModeCheck, 5 * 60 * 1000); // 5 minutos
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function deactivateAwayMode() {
|
|
2144
|
+
if (!_awayModeActive) return;
|
|
2145
|
+
_awayModeActive = false;
|
|
2146
|
+
if (_awayModeTimer) { clearInterval(_awayModeTimer); _awayModeTimer = null; }
|
|
2147
|
+
log('INFO', WORKSPACE_LANGUAGE === 'es' ? 'Modo Ausencia desactivado.' : 'Away Mode deactivated.');
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function startAwayModeWatcher() {
|
|
2151
|
+
if (fs.existsSync(AWAY_MODE_FILE)) activateAwayMode();
|
|
2152
|
+
try {
|
|
2153
|
+
const watcher = fs.watch(WORKSPACE, {persistent: false}, (eventType, filename) => {
|
|
2154
|
+
if (filename !== '.away-mode') return;
|
|
2155
|
+
if (fs.existsSync(AWAY_MODE_FILE)) {
|
|
2156
|
+
activateAwayMode();
|
|
2157
|
+
} else {
|
|
2158
|
+
deactivateAwayMode();
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
watcher.on('error', () => {});
|
|
2162
|
+
} catch {}
|
|
2163
|
+
}
|
|
2164
|
+
startAwayModeWatcher();
|
|
2165
|
+
|
|
2166
|
+
setInterval(() => {
|
|
2167
|
+
updateStatusFile();
|
|
2168
|
+
}, 60000); // Update STATUS.md cada 60 segundos
|
|
1659
2169
|
setInterval(() => {
|
|
1660
2170
|
for (const [name, ag] of Object.entries(state.agents)) {
|
|
1661
2171
|
if (ag.status !== "busy" || !ag.process) continue;
|
|
1662
2172
|
try {
|
|
1663
2173
|
process.kill(ag.process.pid, 0);
|
|
1664
2174
|
} catch {
|
|
1665
|
-
log("WARN",
|
|
1666
|
-
appendToAgent(name,
|
|
2175
|
+
log("WARN", L.logDied(name, ag.task?.id));
|
|
2176
|
+
appendToAgent(name, `{red-fg}${escBl(L.agentDied)}{/red-fg}`, true);
|
|
1667
2177
|
ag.process = null;
|
|
1668
2178
|
failTask(ag.task, name, -1);
|
|
1669
2179
|
}
|