karajan-code 1.10.1 → 1.11.0

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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Solomon rules engine — detects anomalies during session execution.
3
+ * Each rule returns { triggered: boolean, severity: "warn"|"critical", message, detail }
4
+ */
5
+
6
+ const DEFAULT_RULES = {
7
+ max_files_per_iteration: 10,
8
+ max_stale_iterations: 3,
9
+ no_new_dependencies_without_task: true,
10
+ scope_guard: true
11
+ };
12
+
13
+ export function evaluateRules(context, rulesConfig = {}) {
14
+ const rules = { ...DEFAULT_RULES, ...rulesConfig };
15
+ const alerts = [];
16
+
17
+ // Rule 1: Too many files modified
18
+ if (rules.max_files_per_iteration && context.filesChanged > rules.max_files_per_iteration) {
19
+ alerts.push({
20
+ rule: "max_files_per_iteration",
21
+ severity: "critical",
22
+ message: `Coder modified ${context.filesChanged} files (limit: ${rules.max_files_per_iteration}). Possible scope drift.`,
23
+ detail: { filesChanged: context.filesChanged, limit: rules.max_files_per_iteration }
24
+ });
25
+ }
26
+
27
+ // Rule 2: Stale iterations (no progress)
28
+ if (rules.max_stale_iterations && context.staleIterations >= rules.max_stale_iterations) {
29
+ alerts.push({
30
+ rule: "max_stale_iterations",
31
+ severity: "critical",
32
+ message: `${context.staleIterations} iterations without progress. Same errors repeating.`,
33
+ detail: { staleIterations: context.staleIterations, limit: rules.max_stale_iterations }
34
+ });
35
+ }
36
+
37
+ // Rule 3: New dependencies not in task
38
+ if (rules.no_new_dependencies_without_task && context.newDependencies?.length > 0) {
39
+ const depsNotInTask = context.newDependencies.filter(
40
+ dep => !context.task?.toLowerCase().includes(dep.toLowerCase())
41
+ );
42
+ if (depsNotInTask.length > 0) {
43
+ alerts.push({
44
+ rule: "no_new_dependencies_without_task",
45
+ severity: "warn",
46
+ message: `New dependencies added not mentioned in task: ${depsNotInTask.join(", ")}`,
47
+ detail: { dependencies: depsNotInTask }
48
+ });
49
+ }
50
+ }
51
+
52
+ // Rule 4: Scope guard — files outside expected scope
53
+ if (rules.scope_guard && context.outOfScopeFiles?.length > 0) {
54
+ alerts.push({
55
+ rule: "scope_guard",
56
+ severity: "warn",
57
+ message: `Files modified outside expected scope: ${context.outOfScopeFiles.join(", ")}`,
58
+ detail: { files: context.outOfScopeFiles }
59
+ });
60
+ }
61
+
62
+ return {
63
+ alerts,
64
+ hasCritical: alerts.some(a => a.severity === "critical"),
65
+ hasWarnings: alerts.some(a => a.severity === "warn")
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Build context for rules evaluation from git diff and session state.
71
+ */
72
+ export async function buildRulesContext({ session, task, iteration }) {
73
+ const context = {
74
+ task,
75
+ iteration,
76
+ filesChanged: 0,
77
+ staleIterations: 0,
78
+ newDependencies: [],
79
+ outOfScopeFiles: []
80
+ };
81
+
82
+ // Count files changed via git
83
+ try {
84
+ const { execaCommand } = await import("execa");
85
+ const baseRef = session.session_start_sha || "HEAD~1";
86
+
87
+ // Files changed
88
+ const diffResult = await execaCommand(`git diff --name-only ${baseRef}`, { reject: false });
89
+ if (diffResult.stdout) {
90
+ const files = diffResult.stdout.split("\n").filter(Boolean);
91
+ context.filesChanged = files.length;
92
+
93
+ // Detect scope: config files, CI/CD, etc. that are often out of scope
94
+ const scopePatterns = [".github/", ".gitlab-ci", "docker-compose", ".env", "firebase.json", "firestore.rules"];
95
+ context.outOfScopeFiles = files.filter(f =>
96
+ scopePatterns.some(pattern => f.includes(pattern))
97
+ );
98
+
99
+ // Detect new dependencies
100
+ if (files.includes("package.json")) {
101
+ try {
102
+ const pkgDiff = await execaCommand(`git diff ${baseRef} -- package.json`, { reject: false });
103
+ const addedDeps = (pkgDiff.stdout || "").split("\n")
104
+ .filter(line => line.startsWith("+") && line.includes('"') && !line.startsWith("+++"))
105
+ .map(line => {
106
+ const match = line.match(/"([^"]+)":\s*"/);
107
+ return match ? match[1] : null;
108
+ })
109
+ .filter(Boolean)
110
+ .filter(name => !["name", "version", "description", "main", "scripts", "type", "license", "author"].includes(name));
111
+ context.newDependencies = addedDeps;
112
+ } catch { /* ignore */ }
113
+ }
114
+ }
115
+ } catch { /* git not available */ }
116
+
117
+ // Count stale iterations from session checkpoints
118
+ const checkpoints = session.checkpoints || [];
119
+ const recentCoderCheckpoints = checkpoints
120
+ .filter(cp => cp.stage === "coder" || cp.stage === "reviewer")
121
+ .slice(-6); // Last 3 iterations (coder+reviewer each)
122
+
123
+ // Simple heuristic: if last N reviewer checkpoints all have the same note/feedback, it's stale
124
+ if (recentCoderCheckpoints.length >= 4) {
125
+ const lastFeedbacks = checkpoints
126
+ .filter(cp => cp.stage === "reviewer")
127
+ .slice(-3)
128
+ .map(cp => cp.note || "");
129
+ const uniqueFeedbacks = new Set(lastFeedbacks);
130
+ if (uniqueFeedbacks.size === 1 && lastFeedbacks.length >= 2) {
131
+ context.staleIterations = lastFeedbacks.length;
132
+ }
133
+ }
134
+
135
+ return context;
136
+ }
137
+
138
+ export { DEFAULT_RULES };
@@ -0,0 +1,70 @@
1
+ import { emitProgress, makeEvent } from "../utils/events.js";
2
+
3
+ const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
4
+ const MAX_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes
5
+ const MAX_STANDBY_RETRIES = 5;
6
+ const HEARTBEAT_INTERVAL_MS = 30 * 1000; // 30 seconds
7
+
8
+ /**
9
+ * Wait for a rate limit cooldown, emitting heartbeat events.
10
+ * Returns when the cooldown expires.
11
+ *
12
+ * @param {object} options
13
+ * @param {number|null} options.cooldownMs - Milliseconds to wait (null = use default)
14
+ * @param {string|null} options.cooldownUntil - ISO timestamp when cooldown expires
15
+ * @param {string} options.agent - Agent that was rate-limited
16
+ * @param {number} options.retryCount - Current retry attempt (for backoff)
17
+ * @param {object} options.emitter - Event emitter
18
+ * @param {object} options.eventBase - Base event fields
19
+ * @param {object} options.logger
20
+ * @param {object} options.session
21
+ */
22
+ export async function waitForCooldown({ cooldownMs, cooldownUntil, agent, retryCount, emitter, eventBase, logger, session }) {
23
+ // Calculate wait time with exponential backoff for retries without known cooldown
24
+ let waitMs = cooldownMs || DEFAULT_COOLDOWN_MS;
25
+ if (!cooldownMs && retryCount > 0) {
26
+ waitMs = Math.min(DEFAULT_COOLDOWN_MS * Math.pow(2, retryCount), MAX_COOLDOWN_MS);
27
+ }
28
+
29
+ const resumeAt = cooldownUntil || new Date(Date.now() + waitMs).toISOString();
30
+
31
+ logger.info(`Standby: waiting ${Math.round(waitMs / 1000)}s for ${agent} rate limit (retry ${retryCount + 1}/${MAX_STANDBY_RETRIES})`);
32
+
33
+ // Emit standby start event
34
+ emitProgress(emitter, makeEvent("coder:standby", { ...eventBase, stage: "standby" }, {
35
+ message: `Rate limited — standby until ${resumeAt} (attempt ${retryCount + 1}/${MAX_STANDBY_RETRIES})`,
36
+ detail: { agent, cooldownUntil: resumeAt, cooldownMs: waitMs, retryCount: retryCount + 1, maxRetries: MAX_STANDBY_RETRIES }
37
+ }));
38
+
39
+ // Update session status
40
+ session.status = "standby";
41
+
42
+ // Wait with periodic heartbeats
43
+ const startTime = Date.now();
44
+ const endTime = startTime + waitMs;
45
+
46
+ while (Date.now() < endTime) {
47
+ const remaining = endTime - Date.now();
48
+ const sleepTime = Math.min(HEARTBEAT_INTERVAL_MS, remaining);
49
+
50
+ await new Promise(resolve => setTimeout(resolve, sleepTime));
51
+
52
+ if (Date.now() < endTime) {
53
+ const remainingSec = Math.round((endTime - Date.now()) / 1000);
54
+ emitProgress(emitter, makeEvent("coder:standby_heartbeat", { ...eventBase, stage: "standby" }, {
55
+ message: `Standby: ${remainingSec}s remaining`,
56
+ detail: { agent, remainingMs: endTime - Date.now(), retryCount: retryCount + 1 }
57
+ }));
58
+ }
59
+ }
60
+
61
+ // Emit resume event
62
+ emitProgress(emitter, makeEvent("coder:standby_resume", { ...eventBase, stage: "standby" }, {
63
+ message: `Cooldown expired — resuming with ${agent}`,
64
+ detail: { agent, retryCount: retryCount + 1 }
65
+ }));
66
+
67
+ session.status = "running";
68
+ }
69
+
70
+ export { DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS, MAX_STANDBY_RETRIES, HEARTBEAT_INTERVAL_MS };
@@ -3,6 +3,7 @@ import {
3
3
  createSession,
4
4
  loadSession,
5
5
  markSessionStatus,
6
+ pauseSession,
6
7
  resumeSessionWithAnswer,
7
8
  saveSession,
8
9
  addCheckpoint
@@ -25,6 +26,7 @@ import { invokeSolomon } from "./orchestrator/solomon-escalation.js";
25
26
  import { runTriageStage, runResearcherStage, runPlannerStage } from "./orchestrator/pre-loop-stages.js";
26
27
  import { runCoderStage, runRefactorerStage, runTddCheckStage, runSonarStage, runReviewerStage } from "./orchestrator/iteration-stages.js";
27
28
  import { runTesterStage, runSecurityStage } from "./orchestrator/post-loop-stages.js";
29
+ import { waitForCooldown, MAX_STANDBY_RETRIES } from "./orchestrator/standby.js";
28
30
 
29
31
 
30
32
 
@@ -146,6 +148,7 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
146
148
  repeated_issue_count: 0,
147
149
  sonar_retry_count: 0,
148
150
  reviewer_retry_count: 0,
151
+ standby_retry_count: 0,
149
152
  last_sonar_issue_signature: null,
150
153
  sonar_repeat_count: 0,
151
154
  last_reviewer_issue_signature: null,
@@ -406,6 +409,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
406
409
  if (coderResult?.action === "pause") {
407
410
  return coderResult.result;
408
411
  }
412
+ if (coderResult?.action === "standby") {
413
+ const standbyRetries = session.standby_retry_count || 0;
414
+ if (standbyRetries >= MAX_STANDBY_RETRIES) {
415
+ await pauseSession(session, {
416
+ question: `Rate limit standby exhausted after ${standbyRetries} retries. Agent: ${coderResult.standbyInfo.agent}`,
417
+ context: { iteration: i, stage: "coder", reason: "standby_exhausted" }
418
+ });
419
+ emitProgress(emitter, makeEvent("coder:rate_limit", { ...eventBase, stage: "coder" }, {
420
+ status: "paused",
421
+ message: `Standby exhausted after ${standbyRetries} retries`,
422
+ detail: { agent: coderResult.standbyInfo.agent, sessionId: session.id }
423
+ }));
424
+ return { paused: true, sessionId: session.id, question: `Rate limit standby exhausted after ${standbyRetries} retries`, context: "standby_exhausted" };
425
+ }
426
+ session.standby_retry_count = standbyRetries + 1;
427
+ await saveSession(session);
428
+ await waitForCooldown({ ...coderResult.standbyInfo, retryCount: standbyRetries, emitter, eventBase, logger, session });
429
+ i -= 1; // Retry the same iteration
430
+ continue;
431
+ }
409
432
 
410
433
  // --- Refactorer ---
411
434
  if (refactorerEnabled) {
@@ -413,6 +436,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
413
436
  if (refResult?.action === "pause") {
414
437
  return refResult.result;
415
438
  }
439
+ if (refResult?.action === "standby") {
440
+ const standbyRetries = session.standby_retry_count || 0;
441
+ if (standbyRetries >= MAX_STANDBY_RETRIES) {
442
+ await pauseSession(session, {
443
+ question: `Rate limit standby exhausted after ${standbyRetries} retries. Agent: ${refResult.standbyInfo.agent}`,
444
+ context: { iteration: i, stage: "refactorer", reason: "standby_exhausted" }
445
+ });
446
+ emitProgress(emitter, makeEvent("refactorer:rate_limit", { ...eventBase, stage: "refactorer" }, {
447
+ status: "paused",
448
+ message: `Standby exhausted after ${standbyRetries} retries`,
449
+ detail: { agent: refResult.standbyInfo.agent, sessionId: session.id }
450
+ }));
451
+ return { paused: true, sessionId: session.id, question: `Rate limit standby exhausted after ${standbyRetries} retries`, context: "standby_exhausted" };
452
+ }
453
+ session.standby_retry_count = standbyRetries + 1;
454
+ await saveSession(session);
455
+ await waitForCooldown({ ...refResult.standbyInfo, retryCount: standbyRetries, emitter, eventBase, logger, session });
456
+ i -= 1; // Retry the same iteration
457
+ continue;
458
+ }
416
459
  }
417
460
 
418
461
  // --- TDD Policy ---
@@ -458,6 +501,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
458
501
  if (reviewerResult.action === "pause") {
459
502
  return reviewerResult.result;
460
503
  }
504
+ if (reviewerResult.action === "standby") {
505
+ const standbyRetries = session.standby_retry_count || 0;
506
+ if (standbyRetries >= MAX_STANDBY_RETRIES) {
507
+ await pauseSession(session, {
508
+ question: `Rate limit standby exhausted after ${standbyRetries} retries. Agent: ${reviewerResult.standbyInfo.agent}`,
509
+ context: { iteration: i, stage: "reviewer", reason: "standby_exhausted" }
510
+ });
511
+ emitProgress(emitter, makeEvent("reviewer:rate_limit", { ...eventBase, stage: "reviewer" }, {
512
+ status: "paused",
513
+ message: `Standby exhausted after ${standbyRetries} retries`,
514
+ detail: { agent: reviewerResult.standbyInfo.agent, sessionId: session.id }
515
+ }));
516
+ return { paused: true, sessionId: session.id, question: `Rate limit standby exhausted after ${standbyRetries} retries`, context: "standby_exhausted" };
517
+ }
518
+ session.standby_retry_count = standbyRetries + 1;
519
+ await saveSession(session);
520
+ await waitForCooldown({ ...reviewerResult.standbyInfo, retryCount: standbyRetries, emitter, eventBase, logger, session });
521
+ i -= 1; // Retry the same iteration
522
+ continue;
523
+ }
461
524
  review = reviewerResult.review;
462
525
  if (reviewerResult.stalled) {
463
526
  return reviewerResult.stalledResult;
@@ -474,6 +537,49 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
474
537
  })
475
538
  );
476
539
 
540
+ // Reset standby counter after successful iteration
541
+ session.standby_retry_count = 0;
542
+
543
+ // --- Solomon supervisor: anomaly detection after each iteration ---
544
+ if (config.pipeline?.solomon?.enabled !== false) {
545
+ try {
546
+ const { evaluateRules, buildRulesContext } = await import("./orchestrator/solomon-rules.js");
547
+ const rulesContext = await buildRulesContext({ session, task, iteration: i });
548
+ const rulesResult = evaluateRules(rulesContext, config.solomon?.rules);
549
+
550
+ if (rulesResult.alerts.length > 0) {
551
+ for (const alert of rulesResult.alerts) {
552
+ emitProgress(emitter, makeEvent("solomon:alert", { ...eventBase, stage: "solomon" }, {
553
+ status: alert.severity === "critical" ? "fail" : "warn",
554
+ message: alert.message,
555
+ detail: alert.detail
556
+ }));
557
+ logger.warn(`Solomon alert [${alert.rule}]: ${alert.message}`);
558
+ }
559
+
560
+ if (rulesResult.hasCritical && askQuestion) {
561
+ const alertSummary = rulesResult.alerts
562
+ .filter(a => a.severity === "critical")
563
+ .map(a => a.message)
564
+ .join("\n");
565
+ const answer = await askQuestion(
566
+ `Solomon detected critical issues:\n${alertSummary}\n\nShould I continue, pause, or revert?`,
567
+ { iteration: i, stage: "solomon" }
568
+ );
569
+ if (!answer || answer.toLowerCase().includes("pause") || answer.toLowerCase().includes("stop")) {
570
+ await pauseSession(session, {
571
+ question: `Solomon supervisor paused: ${alertSummary}`,
572
+ context: { iteration: i, stage: "solomon", alerts: rulesResult.alerts }
573
+ });
574
+ return { paused: true, sessionId: session.id, reason: "solomon_alert" };
575
+ }
576
+ }
577
+ }
578
+ } catch (err) {
579
+ logger.warn(`Solomon rules evaluation failed: ${err.message}`);
580
+ }
581
+ }
582
+
477
583
  if (review.approved) {
478
584
  session.reviewer_retry_count = 0;
479
585
 
@@ -650,6 +756,7 @@ export async function resumeFlow({ sessionId, answer, config, logger, flags = {}
650
756
  session.repeated_issue_count = 0;
651
757
  session.sonar_retry_count = 0;
652
758
  session.reviewer_retry_count = 0;
759
+ session.standby_retry_count = 0;
653
760
  session.tester_retry_count = 0;
654
761
  session.security_retry_count = 0;
655
762
  session.last_sonar_issue_signature = null;
@@ -0,0 +1,61 @@
1
+ const SUBAGENT_PREAMBLE = [
2
+ "IMPORTANT: You are running as a Karajan sub-agent.",
3
+ "Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
4
+ "Do NOT use any MCP tools. Focus only on task complexity triage."
5
+ ].join(" ");
6
+
7
+ const ROLE_DESCRIPTIONS = [
8
+ { role: "planner", description: "Generates an implementation plan before coding. Useful for complex multi-file tasks." },
9
+ { role: "researcher", description: "Investigates the codebase for context before coding. Useful when understanding existing code is needed." },
10
+ { role: "tester", description: "Runs dedicated testing pass after coding. Ensures tests exist and pass." },
11
+ { role: "security", description: "Audits code for security vulnerabilities. Checks auth, input validation, injection risks." },
12
+ { role: "refactorer", description: "Cleans up and refactors code after the main implementation." },
13
+ { role: "reviewer", description: "Reviews the code diff for quality issues. Standard quality gate." }
14
+ ];
15
+
16
+ export function buildTriagePrompt({ task, instructions, availableRoles }) {
17
+ const sections = [SUBAGENT_PREAMBLE];
18
+
19
+ if (instructions) {
20
+ sections.push(instructions);
21
+ }
22
+
23
+ const roles = availableRoles || ROLE_DESCRIPTIONS;
24
+
25
+ sections.push(
26
+ "You are a task triage agent for Karajan Code, a multi-agent coding orchestrator.",
27
+ "Analyze the following task and determine which pipeline roles should be activated."
28
+ );
29
+
30
+ sections.push(
31
+ "## Available Roles",
32
+ roles.map((r) => `- **${r.role}**: ${r.description}`).join("\n")
33
+ );
34
+
35
+ sections.push(
36
+ "## Decision Guidelines",
37
+ [
38
+ "- **planner**: Enable for complex tasks (multi-file, architectural changes, data model changes). Disable for simple fixes.",
39
+ "- **researcher**: Enable when the task needs codebase context, API understanding, or investigation. Disable for standalone new files.",
40
+ "- **tester**: Enable for any task with logic, APIs, components, services. Disable ONLY for pure documentation, comments, or CSS-only changes.",
41
+ "- **security**: Enable for authentication, APIs, user input handling, data access, external integrations. Disable for UI-only or doc changes.",
42
+ "- **refactorer**: Enable only when explicitly requested or when the task is a refactoring task.",
43
+ "- **reviewer**: Enable for most tasks as a quality gate. Disable only for trivial, single-line changes.",
44
+ "",
45
+ "Note: coder is ALWAYS active — you don't need to decide on it."
46
+ ].join("\n")
47
+ );
48
+
49
+ sections.push(
50
+ "Classify the task complexity, recommend only the necessary pipeline roles, and assess whether the task should be decomposed into smaller subtasks.",
51
+ "Keep the reasoning short and practical.",
52
+ "Return a single valid JSON object and nothing else.",
53
+ 'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security"],"reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}'
54
+ );
55
+
56
+ sections.push(`## Task\n${task}`);
57
+
58
+ return sections.join("\n\n");
59
+ }
60
+
61
+ export { ROLE_DESCRIPTIONS };
@@ -1,11 +1,6 @@
1
1
  import { BaseRole } from "./base-role.js";
2
2
  import { createAgent as defaultCreateAgent } from "../agents/index.js";
3
-
4
- const SUBAGENT_PREAMBLE = [
5
- "IMPORTANT: You are running as a Karajan sub-agent.",
6
- "Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
7
- "Do NOT use any MCP tools. Focus only on task complexity triage."
8
- ].join(" ");
3
+ import { buildTriagePrompt } from "../prompts/triage.js";
9
4
 
10
5
  const VALID_LEVELS = new Set(["trivial", "simple", "medium", "complex"]);
11
6
  const VALID_ROLES = new Set(["planner", "researcher", "refactorer", "reviewer", "tester", "security"]);
@@ -18,25 +13,6 @@ function resolveProvider(config) {
18
13
  );
19
14
  }
20
15
 
21
- function buildPrompt({ task, instructions }) {
22
- const sections = [SUBAGENT_PREAMBLE];
23
-
24
- if (instructions) {
25
- sections.push(instructions);
26
- }
27
-
28
- sections.push(
29
- "Classify the task complexity, recommend only the necessary pipeline roles, and assess whether the task should be decomposed into smaller subtasks.",
30
- "Keep the reasoning short and practical.",
31
- "Return a single valid JSON object and nothing else.",
32
- 'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security"],"reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}'
33
- );
34
-
35
- sections.push(`## Task\n${task}`);
36
-
37
- return sections.join("\n\n");
38
- }
39
-
40
16
  function parseTriageOutput(raw) {
41
17
  const text = raw?.trim() || "";
42
18
  const jsonMatch = text.match(/\{[\s\S]*\}/);
@@ -72,7 +48,7 @@ export class TriageRole extends BaseRole {
72
48
  const provider = resolveProvider(this.config);
73
49
  const agent = this._createAgent(provider, this.config, this.logger);
74
50
 
75
- const prompt = buildPrompt({ task, instructions: this.instructions });
51
+ const prompt = buildTriagePrompt({ task, instructions: this.instructions });
76
52
  const runArgs = { prompt, role: "triage" };
77
53
  if (onOutput) runArgs.onOutput = onOutput;
78
54
  const result = await agent.runTask(runArgs);
@@ -41,6 +41,9 @@ const ICONS = {
41
41
  "solomon:start": "\u2696\ufe0f",
42
42
  "solomon:end": "\u2696\ufe0f",
43
43
  "solomon:escalate": "\u26a0\ufe0f",
44
+ "coder:standby": "\u23f3",
45
+ "coder:standby_heartbeat": "\u23f3",
46
+ "coder:standby_resume": "\u25b6\ufe0f",
44
47
  "budget:update": "\ud83d\udcb8",
45
48
  "iteration:end": "\u23f1\ufe0f",
46
49
  "session:end": "\ud83c\udfc1",
@@ -245,6 +248,24 @@ export function printEvent(event) {
245
248
  break;
246
249
  }
247
250
 
251
+ case "coder:standby": {
252
+ const until = event.detail?.cooldownUntil || "?";
253
+ const attempt = event.detail?.retryCount || "?";
254
+ const maxRetries = event.detail?.maxRetries || "?";
255
+ console.log(` \u251c\u2500 ${ANSI.yellow}${icon} Rate limited \u2014 standby until ${until} (attempt ${attempt}/${maxRetries})${ANSI.reset}`);
256
+ break;
257
+ }
258
+
259
+ case "coder:standby_heartbeat": {
260
+ const remaining = event.detail?.remainingMs !== undefined ? Math.round(event.detail.remainingMs / 1000) : "?";
261
+ console.log(` \u251c\u2500 ${ANSI.yellow}${icon} Standby: ${remaining}s remaining${ANSI.reset}`);
262
+ break;
263
+ }
264
+
265
+ case "coder:standby_resume":
266
+ console.log(` \u251c\u2500 ${ANSI.green}${icon} Cooldown expired \u2014 resuming with ${event.detail?.coder || event.detail?.provider || "?"}${ANSI.reset}`);
267
+ break;
268
+
248
269
  case "iteration:end":
249
270
  console.log(` \u2514\u2500 ${icon} Duration: ${formatElapsed(event.detail?.duration)} ${elapsed}`);
250
271
  break;
@@ -1,9 +1,69 @@
1
1
  /**
2
2
  * Detects rate limit / usage cap messages from CLI agent output.
3
- * Returns { isRateLimit, agent, message } where agent is the best guess
4
- * of which CLI triggered it (or "unknown").
3
+ * Returns { isRateLimit, agent, message, cooldownUntil, cooldownMs }
4
+ * where agent is the best guess of which CLI triggered it (or "unknown").
5
5
  */
6
6
 
7
+ /**
8
+ * Extracts cooldown timing from a rate limit message string.
9
+ * Returns { cooldownUntil, cooldownMs } where cooldownUntil is an ISO string
10
+ * and cooldownMs is milliseconds to wait, or both null if not found.
11
+ */
12
+ export function parseCooldown(message) {
13
+ if (!message || typeof message !== "string") {
14
+ return { cooldownUntil: null, cooldownMs: null };
15
+ }
16
+
17
+ // 1. ISO timestamp: "try again after 2026-03-07T15:30:00Z"
18
+ // Also: "resets at 2026-03-07T15:30:00Z"
19
+ const isoMatch = message.match(
20
+ /(?:after|at)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)/i
21
+ );
22
+ if (isoMatch) {
23
+ const target = new Date(isoMatch[1]);
24
+ if (!isNaN(target.getTime())) {
25
+ const ms = Math.max(0, target.getTime() - Date.now());
26
+ return { cooldownUntil: target.toISOString(), cooldownMs: ms };
27
+ }
28
+ }
29
+
30
+ // 4. Claude specific: "resets at 2026-03-07 15:30 UTC" (space-separated date/time)
31
+ const resetMatch = message.match(
32
+ /resets?\s+at\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s*UTC/i
33
+ );
34
+ if (resetMatch) {
35
+ const target = new Date(`${resetMatch[1]}T${resetMatch[2]}:00Z`);
36
+ if (!isNaN(target.getTime())) {
37
+ const ms = Math.max(0, target.getTime() - Date.now());
38
+ return { cooldownUntil: target.toISOString(), cooldownMs: ms };
39
+ }
40
+ }
41
+
42
+ // 2. Relative seconds: "retry after 120 seconds" / "retry in 120s" / "Retry-After: 120"
43
+ const secMatch = message.match(
44
+ /(?:retry[\s-]*after|retry\s+in|wait)\s*:?\s*(\d+)\s*(?:seconds?|secs?|s\b)/i
45
+ ) || message.match(/Retry-After:\s*(\d+)/i);
46
+ if (secMatch) {
47
+ const seconds = parseInt(secMatch[1], 10);
48
+ const ms = seconds * 1000;
49
+ const target = new Date(Date.now() + ms);
50
+ return { cooldownUntil: target.toISOString(), cooldownMs: ms };
51
+ }
52
+
53
+ // 3. Relative minutes: "retry in 5 minutes" / "wait 5 min"
54
+ const minMatch = message.match(
55
+ /(?:retry\s+in|wait|after)\s+(\d+)\s*(?:minutes?|mins?)/i
56
+ );
57
+ if (minMatch) {
58
+ const minutes = parseInt(minMatch[1], 10);
59
+ const ms = minutes * 60 * 1000;
60
+ const target = new Date(Date.now() + ms);
61
+ return { cooldownUntil: target.toISOString(), cooldownMs: ms };
62
+ }
63
+
64
+ return { cooldownUntil: null, cooldownMs: null };
65
+ }
66
+
7
67
  const RATE_LIMIT_PATTERNS = [
8
68
  // Claude CLI
9
69
  { pattern: /usage limit/i, agent: "claude" },
@@ -34,10 +94,11 @@ export function detectRateLimit({ stderr = "", stdout = "" }) {
34
94
  return {
35
95
  isRateLimit: true,
36
96
  agent,
37
- message: matchedLine.trim()
97
+ message: matchedLine.trim(),
98
+ ...parseCooldown(matchedLine)
38
99
  };
39
100
  }
40
101
  }
41
102
 
42
- return { isRateLimit: false, agent: "", message: "" };
103
+ return { isRateLimit: false, agent: "", message: "", cooldownUntil: null, cooldownMs: null };
43
104
  }