ennodia 0.5.2 → 0.5.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ennodia",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "mcpName": "io.github.cherninlab/ennodia",
5
5
  "description": "Connects the AI agents you use: your agent hands a task to another and gets the answer back",
6
6
  "license": "MIT",
package/src/diagnosis.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { TaskView } from "./tasks";
2
2
  import { truncate } from "./internal";
3
+ import { openCodeQuotaError } from "./harnesses";
3
4
 
4
5
  export type TaskOutputPreview = {
5
6
  harnessId: string;
@@ -150,7 +151,8 @@ export function hasCodexQuotaError(stdout: string): boolean {
150
151
  }
151
152
 
152
153
  function hasQuotaError(task: TaskView): boolean {
153
- return task.harnessId === "codex" && (hasCodexQuotaError(task.stdout) || hasCodexQuotaError(task.stderr));
154
+ return task.harnessId === "opencode" ? Boolean(openCodeQuotaError(task.stderr))
155
+ : task.harnessId === "codex" && (hasCodexQuotaError(task.stdout) || hasCodexQuotaError(task.stderr));
154
156
  }
155
157
 
156
158
  function hasAuthenticationError(task: TaskView): boolean {
package/src/harnesses.ts CHANGED
@@ -95,6 +95,8 @@ export type HarnessAdapter = {
95
95
  extractSessionId?: (stdout: string) => string | undefined;
96
96
  /** Some public CLIs report a semantic failure with exit code zero. */
97
97
  failureReason?: (stdout: string, stderr: string) => string | undefined;
98
+ /** A confirmed native failure that must stop a still-running CLI. */
99
+ streamFailureReason?: (stdout: string, stderr: string) => string | undefined;
98
100
  /** The CLI accepts operator instructions separately from the prompt. A
99
101
  * worker then reads Ennodia's limits as its session's own, not as text
100
102
  * inside the task, which a careful model can distrust. */
@@ -312,8 +314,10 @@ export const harnessAdapters: HarnessAdapter[] = [
312
314
  notes: ["Runs through `opencode run` without permission-bypass flags."],
313
315
  // Concurrent starts can fail while another instance holds its database.
314
316
  startupConflict: /database is locked/i,
317
+ streamFailureReason: (_stdout, stderr) => openCodeQuotaError(stderr),
315
318
  buildCommand: (commandPath, input) => {
316
- const args = ["run"];
319
+ // Retryable provider errors otherwise stay in OpenCode's private log.
320
+ const args = ["run", "--print-logs", "--log-level", "ERROR"];
317
321
 
318
322
  if (input.cwd) {
319
323
  args.push("--dir", input.cwd);
@@ -501,6 +505,24 @@ export const harnessAdapters: HarnessAdapter[] = [
501
505
  },
502
506
  ];
503
507
 
508
+ /** OpenCode's public --print-logs stderr; never inspect worker stdout here. */
509
+ export function openCodeQuotaError(stderr: string): string | undefined {
510
+ for (const line of stderr.split(/\r?\n/)) {
511
+ if (!/^timestamp=\S+ level=ERROR /.test(line) ||
512
+ !/ message="stream error" /.test(line) ||
513
+ !/ small=false /.test(line) || !/ mode=primary /.test(line)) continue;
514
+ // Ignore small/title requests: they can fail while the main agent works.
515
+ const encoded = / error\.error=("(?:[^"\\]|\\.)*")(?= |$)/.exec(line)?.[1];
516
+ if (!encoded) continue;
517
+ let message: string;
518
+ try { message = JSON.parse(encoded); } catch { continue; }
519
+ if (/^AI_APICallError:\s*(?:OpenCode )?Go usage limit (?:reached|exceeded)\b/i.test(message)) {
520
+ return message.replace(/^AI_APICallError:\s*/i, "");
521
+ }
522
+ }
523
+ return undefined;
524
+ }
525
+
504
526
  function extractCodexSessionId(stdout: string): string | undefined {
505
527
  for (const line of stdout.split(/\r?\n/)) {
506
528
  const event = parseJsonObject(line);
package/src/readiness.ts CHANGED
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
  import type { CommandSpec, HarnessAdapter, HarnessDiscovery, HarnessRunInput } from "./harnesses";
7
+ import { openCodeQuotaError } from "./harnesses";
7
8
  import { signalOwnedProcess } from "./process";
8
9
 
9
10
  export type ReadinessState = "unverified" | "ready" | "login-required" | "credentials-expired" | "setup-required" | "model-unavailable" | "quota-exhausted" | "unreachable" | "updating";
@@ -42,6 +43,7 @@ export class HarnessReadinessError extends Error {
42
43
 
43
44
  /** Recognize native failure envelopes, never quoted prose inside an answer. */
44
45
  export function nativeReadinessFailure(stdout: string, stderr: string, allowPlainStdout = false): ReadinessState | undefined {
46
+ if (openCodeQuotaError(stderr)) return "quota-exhausted";
45
47
  const stderrLines = stripVTControlCharacters(stderr).split(/\r?\n/);
46
48
  const lines = [...stderrLines, ...stripVTControlCharacters(stdout).split(/\r?\n/)];
47
49
  for (const [index, line] of lines.entries()) {
package/src/tasks.ts CHANGED
@@ -145,6 +145,8 @@ type InternalTask = Omit<
145
145
  isolatedCwd?: string;
146
146
  extractUsage?: HarnessAdapter["extractUsage"];
147
147
  failureReason?: HarnessAdapter["failureReason"];
148
+ streamFailureReason?: HarnessAdapter["streamFailureReason"];
149
+ streamFailure?: string;
148
150
  /** The exact spawn, kept so a start that met a startup conflict can run again. */
149
151
  spawnInput: TaskSpawnInput;
150
152
  stdinText?: string;
@@ -379,6 +381,7 @@ ${noticeLines.join("\n")}
379
381
  isolatedCwd,
380
382
  extractUsage: adapter.extractUsage,
381
383
  failureReason: adapter.failureReason,
384
+ streamFailureReason: adapter.streamFailureReason,
382
385
  spawnInput,
383
386
  stdinText: commandSpec.stdin,
384
387
  startupConflict: adapter.startupConflict,
@@ -407,7 +410,7 @@ ${noticeLines.join("\n")}
407
410
  ]).then(() => undefined);
408
411
 
409
412
  task.timeout = setTimeout(() => {
410
- if (task.cancelRequested) {
413
+ if (task.cancelRequested || task.streamFailure) {
411
414
  return;
412
415
  }
413
416
 
@@ -549,7 +552,7 @@ ${noticeLines.join("\n")}
549
552
  terminalStatus = "cancelled";
550
553
  } else {
551
554
  terminalStatus =
552
- exitCode === 0 && !task.timedOut && !task.drainTimedOut
555
+ exitCode === 0 && !task.timedOut && !task.drainTimedOut && !task.streamFailure
553
556
  ? "succeeded"
554
557
  : "failed";
555
558
  }
@@ -628,7 +631,7 @@ ${noticeLines.join("\n")}
628
631
  private metStartupConflict(task: InternalTask): boolean {
629
632
  return Boolean(task.startupConflict?.test(task.stderr)) &&
630
633
  !task.stdout.trim() &&
631
- !task.cancelRequested && !task.timedOut && !task.drainTimedOut && !this.shuttingDown &&
634
+ !task.cancelRequested && !task.timedOut && !task.drainTimedOut && !task.streamFailure && !this.shuttingDown &&
632
635
  (task.startupRestarts ?? 0) < MAX_STARTUP_RESTARTS &&
633
636
  Date.now() - task.attemptStartedAtMs < STARTUP_CONFLICT_WINDOW_MS;
634
637
  }
@@ -903,6 +906,7 @@ ${noticeLines.join("\n")}
903
906
  }
904
907
  task.streamCaptureTruncated ||= task[streamName].length + chunk.length > MAX_CAPTURE_CHARS;
905
908
  task[streamName] = appendLimited(task[streamName], chunk);
909
+ this.checkStreamFailure(task);
906
910
  task.lastOutputAtMs = Date.now();
907
911
  this.pushEvent(task, { type: streamName, message: chunk });
908
912
  this.touch(task);
@@ -912,6 +916,7 @@ ${noticeLines.join("\n")}
912
916
  if (finalChunk) {
913
917
  task.streamCaptureTruncated ||= task[streamName].length + finalChunk.length > MAX_CAPTURE_CHARS;
914
918
  task[streamName] = appendLimited(task[streamName], finalChunk);
919
+ this.checkStreamFailure(task);
915
920
  task.lastOutputAtMs = Date.now();
916
921
  this.pushEvent(task, { type: streamName, message: finalChunk });
917
922
  this.touch(task);
@@ -922,6 +927,20 @@ ${noticeLines.join("\n")}
922
927
  }
923
928
  }
924
929
 
930
+ private checkStreamFailure(task: InternalTask): void {
931
+ if (!task.streamFailureReason || task.streamFailure || task.cancelRequested || task.timedOut) return;
932
+ try {
933
+ const reason = task.streamFailureReason(task.stdout, task.stderr);
934
+ if (!reason) return;
935
+ task.streamFailure = reason;
936
+ this.pushEvent(task, { type: "error", message: reason });
937
+ this.terminate(task);
938
+ } catch (error) {
939
+ task.streamFailureReason = undefined;
940
+ this.pushEvent(task, { type: "error", message: `Adapter stream check failed: ${String(error)}` });
941
+ }
942
+ }
943
+
925
944
  private toView(task: InternalTask, options: TaskViewOptions = {}): TaskView {
926
945
  const now = Date.now();
927
946
  const end = task.endedAtMs ?? now;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const ENNODIA_VERSION = "0.5.2";
1
+ export const ENNODIA_VERSION = "0.5.3";