opencode-ultra 0.8.3 → 0.9.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.
package/dist/config.d.ts CHANGED
@@ -76,6 +76,7 @@ declare const PluginConfigSchema: z.ZodObject<{
76
76
  evolve_exe: z.ZodOptional<z.ZodObject<{
77
77
  maxIterations: z.ZodOptional<z.ZodNumber>;
78
78
  iterationTimeoutMs: z.ZodOptional<z.ZodNumber>;
79
+ totalTimeoutMs: z.ZodOptional<z.ZodNumber>;
79
80
  skipReview: z.ZodOptional<z.ZodBoolean>;
80
81
  skipTests: z.ZodOptional<z.ZodBoolean>;
81
82
  }, z.core.$strip>>;
package/dist/index.js CHANGED
@@ -14415,6 +14415,228 @@ class TtlMap {
14415
14415
  this.map.clear();
14416
14416
  }
14417
14417
  }
14418
+ // src/shared/concurrency.ts
14419
+ function computeExponentialBackoffDelayMs(attempt, opts = {}) {
14420
+ const base = opts.baseDelayMs ?? 200;
14421
+ const max = opts.maxDelayMs ?? 1e4;
14422
+ const jitter = opts.jitter ?? "full";
14423
+ const rng = opts.rng ?? Math.random;
14424
+ const a = Math.max(0, Math.floor(attempt));
14425
+ const cap = Math.min(max, base * 2 ** a);
14426
+ if (jitter === "none")
14427
+ return Math.floor(cap);
14428
+ const r0 = rng();
14429
+ const r = Number.isFinite(r0) ? Math.max(0, Math.min(0.999999, r0)) : 0;
14430
+ return Math.floor(r * cap);
14431
+ }
14432
+ class CircuitBreaker {
14433
+ state = "closed";
14434
+ consecutiveFailures = 0;
14435
+ openUntil = 0;
14436
+ halfOpenInFlight = false;
14437
+ failureThreshold;
14438
+ cooldownMs;
14439
+ constructor(opts = {}) {
14440
+ this.failureThreshold = Math.max(1, Math.floor(opts.failureThreshold ?? 3));
14441
+ this.cooldownMs = Math.max(0, Math.floor(opts.cooldownMs ?? 1e4));
14442
+ }
14443
+ snapshot(now) {
14444
+ const openForMs = this.state === "open" ? Math.max(0, this.openUntil - now) : 0;
14445
+ return { state: this.state, consecutiveFailures: this.consecutiveFailures, openForMs };
14446
+ }
14447
+ enter(now) {
14448
+ if (this.state === "open") {
14449
+ if (now < this.openUntil) {
14450
+ return { allowed: false, waitMs: this.openUntil - now };
14451
+ }
14452
+ this.state = "half_open";
14453
+ this.halfOpenInFlight = false;
14454
+ }
14455
+ if (this.state === "half_open") {
14456
+ if (this.halfOpenInFlight)
14457
+ return { allowed: false, waitMs: this.cooldownMs };
14458
+ this.halfOpenInFlight = true;
14459
+ return { allowed: true };
14460
+ }
14461
+ return { allowed: true };
14462
+ }
14463
+ onSuccess() {
14464
+ this.consecutiveFailures = 0;
14465
+ this.openUntil = 0;
14466
+ this.halfOpenInFlight = false;
14467
+ this.state = "closed";
14468
+ }
14469
+ onFailure(now) {
14470
+ this.halfOpenInFlight = false;
14471
+ if (this.state === "half_open") {
14472
+ this.state = "open";
14473
+ this.openUntil = now + this.cooldownMs;
14474
+ this.consecutiveFailures = this.failureThreshold;
14475
+ return;
14476
+ }
14477
+ this.consecutiveFailures++;
14478
+ if (this.consecutiveFailures >= this.failureThreshold) {
14479
+ this.state = "open";
14480
+ this.openUntil = now + this.cooldownMs;
14481
+ }
14482
+ }
14483
+ }
14484
+
14485
+ class RetryBudgetExceededError extends Error {
14486
+ provider;
14487
+ constructor(provider) {
14488
+ super(`Retry budget exceeded for ${provider}`);
14489
+ this.name = "RetryBudgetExceededError";
14490
+ this.provider = provider;
14491
+ }
14492
+ }
14493
+
14494
+ class RetryBudget {
14495
+ maxRetries;
14496
+ intervalMs;
14497
+ state;
14498
+ constructor(opts = {}) {
14499
+ this.maxRetries = Math.max(0, Math.floor(opts.maxRetries ?? 20));
14500
+ this.intervalMs = Math.max(1, Math.floor(opts.intervalMs ?? 60000));
14501
+ this.state = new TtlMap({ maxSize: 1000, ttlMs: this.intervalMs });
14502
+ }
14503
+ trySpend(provider, cost = 1) {
14504
+ const c = Math.max(0, Math.floor(cost));
14505
+ if (c === 0)
14506
+ return true;
14507
+ const entry = this.state.get(provider) ?? { used: 0 };
14508
+ if (entry.used + c > this.maxRetries)
14509
+ return false;
14510
+ this.state.set(provider, { used: entry.used + c });
14511
+ return true;
14512
+ }
14513
+ }
14514
+ function getHeaderValue(headers, name) {
14515
+ if (!headers || typeof headers !== "object")
14516
+ return;
14517
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
14518
+ if (!key)
14519
+ return;
14520
+ const v = headers[key];
14521
+ return typeof v === "string" ? v : Array.isArray(v) && typeof v[0] === "string" ? v[0] : undefined;
14522
+ }
14523
+ function parseRetryAfterMs(value, now) {
14524
+ const trimmed = value.trim();
14525
+ if (!trimmed)
14526
+ return;
14527
+ const asNum = Number(trimmed);
14528
+ if (Number.isFinite(asNum) && asNum >= 0)
14529
+ return Math.floor(asNum * 1000);
14530
+ const asDate = Date.parse(trimmed);
14531
+ if (!Number.isNaN(asDate)) {
14532
+ const ms = asDate - now;
14533
+ return ms > 0 ? ms : 0;
14534
+ }
14535
+ return;
14536
+ }
14537
+ function classifyRetryableError(err, now = Date.now()) {
14538
+ const anyErr = err;
14539
+ const status = typeof anyErr?.status === "number" ? anyErr.status : typeof anyErr?.statusCode === "number" ? anyErr.statusCode : typeof anyErr?.response?.status === "number" ? anyErr.response.status : typeof anyErr?.response?.statusCode === "number" ? anyErr.response.statusCode : undefined;
14540
+ const headers = anyErr?.headers ?? anyErr?.response?.headers;
14541
+ const retryAfterRaw = getHeaderValue(headers, "retry-after");
14542
+ const retryAfterMs = retryAfterRaw ? parseRetryAfterMs(retryAfterRaw, now) : undefined;
14543
+ if (status === 429)
14544
+ return { retryable: true, status, retryAfterMs };
14545
+ if (typeof status === "number" && status >= 500 && status <= 599)
14546
+ return { retryable: true, status, retryAfterMs };
14547
+ const code = typeof anyErr?.code === "string" ? anyErr.code : undefined;
14548
+ if (code && (code === "ECONNRESET" || code === "ETIMEDOUT" || code === "EAI_AGAIN" || code === "ENOTFOUND")) {
14549
+ return { retryable: true };
14550
+ }
14551
+ return { retryable: false, status, retryAfterMs };
14552
+ }
14553
+ async function adaptiveRetry(provider, fn, breaker, budget, opts = {}) {
14554
+ const maxAttempts = Math.max(1, Math.floor(opts.maxAttempts ?? 4));
14555
+ const nowFn = opts.now ?? Date.now;
14556
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
14557
+ const classify = opts.classify ?? classifyRetryableError;
14558
+ const maxRetryAfterMs = Math.max(0, Math.floor(opts.maxRetryAfterMs ?? 30000));
14559
+ const backoff = opts.backoff ?? {};
14560
+ let attempt = 0;
14561
+ while (true) {
14562
+ const now = nowFn();
14563
+ const gate = breaker.enter(now);
14564
+ if (!gate.allowed) {
14565
+ const waitMs = Math.max(0, Math.floor(gate.waitMs ?? 0));
14566
+ if (waitMs > 0) {
14567
+ const delay = Math.min(waitMs, maxRetryAfterMs);
14568
+ await sleep(delay);
14569
+ }
14570
+ }
14571
+ try {
14572
+ const result = await fn();
14573
+ breaker.onSuccess();
14574
+ return result;
14575
+ } catch (err) {
14576
+ const now2 = nowFn();
14577
+ const decision = classify(err, now2);
14578
+ if (!decision.retryable) {
14579
+ throw err;
14580
+ }
14581
+ breaker.onFailure(now2);
14582
+ attempt++;
14583
+ if (attempt >= maxAttempts)
14584
+ throw err;
14585
+ if (!budget.trySpend(provider, 1)) {
14586
+ throw new RetryBudgetExceededError(provider);
14587
+ }
14588
+ const retryAfterMs = typeof decision.retryAfterMs === "number" ? Math.min(Math.max(0, decision.retryAfterMs), maxRetryAfterMs) : undefined;
14589
+ const delay = retryAfterMs !== undefined ? retryAfterMs : computeExponentialBackoffDelayMs(attempt - 1, {
14590
+ baseDelayMs: backoff.baseDelayMs,
14591
+ maxDelayMs: backoff.maxDelayMs,
14592
+ jitter: backoff.jitter,
14593
+ rng: backoff.rng
14594
+ });
14595
+ if (delay > 0)
14596
+ await sleep(delay);
14597
+ }
14598
+ }
14599
+ }
14600
+
14601
+ class ProviderResilience {
14602
+ breakers = new Map;
14603
+ budgets = new Map;
14604
+ opts;
14605
+ constructor(opts = {}) {
14606
+ this.opts = opts;
14607
+ }
14608
+ getBreaker(provider) {
14609
+ const existing = this.breakers.get(provider);
14610
+ if (existing)
14611
+ return existing;
14612
+ const b = new CircuitBreaker(this.opts.breaker);
14613
+ this.breakers.set(provider, b);
14614
+ return b;
14615
+ }
14616
+ getBudget(provider) {
14617
+ const existing = this.budgets.get(provider);
14618
+ if (existing)
14619
+ return existing;
14620
+ const b = new RetryBudget(this.opts.retryBudget);
14621
+ this.budgets.set(provider, b);
14622
+ return b;
14623
+ }
14624
+ async run(provider, fn, retryOverrides = {}) {
14625
+ const breaker = this.getBreaker(provider);
14626
+ const budget = this.getBudget(provider);
14627
+ const baseRetry = this.opts.retry ?? {};
14628
+ const merged = {
14629
+ ...baseRetry,
14630
+ ...retryOverrides,
14631
+ backoff: { ...baseRetry.backoff, ...retryOverrides.backoff }
14632
+ };
14633
+ return adaptiveRetry(provider, fn, breaker, budget, merged);
14634
+ }
14635
+ snapshot(provider, now = Date.now()) {
14636
+ const breaker = this.getBreaker(provider);
14637
+ return { breaker: breaker.snapshot(now) };
14638
+ }
14639
+ }
14418
14640
  // src/config.ts
14419
14641
  var AgentOverrideSchema = exports_external.object({
14420
14642
  model: exports_external.string().optional(),
@@ -14476,6 +14698,7 @@ var PluginConfigSchema = exports_external.object({
14476
14698
  evolve_exe: exports_external.object({
14477
14699
  maxIterations: exports_external.number().min(1).max(20).optional(),
14478
14700
  iterationTimeoutMs: exports_external.number().min(1e4).optional(),
14701
+ totalTimeoutMs: exports_external.number().min(60000).optional(),
14479
14702
  skipReview: exports_external.boolean().optional(),
14480
14703
  skipTests: exports_external.boolean().optional()
14481
14704
  }).optional()
@@ -27549,6 +27772,86 @@ function tool(input) {
27549
27772
  return input;
27550
27773
  }
27551
27774
  tool.schema = exports_external2;
27775
+ // src/concurrency/semaphore.ts
27776
+ class Semaphore {
27777
+ max;
27778
+ queue = [];
27779
+ active = 0;
27780
+ constructor(max) {
27781
+ this.max = max;
27782
+ }
27783
+ async acquire() {
27784
+ if (this.active < this.max) {
27785
+ this.active++;
27786
+ return;
27787
+ }
27788
+ return new Promise((resolve2) => {
27789
+ this.queue.push(() => {
27790
+ this.active++;
27791
+ resolve2();
27792
+ });
27793
+ });
27794
+ }
27795
+ release() {
27796
+ if (this.active <= 0)
27797
+ return;
27798
+ this.active--;
27799
+ const next = this.queue.shift();
27800
+ if (next)
27801
+ next();
27802
+ }
27803
+ get pending() {
27804
+ return this.queue.length;
27805
+ }
27806
+ get running() {
27807
+ return this.active;
27808
+ }
27809
+ }
27810
+ // src/concurrency/pool.ts
27811
+ function extractProvider(model) {
27812
+ const slash = model.indexOf("/");
27813
+ return slash > 0 ? model.slice(0, slash) : model;
27814
+ }
27815
+
27816
+ class ConcurrencyPool {
27817
+ global;
27818
+ providers = new Map;
27819
+ models = new Map;
27820
+ config;
27821
+ constructor(config3) {
27822
+ this.config = config3;
27823
+ this.global = new Semaphore(config3.defaultConcurrency ?? Infinity);
27824
+ for (const [provider, limit] of Object.entries(config3.providerConcurrency ?? {})) {
27825
+ this.providers.set(provider, new Semaphore(limit));
27826
+ }
27827
+ for (const [model, limit] of Object.entries(config3.modelConcurrency ?? {})) {
27828
+ this.models.set(model, new Semaphore(limit));
27829
+ }
27830
+ }
27831
+ async run(model, fn) {
27832
+ const provider = extractProvider(model);
27833
+ const semaphores = [];
27834
+ const modelSem = this.models.get(model);
27835
+ if (modelSem) {
27836
+ await modelSem.acquire();
27837
+ semaphores.push(modelSem);
27838
+ }
27839
+ const providerSem = this.providers.get(provider);
27840
+ if (providerSem) {
27841
+ await providerSem.acquire();
27842
+ semaphores.push(providerSem);
27843
+ }
27844
+ await this.global.acquire();
27845
+ semaphores.push(this.global);
27846
+ try {
27847
+ return await fn();
27848
+ } finally {
27849
+ for (let i = semaphores.length - 1;i >= 0; i--) {
27850
+ semaphores[i].release();
27851
+ }
27852
+ }
27853
+ }
27854
+ }
27552
27855
  // src/categories/index.ts
27553
27856
  var DEFAULT_CATEGORIES = {
27554
27857
  "visual-engineering": {
@@ -27900,7 +28203,7 @@ async function withTimeout(promise3, ms, label) {
27900
28203
  clearTimeout(timer);
27901
28204
  }
27902
28205
  }
27903
- async function runAgent(ctx, task, toolCtx, internalSessions, deps, progress) {
28206
+ async function runAgent(ctx, task, toolCtx, internalSessions, resilience, provider, deps, progress) {
27904
28207
  const { agent, prompt, description } = task;
27905
28208
  const t0 = Date.now();
27906
28209
  const timeoutMs = deps.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
@@ -27916,10 +28219,10 @@ async function runAgent(ctx, task, toolCtx, internalSessions, deps, progress) {
27916
28219
  log(`spawn_agent: starting ${agent}`, { description });
27917
28220
  let sessionID;
27918
28221
  try {
27919
- const sessionResp = await ctx.client.session.create({
28222
+ const sessionResp = await resilience.run(provider, () => ctx.client.session.create({
27920
28223
  body: {},
27921
28224
  query: { directory: ctx.directory }
27922
- });
28225
+ }), { maxAttempts: 3 });
27923
28226
  sessionID = sessionResp.data?.id;
27924
28227
  if (!sessionID) {
27925
28228
  log(`spawn_agent: ${agent} \u2014 failed to create session`);
@@ -27928,25 +28231,26 @@ async function runAgent(ctx, task, toolCtx, internalSessions, deps, progress) {
27928
28231
  **Agent**: ${agent}
27929
28232
  **Error**: Failed to create session`;
27930
28233
  }
27931
- internalSessions.add(sessionID);
27932
- await withTimeout(ctx.client.session.prompt({
27933
- path: { id: sessionID },
28234
+ const id = sessionID;
28235
+ internalSessions.add(id);
28236
+ await resilience.run(provider, () => withTimeout(ctx.client.session.prompt({
28237
+ path: { id },
27934
28238
  body: {
27935
28239
  parts: [{ type: "text", text: prompt }],
27936
28240
  agent
27937
28241
  },
27938
28242
  query: { directory: ctx.directory }
27939
- }), timeoutMs, `${agent} (${description})`);
27940
- const messagesResp = await ctx.client.session.messages({
27941
- path: { id: sessionID },
28243
+ }), timeoutMs, `${agent} (${description})`), { maxAttempts: 4 });
28244
+ const messagesResp = await resilience.run(provider, () => ctx.client.session.messages({
28245
+ path: { id },
27942
28246
  query: { directory: ctx.directory }
27943
- });
28247
+ }), { maxAttempts: 3 });
27944
28248
  const messages = messagesResp.data ?? [];
27945
28249
  const lastAssistant = messages.filter((m) => m.info?.role === "assistant").pop();
27946
28250
  const rawResult = lastAssistant?.parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
27947
28251
  `) ?? "(No response from agent)";
27948
- internalSessions.delete(sessionID);
27949
- await ctx.client.session.delete({ path: { id: sessionID }, query: { directory: ctx.directory } }).catch(() => {});
28252
+ internalSessions.delete(id);
28253
+ await ctx.client.session.delete({ path: { id }, query: { directory: ctx.directory } }).catch(() => {});
27950
28254
  const agentTime = ((Date.now() - t0) / 1000).toFixed(1);
27951
28255
  log(`spawn_agent: ${agent} done`, { seconds: agentTime, description });
27952
28256
  const result = sanitizeSpawnResult(rawResult);
@@ -27979,6 +28283,11 @@ ${result}`;
27979
28283
  }
27980
28284
  function createSpawnAgentTool(ctx, internalSessions, deps = {}) {
27981
28285
  const maxTotalSpawned = deps.maxTotalSpawned ?? DEFAULT_MAX_TOTAL_SPAWNED;
28286
+ const resilience = deps.resilience ?? new ProviderResilience({
28287
+ breaker: { failureThreshold: 3, cooldownMs: 1e4 },
28288
+ retryBudget: { maxRetries: 20, intervalMs: 60000 },
28289
+ retry: { maxAttempts: 4, backoff: { baseDelayMs: 200, maxDelayMs: 1e4, jitter: "full" } }
28290
+ });
27982
28291
  return tool({
27983
28292
  description: `Spawn subagents to execute tasks in PARALLEL.
27984
28293
  All agents in the array run concurrently (respecting concurrency limits if configured).
@@ -28043,7 +28352,8 @@ spawn_agent({
28043
28352
  const task = agents[0];
28044
28353
  toolCtx.metadata({ title: `${task.agent}: ${task.description}...` });
28045
28354
  const model = resolveTaskModel(task, deps);
28046
- const execute = () => runAgent(ctx, task, toolCtx, internalSessions, deps);
28355
+ const provider = model ? extractProvider(model) : "unknown";
28356
+ const execute = () => runAgent(ctx, task, toolCtx, internalSessions, resilience, provider, deps);
28047
28357
  let result;
28048
28358
  if (deps.pool && model) {
28049
28359
  result = await deps.pool.run(model, execute);
@@ -28060,7 +28370,8 @@ spawn_agent({
28060
28370
  });
28061
28371
  const tasks = agents.map(async (task) => {
28062
28372
  const model = resolveTaskModel(task, deps);
28063
- const execute = () => runAgent(ctx, task, toolCtx, internalSessions, deps, progress);
28373
+ const provider = model ? extractProvider(model) : "unknown";
28374
+ const execute = () => runAgent(ctx, task, toolCtx, internalSessions, resilience, provider, deps, progress);
28064
28375
  let result;
28065
28376
  if (deps.pool && model) {
28066
28377
  result = await deps.pool.run(model, execute);
@@ -28106,6 +28417,14 @@ var COMPLETION_MARKER = "<promise>DONE</promise>";
28106
28417
  var DEFAULT_MAX_ITERATIONS = 10;
28107
28418
  var DEFAULT_ITERATION_TIMEOUT_MS = 180000;
28108
28419
  var activeLoops = new Map;
28420
+ function getActiveRalphLoops() {
28421
+ return [...activeLoops.values()].map((s) => ({
28422
+ sessionID: s.sessionID,
28423
+ iteration: s.iteration,
28424
+ maxIterations: s.maxIterations,
28425
+ active: s.active
28426
+ }));
28427
+ }
28109
28428
  async function withTimeout2(promise3, ms, label) {
28110
28429
  let timer;
28111
28430
  const timeout = new Promise((_, reject) => {
@@ -29083,8 +29402,15 @@ import { spawnSync as spawnSync2 } from "child_process";
29083
29402
  import * as fs9 from "fs";
29084
29403
  import * as path9 from "path";
29085
29404
  var COMPLETION_MARKER2 = "<promise>DONE</promise>";
29086
- var DEFAULT_MAX_ITERATIONS2 = 10;
29087
- var DEFAULT_ITERATION_TIMEOUT_MS2 = 300000;
29405
+ var COMPLETION_PATTERNS = [
29406
+ COMPLETION_MARKER2,
29407
+ "<promise>done</promise>",
29408
+ "IMPLEMENTATION COMPLETE",
29409
+ "Implementation complete"
29410
+ ];
29411
+ var DEFAULT_MAX_ITERATIONS2 = 5;
29412
+ var DEFAULT_ITERATION_TIMEOUT_MS2 = 120000;
29413
+ var DEFAULT_TOTAL_TIMEOUT_MS = 600000;
29088
29414
  async function withTimeout3(promise3, ms, label) {
29089
29415
  let timer;
29090
29416
  const timeout = new Promise((_, reject) => {
@@ -29170,36 +29496,34 @@ function buildProjectInfo(cwd) {
29170
29496
  }
29171
29497
  }
29172
29498
  var IMPLEMENT_PROMPT = (proposal, projectInfo) => `
29173
- [EVOLVE IMPLEMENTATION \u2014 AUTONOMOUS MODE]
29499
+ [EVOLVE \u2014 AUTONOMOUS IMPLEMENTATION]
29174
29500
 
29175
- ## Task
29176
- Implement the following improvement proposal for this project:
29501
+ You MUST complete this in a SINGLE iteration. Do not wait for follow-up prompts.
29177
29502
 
29178
- **Title**: ${proposal.title}
29179
- **Priority**: ${proposal.priority} | **Effort**: ${proposal.effort}
29180
- **Description**: ${proposal.description}
29181
- ${proposal.currentState ? `**Current State**: ${proposal.currentState}` : ""}
29182
- ${proposal.files?.length ? `**Target Files**: ${proposal.files.join(", ")}` : ""}
29183
- ${proposal.inspiration ? `**Inspiration**: ${proposal.inspiration}` : ""}
29503
+ ## Proposal
29504
+ - **Title**: ${proposal.title}
29505
+ - **Priority**: ${proposal.priority} | **Effort**: ${proposal.effort}
29506
+ ${proposal.description ? `- **What to do**: ${proposal.description}` : ""}
29507
+ ${proposal.currentState ? `- **Current state**: ${proposal.currentState}` : ""}
29508
+ ${proposal.files?.length ? `- **Target files**: ${proposal.files.join(", ")}` : ""}
29184
29509
 
29185
- ## Project Context
29510
+ ## Project
29186
29511
  ${projectInfo}
29187
29512
 
29188
- ## Requirements
29189
- 1. Implement the feature described above
29190
- 2. Write unit tests in \`__test__/\` directory (same naming pattern as existing tests)
29191
- 3. Ensure all existing tests still pass
29192
- 4. Follow existing code patterns and conventions:
29193
- - Use \`@opencode-ai/plugin\` tool() API for new tools
29194
- - Pure functions where possible, side effects isolated
29195
- - TypeScript strict mode compatible
29196
- - Use zod for schema validation
29197
- - Export types from the module
29198
- 5. Do NOT modify unrelated files
29199
- 6. Do NOT add unnecessary dependencies
29513
+ ## Steps (execute ALL in order)
29514
+ 1. Read the target files to understand current code
29515
+ 2. Write the implementation (new files or edits)
29516
+ 3. Write tests in \`__test__/\` matching existing patterns
29517
+ 4. Verify your changes compile (no syntax errors)
29518
+
29519
+ ## Rules
29520
+ - Do NOT touch unrelated files
29521
+ - Do NOT add dependencies
29522
+ - Follow existing code patterns (TypeScript strict, pure functions, zod schemas)
29523
+ - Keep changes minimal and focused
29200
29524
 
29201
- ## Completion
29202
- When fully implemented and tested, output: ${COMPLETION_MARKER2}
29525
+ ## When done
29526
+ Output exactly: ${COMPLETION_MARKER2}
29203
29527
  `;
29204
29528
  var REVIEW_PROMPT = (proposal, diff, testOutput) => `
29205
29529
  [CODE REVIEW \u2014 EVOLVE IMPLEMENTATION]
@@ -29231,21 +29555,24 @@ Then overall: APPROVE / APPROVE_WITH_WARNINGS / BLOCK
29231
29555
 
29232
29556
  If BLOCK: explain what must be fixed before merging.
29233
29557
  `;
29234
- function buildContinuationPrompt2(original, iteration) {
29235
- return `[Evolve Exe \u2014 Iteration ${iteration}]
29558
+ function buildContinuationPrompt2(_original, iteration, maxIterations) {
29559
+ return `[Evolve Exe \u2014 Iteration ${iteration}/${maxIterations}]
29236
29560
 
29237
- IMPORTANT:
29238
- - Review your progress so far
29239
- - Continue from where you left off
29240
- - When FULLY complete, output exactly: ${COMPLETION_MARKER2}
29241
- - Do not stop until the task is truly done
29561
+ Continue from where you left off. This is iteration ${iteration} of ${maxIterations} \u2014 if you do not finish now, the implementation will be rolled back.
29242
29562
 
29243
- Original task:
29244
- ${original}`;
29563
+ Finish all remaining work and output: ${COMPLETION_MARKER2}`;
29564
+ }
29565
+ function isCompletionDetected(text) {
29566
+ return COMPLETION_PATTERNS.some((p) => text.includes(p));
29567
+ }
29568
+ function getFileChanges(cwd) {
29569
+ const result = spawnSync2("git", ["diff", "--stat"], { cwd, encoding: "utf-8", timeout: 1e4 });
29570
+ return (result.stdout ?? "").trim();
29245
29571
  }
29246
29572
  function createEvolveExeTool(ctx, internalSessions, deps = {}) {
29247
29573
  const maxIterations = deps.evolveExeConfig?.maxIterations ?? DEFAULT_MAX_ITERATIONS2;
29248
29574
  const iterationTimeoutMs = deps.evolveExeConfig?.iterationTimeoutMs ?? deps.agentTimeoutMs ?? DEFAULT_ITERATION_TIMEOUT_MS2;
29575
+ const totalTimeoutMs = deps.evolveExeConfig?.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
29249
29576
  const configSkipReview = deps.evolveExeConfig?.skipReview ?? false;
29250
29577
  const configSkipTests = deps.evolveExeConfig?.skipTests ?? false;
29251
29578
  return tool({
@@ -29337,6 +29664,7 @@ Failed to determine current git branch. Is this a git repository?`;
29337
29664
  skipReview,
29338
29665
  maxIterations,
29339
29666
  iterationTimeoutMs,
29667
+ totalTimeoutMs,
29340
29668
  progressLabel: `[${i + 1}/${selected.length}]`
29341
29669
  });
29342
29670
  results.push(result);
@@ -29381,6 +29709,7 @@ async function executeProposal(args) {
29381
29709
  skipReview,
29382
29710
  maxIterations,
29383
29711
  iterationTimeoutMs,
29712
+ totalTimeoutMs,
29384
29713
  progressLabel
29385
29714
  } = args;
29386
29715
  const slug = slugify2(proposal.title);
@@ -29408,6 +29737,7 @@ async function executeProposal(args) {
29408
29737
  projectInfo,
29409
29738
  maxIterations,
29410
29739
  iterationTimeoutMs,
29740
+ totalTimeoutMs,
29411
29741
  toolCtx,
29412
29742
  progressLabel
29413
29743
  });
@@ -29477,7 +29807,8 @@ async function rollback(cwd, originalBranch, branchName) {
29477
29807
  log(`evolve_exe: rolled back branch ${branchName}`);
29478
29808
  }
29479
29809
  async function runImplementation(args) {
29480
- const { ctx, internalSessions, proposal, projectInfo, maxIterations, iterationTimeoutMs, toolCtx, progressLabel } = args;
29810
+ const { ctx, internalSessions, proposal, projectInfo, maxIterations, iterationTimeoutMs, totalTimeoutMs, toolCtx, progressLabel } = args;
29811
+ const pipelineStart = Date.now();
29481
29812
  const sessionResp = await ctx.client.session.create({
29482
29813
  body: {},
29483
29814
  query: { directory: ctx.directory }
@@ -29487,12 +29818,21 @@ async function runImplementation(args) {
29487
29818
  return { ok: false, error: "Failed to create implementation session" };
29488
29819
  internalSessions.add(sessionID);
29489
29820
  const initialPrompt = IMPLEMENT_PROMPT(proposal, projectInfo);
29821
+ let lastDiffStat = getFileChanges(ctx.directory);
29822
+ let staleCount = 0;
29490
29823
  try {
29491
29824
  for (let i = 0;i < maxIterations; i++) {
29825
+ const elapsed = Date.now() - pipelineStart;
29826
+ if (elapsed > totalTimeoutMs) {
29827
+ log(`evolve_exe: total timeout exceeded (${(elapsed / 1000).toFixed(0)}s)`);
29828
+ return { ok: false, error: `Total timeout (${(totalTimeoutMs / 1000).toFixed(0)}s) exceeded at iteration ${i + 1}` };
29829
+ }
29830
+ const remaining = totalTimeoutMs - elapsed;
29831
+ const effectiveTimeout = Math.min(iterationTimeoutMs, remaining);
29492
29832
  toolCtx.metadata({
29493
- title: `${progressLabel} #${proposal.index}: implementing [${i + 1}/${maxIterations}]`
29833
+ title: `${progressLabel} #${proposal.index}: implementing [${i + 1}/${maxIterations}] (${(elapsed / 1000).toFixed(0)}s)`
29494
29834
  });
29495
- const prompt = i === 0 ? initialPrompt : buildContinuationPrompt2(initialPrompt, i + 1);
29835
+ const prompt = i === 0 ? initialPrompt : buildContinuationPrompt2(initialPrompt, i + 1, maxIterations);
29496
29836
  try {
29497
29837
  await withTimeout3(ctx.client.session.prompt({
29498
29838
  path: { id: sessionID },
@@ -29501,11 +29841,16 @@ async function runImplementation(args) {
29501
29841
  agent: "hephaestus"
29502
29842
  },
29503
29843
  query: { directory: ctx.directory }
29504
- }), iterationTimeoutMs, `evolve_exe implementation iteration ${i + 1}`);
29844
+ }), effectiveTimeout, `evolve_exe implementation iteration ${i + 1}`);
29505
29845
  } catch (iterError) {
29506
29846
  const msg = iterError instanceof Error ? iterError.message : String(iterError);
29507
29847
  if (msg.startsWith("Timeout:")) {
29508
- log(`evolve_exe: implementation iteration ${i + 1} timed out`);
29848
+ const currentDiff2 = getFileChanges(ctx.directory);
29849
+ if (currentDiff2 && currentDiff2 !== lastDiffStat) {
29850
+ log(`evolve_exe: iteration ${i + 1} timed out but has file changes \u2014 treating as complete`);
29851
+ return { ok: true };
29852
+ }
29853
+ log(`evolve_exe: implementation iteration ${i + 1} timed out with no progress`);
29509
29854
  return { ok: false, error: `Implementation timed out at iteration ${i + 1}` };
29510
29855
  }
29511
29856
  throw iterError;
@@ -29518,13 +29863,29 @@ async function runImplementation(args) {
29518
29863
  const lastAssistant = messages.filter((m) => m.info?.role === "assistant").pop();
29519
29864
  const rawResult = lastAssistant?.parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
29520
29865
  `) ?? "";
29521
- if (rawResult.includes(COMPLETION_MARKER2)) {
29866
+ if (isCompletionDetected(rawResult)) {
29522
29867
  log(`evolve_exe: implementation completed at iteration ${i + 1}`);
29523
29868
  return { ok: true };
29524
29869
  }
29525
- log(`evolve_exe: implementation iteration ${i + 1}/${maxIterations} \u2014 no completion marker`);
29870
+ const currentDiff = getFileChanges(ctx.directory);
29871
+ if (currentDiff === lastDiffStat) {
29872
+ staleCount++;
29873
+ log(`evolve_exe: no file changes after iteration ${i + 1} (stale=${staleCount})`);
29874
+ if (staleCount >= 2) {
29875
+ return { ok: false, error: `No progress after ${staleCount} consecutive iterations \u2014 aborting` };
29876
+ }
29877
+ } else {
29878
+ staleCount = 0;
29879
+ lastDiffStat = currentDiff;
29880
+ }
29881
+ log(`evolve_exe: implementation iteration ${i + 1}/${maxIterations} \u2014 continuing`);
29882
+ }
29883
+ const finalDiff = getFileChanges(ctx.directory);
29884
+ if (finalDiff) {
29885
+ log(`evolve_exe: max iterations reached but files changed \u2014 treating as complete`);
29886
+ return { ok: true };
29526
29887
  }
29527
- return { ok: false, error: `Max iterations (${maxIterations}) reached without completion` };
29888
+ return { ok: false, error: `Max iterations (${maxIterations}) reached without completion or file changes` };
29528
29889
  } finally {
29529
29890
  internalSessions.delete(sessionID);
29530
29891
  await ctx.client.session.delete({ path: { id: sessionID }, query: { directory: ctx.directory } }).catch(() => {});
@@ -29677,6 +30038,300 @@ Git working directory is not clean. Commit or stash changes before publishing.
29677
30038
  });
29678
30039
  }
29679
30040
 
30041
+ // src/tools/health-check.ts
30042
+ import { spawnSync as spawnSync4 } from "child_process";
30043
+ import * as fs11 from "fs";
30044
+ import * as path11 from "path";
30045
+
30046
+ // src/mcp/servers.ts
30047
+ var BUILTIN_MCPS = {
30048
+ context7: {
30049
+ command: "npx",
30050
+ args: ["-y", "@upstash/context7-mcp@latest"]
30051
+ }
30052
+ };
30053
+
30054
+ // src/mcp/index.ts
30055
+ async function registerMcps(ctx, disabled, apiKeys) {
30056
+ const disabledSet = new Set(disabled);
30057
+ const keys = apiKeys ?? {};
30058
+ for (const [name, config3] of Object.entries(BUILTIN_MCPS)) {
30059
+ if (disabledSet.has(name)) {
30060
+ log(`MCP ${name} disabled by config`);
30061
+ continue;
30062
+ }
30063
+ try {
30064
+ const args = [...config3.args];
30065
+ const envKey = `${name.toUpperCase().replace(/-/g, "_")}_API_KEY`;
30066
+ const apiKey = keys[name] ?? process.env[envKey];
30067
+ if (apiKey) {
30068
+ args.push("--api-key", apiKey);
30069
+ }
30070
+ const body = {
30071
+ name,
30072
+ command: config3.command,
30073
+ args
30074
+ };
30075
+ if (config3.env) {
30076
+ body.env = config3.env;
30077
+ }
30078
+ const client = ctx.client;
30079
+ await client.mcp?.add?.({ body });
30080
+ log(`MCP ${name} registered${apiKey ? " (with API key)" : ""}`);
30081
+ } catch (err) {
30082
+ log(`MCP ${name} registration failed: ${err}`);
30083
+ }
30084
+ }
30085
+ }
30086
+
30087
+ // src/tools/health-check.ts
30088
+ var BUILTIN_HOOK_IDS = [
30089
+ "keyword-detector",
30090
+ "rules-injector",
30091
+ "context-injector",
30092
+ "fragment-injector",
30093
+ "prompt-renderer",
30094
+ "todo-enforcer",
30095
+ "comment-checker",
30096
+ "token-truncation",
30097
+ "session-compaction"
30098
+ ];
30099
+ var BUILTIN_TOOL_IDS = [
30100
+ "spawn_agent",
30101
+ "ralph_loop",
30102
+ "cancel_ralph",
30103
+ "batch_read",
30104
+ "ledger_save",
30105
+ "ledger_load",
30106
+ "ast_search",
30107
+ "evolve_apply",
30108
+ "evolve_scan",
30109
+ "evolve_score",
30110
+ "evolve_exe",
30111
+ "evolve_publish",
30112
+ "health_check"
30113
+ ];
30114
+ var BinaryStatusSchema = exports_external.object({
30115
+ found: exports_external.boolean(),
30116
+ path: exports_external.string().optional(),
30117
+ version: exports_external.string().optional(),
30118
+ error: exports_external.string().optional()
30119
+ });
30120
+ var HealthCheckResultSchema = exports_external.object({
30121
+ ok: exports_external.boolean(),
30122
+ generatedAt: exports_external.string(),
30123
+ registry: exports_external.object({
30124
+ tools: exports_external.object({
30125
+ enabled: exports_external.array(exports_external.string()),
30126
+ disabled: exports_external.array(exports_external.string()),
30127
+ unknownDisabled: exports_external.array(exports_external.string())
30128
+ }),
30129
+ hooks: exports_external.object({
30130
+ enabled: exports_external.array(exports_external.string()),
30131
+ disabled: exports_external.array(exports_external.string()),
30132
+ unknownDisabled: exports_external.array(exports_external.string())
30133
+ }),
30134
+ mcps: exports_external.object({
30135
+ enabled: exports_external.array(exports_external.string()),
30136
+ disabled: exports_external.array(exports_external.string()),
30137
+ unknownDisabled: exports_external.array(exports_external.string())
30138
+ })
30139
+ }),
30140
+ loops: exports_external.object({
30141
+ ralph: exports_external.object({
30142
+ activeCount: exports_external.number(),
30143
+ active: exports_external.array(exports_external.object({
30144
+ sessionID: exports_external.string(),
30145
+ iteration: exports_external.number(),
30146
+ maxIterations: exports_external.number(),
30147
+ active: exports_external.boolean()
30148
+ }))
30149
+ })
30150
+ }),
30151
+ sessions: exports_external.object({
30152
+ internalSessionCount: exports_external.number()
30153
+ }),
30154
+ config: exports_external.object({
30155
+ astSearch: exports_external.object({
30156
+ enabled: exports_external.boolean(),
30157
+ binaryFound: exports_external.boolean(),
30158
+ binaryPath: exports_external.string().optional()
30159
+ }),
30160
+ mcp: exports_external.object({
30161
+ context7: exports_external.object({
30162
+ enabled: exports_external.boolean(),
30163
+ npxFound: exports_external.boolean(),
30164
+ npxPath: exports_external.string().optional()
30165
+ })
30166
+ })
30167
+ }),
30168
+ binaries: exports_external.record(exports_external.string(), BinaryStatusSchema),
30169
+ warnings: exports_external.array(exports_external.string())
30170
+ });
30171
+ function splitPathEnv() {
30172
+ const raw = process.env.PATH ?? "";
30173
+ const sep = path11.delimiter;
30174
+ return raw.split(sep).filter(Boolean);
30175
+ }
30176
+ function isExecutable(p) {
30177
+ try {
30178
+ const stat = fs11.statSync(p);
30179
+ if (!stat.isFile())
30180
+ return false;
30181
+ fs11.accessSync(p, fs11.constants.X_OK);
30182
+ return true;
30183
+ } catch {
30184
+ return false;
30185
+ }
30186
+ }
30187
+ function candidateNames(base) {
30188
+ if (process.platform !== "win32")
30189
+ return [base];
30190
+ return [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`];
30191
+ }
30192
+ function findBinaryInPath(name) {
30193
+ const dirs = splitPathEnv();
30194
+ const candidates = candidateNames(name);
30195
+ for (const dir of dirs) {
30196
+ for (const c of candidates) {
30197
+ const full = path11.join(dir, c);
30198
+ if (isExecutable(full))
30199
+ return full;
30200
+ }
30201
+ }
30202
+ return null;
30203
+ }
30204
+ function getVersion(exePath, args) {
30205
+ try {
30206
+ const out = spawnSync4(exePath, args, { encoding: "utf-8" });
30207
+ const stdout = (out.stdout ?? "").toString().trim();
30208
+ const stderr = (out.stderr ?? "").toString().trim();
30209
+ if (out.status === 0) {
30210
+ const firstLine = (stdout || stderr).split(/\r?\n/)[0]?.trim();
30211
+ return firstLine ? { version: firstLine } : {};
30212
+ }
30213
+ const msg = stderr || stdout;
30214
+ return msg ? { error: msg.split(/\r?\n/)[0].trim() } : { error: `Exit code ${out.status ?? "unknown"}` };
30215
+ } catch (err) {
30216
+ const msg = err instanceof Error ? err.message : String(err);
30217
+ return { error: msg };
30218
+ }
30219
+ }
30220
+ function checkBinary(name, versionArgs = ["--version"], includeVersion = true) {
30221
+ const p = findBinaryInPath(name);
30222
+ if (!p)
30223
+ return { found: false };
30224
+ if (!includeVersion)
30225
+ return { found: true, path: p };
30226
+ const v = getVersion(p, versionArgs);
30227
+ return { found: true, path: p, version: v.version, error: v.error };
30228
+ }
30229
+ function uniqSorted(xs) {
30230
+ return [...new Set(xs)].sort((a, b) => a.localeCompare(b));
30231
+ }
30232
+ function computeUnknownDisabled(disabled, known) {
30233
+ const set3 = new Set(known);
30234
+ return uniqSorted(disabled.filter((x) => !set3.has(x)));
30235
+ }
30236
+ function enabledFromDisabled(known, disabled) {
30237
+ const dis = new Set(disabled);
30238
+ return uniqSorted(known.filter((x) => !dis.has(x)));
30239
+ }
30240
+ function createHealthCheckTool(ctx, deps) {
30241
+ return tool({
30242
+ description: "Collect plugin health diagnostics: registered hooks/tools, active loops, config consistency, and dependency binaries.",
30243
+ args: {
30244
+ includeVersions: tool.schema.boolean().optional().describe("If true, run each binary with --version (default: false)")
30245
+ },
30246
+ execute: async (args) => {
30247
+ const includeVersions = args.includeVersions ?? false;
30248
+ const disabledTools = deps.disabledTools ?? [];
30249
+ const disabledHooks = deps.disabledHooks ?? [];
30250
+ const disabledMcps = deps.disabledMcps ?? [];
30251
+ const knownTools = [...BUILTIN_TOOL_IDS];
30252
+ const knownHooks = [...BUILTIN_HOOK_IDS];
30253
+ const knownMcps = Object.keys(BUILTIN_MCPS);
30254
+ const enabledTools = uniqSorted(deps.toolNames);
30255
+ const enabledHooks = enabledFromDisabled(knownHooks, disabledHooks);
30256
+ const enabledMcps = enabledFromDisabled(knownMcps, disabledMcps);
30257
+ const unknownDisabledTools = computeUnknownDisabled(disabledTools, knownTools);
30258
+ const unknownDisabledHooks = computeUnknownDisabled(disabledHooks, knownHooks);
30259
+ const unknownDisabledMcps = computeUnknownDisabled(disabledMcps, knownMcps);
30260
+ const astPath = deps.astGrepBinary !== undefined ? deps.astGrepBinary : findAstGrepBinary();
30261
+ const astEnabled = !disabledTools.includes("ast_search");
30262
+ const astFound = Boolean(astPath);
30263
+ const binaries = {};
30264
+ binaries.node = checkBinary("node", ["--version"], includeVersions);
30265
+ binaries.bun = checkBinary("bun", ["--version"], includeVersions);
30266
+ binaries.npx = checkBinary("npx", ["--version"], includeVersions);
30267
+ binaries.git = checkBinary("git", ["--version"], includeVersions);
30268
+ binaries.gh = checkBinary("gh", ["--version"], includeVersions);
30269
+ binaries.rg = checkBinary("rg", ["--version"], includeVersions);
30270
+ if (astPath) {
30271
+ const v = includeVersions ? getVersion(astPath, ["--version"]) : {};
30272
+ binaries["ast-grep"] = { found: true, path: astPath, version: v.version, error: v.error };
30273
+ } else {
30274
+ const ast = checkBinary("sg", ["--version"], includeVersions);
30275
+ binaries["ast-grep"] = ast.found ? ast : { found: false };
30276
+ }
30277
+ const context7Enabled = enabledMcps.includes("context7");
30278
+ const npxFound = binaries.npx.found;
30279
+ const warnings = [];
30280
+ if (unknownDisabledTools.length > 0)
30281
+ warnings.push(`Unknown disabled tool id(s): ${unknownDisabledTools.join(", ")}`);
30282
+ if (unknownDisabledHooks.length > 0)
30283
+ warnings.push(`Unknown disabled hook id(s): ${unknownDisabledHooks.join(", ")}`);
30284
+ if (unknownDisabledMcps.length > 0)
30285
+ warnings.push(`Unknown disabled mcp id(s): ${unknownDisabledMcps.join(", ")}`);
30286
+ if (astEnabled && !astFound)
30287
+ warnings.push("ast_search is enabled but ast-grep binary was not found in PATH");
30288
+ if (context7Enabled && !npxFound)
30289
+ warnings.push("MCP 'context7' is enabled but 'npx' was not found in PATH");
30290
+ const active = getActiveRalphLoops();
30291
+ const result = {
30292
+ ok: warnings.length === 0,
30293
+ generatedAt: new Date().toISOString(),
30294
+ registry: {
30295
+ tools: { enabled: enabledTools, disabled: uniqSorted(disabledTools), unknownDisabled: unknownDisabledTools },
30296
+ hooks: { enabled: enabledHooks, disabled: uniqSorted(disabledHooks), unknownDisabled: unknownDisabledHooks },
30297
+ mcps: { enabled: enabledMcps, disabled: uniqSorted(disabledMcps), unknownDisabled: unknownDisabledMcps }
30298
+ },
30299
+ loops: {
30300
+ ralph: { activeCount: active.length, active }
30301
+ },
30302
+ sessions: {
30303
+ internalSessionCount: deps.internalSessions?.size ?? 0
30304
+ },
30305
+ config: {
30306
+ astSearch: {
30307
+ enabled: astEnabled,
30308
+ binaryFound: astFound,
30309
+ binaryPath: astPath ?? undefined
30310
+ },
30311
+ mcp: {
30312
+ context7: {
30313
+ enabled: context7Enabled,
30314
+ npxFound,
30315
+ npxPath: binaries.npx.path
30316
+ }
30317
+ }
30318
+ },
30319
+ binaries,
30320
+ warnings
30321
+ };
30322
+ const parsed = HealthCheckResultSchema.safeParse(result);
30323
+ if (!parsed.success) {
30324
+ return {
30325
+ ok: false,
30326
+ generatedAt: result.generatedAt,
30327
+ error: parsed.error.issues.map((i) => `${i.path.map(String).join(".")}: ${i.message}`).join("; ")
30328
+ };
30329
+ }
30330
+ return result;
30331
+ }
30332
+ });
30333
+ }
30334
+
29680
30335
  // src/hooks/todo-enforcer.ts
29681
30336
  var DEFAULT_MAX_ENFORCEMENTS = 5;
29682
30337
  var sessionState = new Map;
@@ -29759,7 +30414,7 @@ ${unfinished.map((t) => `- [ ] ${t.text}`).join(`
29759
30414
  }
29760
30415
 
29761
30416
  // src/hooks/comment-checker.ts
29762
- import * as fs11 from "fs";
30417
+ import * as fs12 from "fs";
29763
30418
  var AI_SLOP_PATTERNS = [
29764
30419
  /\/\/ .{80,}/,
29765
30420
  /\/\/ (This|The|We|Here|Note:)/i,
@@ -29838,7 +30493,7 @@ function createCommentCheckerHook(internalSessions, maxRatio = 0.3, slopThreshol
29838
30493
  if (!filePath || !isCodeFile(filePath))
29839
30494
  return;
29840
30495
  try {
29841
- const content = fs11.readFileSync(filePath, "utf-8");
30496
+ const content = fs12.readFileSync(filePath, "utf-8");
29842
30497
  const result = checkComments(content, filePath, maxRatio, slopThreshold);
29843
30498
  if (result.shouldWarn) {
29844
30499
  output.output += `
@@ -29997,127 +30652,6 @@ function createSessionCompactionHook(ctx, internalSessions) {
29997
30652
  };
29998
30653
  }
29999
30654
 
30000
- // src/concurrency/semaphore.ts
30001
- class Semaphore {
30002
- max;
30003
- queue = [];
30004
- active = 0;
30005
- constructor(max) {
30006
- this.max = max;
30007
- }
30008
- async acquire() {
30009
- if (this.active < this.max) {
30010
- this.active++;
30011
- return;
30012
- }
30013
- return new Promise((resolve2) => {
30014
- this.queue.push(() => {
30015
- this.active++;
30016
- resolve2();
30017
- });
30018
- });
30019
- }
30020
- release() {
30021
- if (this.active <= 0)
30022
- return;
30023
- this.active--;
30024
- const next = this.queue.shift();
30025
- if (next)
30026
- next();
30027
- }
30028
- get pending() {
30029
- return this.queue.length;
30030
- }
30031
- get running() {
30032
- return this.active;
30033
- }
30034
- }
30035
- // src/concurrency/pool.ts
30036
- function extractProvider(model) {
30037
- const slash = model.indexOf("/");
30038
- return slash > 0 ? model.slice(0, slash) : model;
30039
- }
30040
-
30041
- class ConcurrencyPool {
30042
- global;
30043
- providers = new Map;
30044
- models = new Map;
30045
- config;
30046
- constructor(config3) {
30047
- this.config = config3;
30048
- this.global = new Semaphore(config3.defaultConcurrency ?? Infinity);
30049
- for (const [provider, limit] of Object.entries(config3.providerConcurrency ?? {})) {
30050
- this.providers.set(provider, new Semaphore(limit));
30051
- }
30052
- for (const [model, limit] of Object.entries(config3.modelConcurrency ?? {})) {
30053
- this.models.set(model, new Semaphore(limit));
30054
- }
30055
- }
30056
- async run(model, fn) {
30057
- const provider = extractProvider(model);
30058
- const semaphores = [];
30059
- const modelSem = this.models.get(model);
30060
- if (modelSem) {
30061
- await modelSem.acquire();
30062
- semaphores.push(modelSem);
30063
- }
30064
- const providerSem = this.providers.get(provider);
30065
- if (providerSem) {
30066
- await providerSem.acquire();
30067
- semaphores.push(providerSem);
30068
- }
30069
- await this.global.acquire();
30070
- semaphores.push(this.global);
30071
- try {
30072
- return await fn();
30073
- } finally {
30074
- for (let i = semaphores.length - 1;i >= 0; i--) {
30075
- semaphores[i].release();
30076
- }
30077
- }
30078
- }
30079
- }
30080
- // src/mcp/servers.ts
30081
- var BUILTIN_MCPS = {
30082
- context7: {
30083
- command: "npx",
30084
- args: ["-y", "@upstash/context7-mcp@latest"]
30085
- }
30086
- };
30087
-
30088
- // src/mcp/index.ts
30089
- async function registerMcps(ctx, disabled, apiKeys) {
30090
- const disabledSet = new Set(disabled);
30091
- const keys = apiKeys ?? {};
30092
- for (const [name, config3] of Object.entries(BUILTIN_MCPS)) {
30093
- if (disabledSet.has(name)) {
30094
- log(`MCP ${name} disabled by config`);
30095
- continue;
30096
- }
30097
- try {
30098
- const args = [...config3.args];
30099
- const envKey = `${name.toUpperCase().replace(/-/g, "_")}_API_KEY`;
30100
- const apiKey = keys[name] ?? process.env[envKey];
30101
- if (apiKey) {
30102
- args.push("--api-key", apiKey);
30103
- }
30104
- const body = {
30105
- name,
30106
- command: config3.command,
30107
- args
30108
- };
30109
- if (config3.env) {
30110
- body.env = config3.env;
30111
- }
30112
- const client = ctx.client;
30113
- await client.mcp?.add?.({ body });
30114
- log(`MCP ${name} registered${apiKey ? " (with API key)" : ""}`);
30115
- } catch (err) {
30116
- log(`MCP ${name} registration failed: ${err}`);
30117
- }
30118
- }
30119
- }
30120
-
30121
30655
  // src/index.ts
30122
30656
  var OpenCodeUltra = async (ctx) => {
30123
30657
  log("ENTRY \u2014 plugin loading", { directory: ctx.directory });
@@ -30227,6 +30761,16 @@ var OpenCodeUltra = async (ctx) => {
30227
30761
  if (!disabledTools.has("evolve_publish")) {
30228
30762
  toolRegistry.evolve_publish = createEvolvePublishTool();
30229
30763
  }
30764
+ if (!disabledTools.has("health_check")) {
30765
+ toolRegistry.health_check = createHealthCheckTool(ctx, {
30766
+ toolNames: Object.keys(toolRegistry),
30767
+ disabledTools: [...disabledTools],
30768
+ disabledHooks: [...pluginConfig.disabled_hooks ?? []],
30769
+ disabledMcps: [...pluginConfig.disabled_mcps ?? []],
30770
+ internalSessions,
30771
+ astGrepBinary: findAstGrepBinary()
30772
+ });
30773
+ }
30230
30774
  return {
30231
30775
  tool: toolRegistry,
30232
30776
  config: async (config3) => {
@@ -0,0 +1,109 @@
1
+ export interface BackoffOptions {
2
+ /** Base delay in ms (default: 200) */
3
+ baseDelayMs?: number;
4
+ /** Max delay in ms (default: 10_000) */
5
+ maxDelayMs?: number;
6
+ }
7
+ export type BackoffJitterMode = "full" | "none";
8
+ export interface ExponentialBackoffOptions extends BackoffOptions {
9
+ /** Jitter strategy (default: full) */
10
+ jitter?: BackoffJitterMode;
11
+ /** Random generator in [0, 1) (default: Math.random) */
12
+ rng?: () => number;
13
+ }
14
+ export declare function computeExponentialBackoffDelayMs(attempt: number, opts?: ExponentialBackoffOptions): number;
15
+ export interface CircuitBreakerOptions {
16
+ /** Open circuit after this many consecutive retryable failures (default: 3) */
17
+ failureThreshold?: number;
18
+ /** How long the circuit stays open before allowing a single trial (default: 10_000) */
19
+ cooldownMs?: number;
20
+ }
21
+ type CircuitState = "closed" | "open" | "half_open";
22
+ export declare class CircuitBreakerOpenError extends Error {
23
+ readonly provider: string;
24
+ readonly waitMs: number;
25
+ constructor(provider: string, waitMs: number);
26
+ }
27
+ export declare class CircuitBreaker {
28
+ private state;
29
+ private consecutiveFailures;
30
+ private openUntil;
31
+ private halfOpenInFlight;
32
+ private failureThreshold;
33
+ private cooldownMs;
34
+ constructor(opts?: CircuitBreakerOptions);
35
+ snapshot(now: number): {
36
+ state: CircuitState;
37
+ consecutiveFailures: number;
38
+ openForMs: number;
39
+ };
40
+ /**
41
+ * Gate a request. If open, returns not allowed with waitMs.
42
+ * If half-open, only allows a single in-flight probe.
43
+ */
44
+ enter(now: number): {
45
+ allowed: boolean;
46
+ waitMs?: number;
47
+ };
48
+ onSuccess(): void;
49
+ onFailure(now: number): void;
50
+ }
51
+ export interface RetryBudgetOptions {
52
+ /** Max retries per provider per interval (default: 20) */
53
+ maxRetries?: number;
54
+ /** Interval for budget reset (default: 60_000) */
55
+ intervalMs?: number;
56
+ }
57
+ export declare class RetryBudgetExceededError extends Error {
58
+ readonly provider: string;
59
+ constructor(provider: string);
60
+ }
61
+ export declare class RetryBudget {
62
+ private maxRetries;
63
+ private intervalMs;
64
+ private state;
65
+ constructor(opts?: RetryBudgetOptions);
66
+ trySpend(provider: string, cost?: number): boolean;
67
+ }
68
+ export interface RetryDecision {
69
+ retryable: boolean;
70
+ /** HTTP status code when available */
71
+ status?: number;
72
+ /** Retry-After when available */
73
+ retryAfterMs?: number;
74
+ }
75
+ export declare function parseRetryAfterMs(value: string, now: number): number | undefined;
76
+ export declare function classifyRetryableError(err: unknown, now?: number): RetryDecision;
77
+ export interface AdaptiveRetryOptions {
78
+ /** Max total attempts including the first attempt (default: 4) */
79
+ maxAttempts?: number;
80
+ /** Backoff options (default: base 200, max 10_000) */
81
+ backoff?: ExponentialBackoffOptions;
82
+ /** Cap any retry-after delays to this max (default: 30_000) */
83
+ maxRetryAfterMs?: number;
84
+ /** Treat this classifier result as authoritative (default: classifyRetryableError) */
85
+ classify?: (err: unknown, now: number) => RetryDecision;
86
+ /** Sleep function used for delays (default: setTimeout) */
87
+ sleep?: (ms: number) => Promise<void>;
88
+ /** Time source (default: Date.now) */
89
+ now?: () => number;
90
+ }
91
+ export declare function adaptiveRetry<T>(provider: string, fn: () => Promise<T>, breaker: CircuitBreaker, budget: RetryBudget, opts?: AdaptiveRetryOptions): Promise<T>;
92
+ export interface ProviderResilienceOptions {
93
+ breaker?: CircuitBreakerOptions;
94
+ retryBudget?: RetryBudgetOptions;
95
+ retry?: AdaptiveRetryOptions;
96
+ }
97
+ export declare class ProviderResilience {
98
+ private breakers;
99
+ private budgets;
100
+ private opts;
101
+ constructor(opts?: ProviderResilienceOptions);
102
+ private getBreaker;
103
+ private getBudget;
104
+ run<T>(provider: string, fn: () => Promise<T>, retryOverrides?: AdaptiveRetryOptions): Promise<T>;
105
+ snapshot(provider: string, now?: number): {
106
+ breaker: ReturnType<CircuitBreaker["snapshot"]>;
107
+ };
108
+ }
109
+ export {};
@@ -3,3 +3,5 @@ export { parseJsonc } from "./jsonc";
3
3
  export { log } from "./log";
4
4
  export type { ExtendedClient, ModelRef, ToastOptions, SystemTransformInput } from "./types";
5
5
  export { TtlMap } from "./ttl-map";
6
+ export { CircuitBreaker, CircuitBreakerOpenError, ProviderResilience, RetryBudget, RetryBudgetExceededError, adaptiveRetry, classifyRetryableError, computeExponentialBackoffDelayMs, parseRetryAfterMs, } from "./concurrency";
7
+ export type { AdaptiveRetryOptions, BackoffJitterMode, BackoffOptions, CircuitBreakerOptions, ExponentialBackoffOptions, ProviderResilienceOptions, RetryBudgetOptions, RetryDecision, } from "./concurrency";
@@ -0,0 +1,73 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import { tool } from "@opencode-ai/plugin";
3
+ import { z } from "zod";
4
+ import { type ActiveRalphLoopInfo } from "./ralph-loop";
5
+ export declare const BUILTIN_HOOK_IDS: readonly ["keyword-detector", "rules-injector", "context-injector", "fragment-injector", "prompt-renderer", "todo-enforcer", "comment-checker", "token-truncation", "session-compaction"];
6
+ export declare const BUILTIN_TOOL_IDS: readonly ["spawn_agent", "ralph_loop", "cancel_ralph", "batch_read", "ledger_save", "ledger_load", "ast_search", "evolve_apply", "evolve_scan", "evolve_score", "evolve_exe", "evolve_publish", "health_check"];
7
+ export type BuiltinHookId = (typeof BUILTIN_HOOK_IDS)[number];
8
+ export type BuiltinToolId = (typeof BUILTIN_TOOL_IDS)[number];
9
+ export interface BinaryStatus {
10
+ found: boolean;
11
+ path?: string;
12
+ version?: string;
13
+ error?: string;
14
+ }
15
+ export interface HealthCheckResult {
16
+ ok: boolean;
17
+ generatedAt: string;
18
+ registry: {
19
+ tools: {
20
+ enabled: string[];
21
+ disabled: string[];
22
+ unknownDisabled: string[];
23
+ };
24
+ hooks: {
25
+ enabled: string[];
26
+ disabled: string[];
27
+ unknownDisabled: string[];
28
+ };
29
+ mcps: {
30
+ enabled: string[];
31
+ disabled: string[];
32
+ unknownDisabled: string[];
33
+ };
34
+ };
35
+ loops: {
36
+ ralph: {
37
+ activeCount: number;
38
+ active: ActiveRalphLoopInfo[];
39
+ };
40
+ };
41
+ sessions: {
42
+ internalSessionCount: number;
43
+ };
44
+ config: {
45
+ astSearch: {
46
+ enabled: boolean;
47
+ binaryFound: boolean;
48
+ binaryPath?: string;
49
+ };
50
+ mcp: {
51
+ context7: {
52
+ enabled: boolean;
53
+ npxFound: boolean;
54
+ npxPath?: string;
55
+ };
56
+ };
57
+ };
58
+ binaries: Record<string, BinaryStatus>;
59
+ warnings: string[];
60
+ }
61
+ export declare const BinaryStatusSchema: z.ZodType<BinaryStatus>;
62
+ export declare const HealthCheckResultSchema: z.ZodType<HealthCheckResult>;
63
+ export interface HealthCheckDeps {
64
+ toolNames: string[];
65
+ disabledTools?: string[];
66
+ disabledHooks?: string[];
67
+ disabledMcps?: string[];
68
+ internalSessions?: Set<string>;
69
+ astGrepBinary?: string | null;
70
+ }
71
+ export declare function findBinaryInPath(name: string): string | null;
72
+ export declare function checkBinary(name: string, versionArgs?: string[], includeVersion?: boolean): BinaryStatus;
73
+ export declare function createHealthCheckTool(ctx: PluginInput, deps: HealthCheckDeps): ReturnType<typeof tool>;
@@ -0,0 +1,16 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ export interface ActiveRalphLoopInfo {
3
+ sessionID: string;
4
+ iteration: number;
5
+ maxIterations: number;
6
+ active: boolean;
7
+ }
8
+ export declare function getActiveRalphLoops(): ActiveRalphLoopInfo[];
9
+ export interface RalphLoopDeps {
10
+ /** Per-iteration timeout in ms (default: 180000 = 3min) */
11
+ iterationTimeoutMs?: number;
12
+ }
13
+ export declare function createRalphLoopTools(ctx: PluginInput, internalSessions: Set<string>, deps?: RalphLoopDeps): {
14
+ ralph_loop: any;
15
+ cancel_ralph: any;
16
+ };
@@ -0,0 +1,19 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import { tool } from "@opencode-ai/plugin";
3
+ import { ProviderResilience } from "../shared";
4
+ import { type ConcurrencyPool } from "../concurrency";
5
+ import type { CategoryConfig } from "../categories";
6
+ export interface SpawnAgentDeps {
7
+ pool?: ConcurrencyPool;
8
+ categories?: Record<string, CategoryConfig>;
9
+ resolveAgentModel?: (agent: string) => string | undefined;
10
+ /** Provider-level retry/backoff + circuit breaker */
11
+ resilience?: ProviderResilience;
12
+ /** Max total concurrent spawned sessions (default: 15) */
13
+ maxTotalSpawned?: number;
14
+ /** Per-agent timeout in ms (default: 180000 = 3min) */
15
+ agentTimeoutMs?: number;
16
+ /** Reference to internalSessions for spawn limit check */
17
+ internalSessions?: Set<string>;
18
+ }
19
+ export declare function createSpawnAgentTool(ctx: PluginInput, internalSessions: Set<string>, deps?: SpawnAgentDeps): ReturnType<typeof tool>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ultra",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Lightweight OpenCode 1.2.x plugin — ultrawork mode, multi-agent orchestration, rules injection",
5
5
  "keywords": [
6
6
  "opencode",