@awak-app/simy-cli 0.5.73 → 0.5.74

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/README.md CHANGED
@@ -99,6 +99,19 @@ retains the authoritative analysis history. Persistent provider login, model or
99
99
  access errors pause new local claims with a visible recovery hint; refreshing
100
100
  after reconnecting permits another probe, with bounded automatic cooldowns.
101
101
 
102
+ Conversation checks blocked by **Sign-in required** automatically recheck after
103
+ a fresh Codex or Claude login probe succeeds, including after an app restart.
104
+ An unchanged ready status cannot repeatedly re-run a failed check. Manual retry
105
+ remains available; continuing the conversation still requires **Auto-continue**.
106
+
107
+ Claude read-only structured reviews retry once when the provider explicitly
108
+ reports exhausted turns or structured-output repair attempts, within the original
109
+ timeout. Other failures retain distinct codes and recovery hints. Safe native
110
+ result subtype, exit status, output size and recognized model identifiers survive
111
+ restart in SQM history and correlate with the task ID in diagnostic logs. Provider
112
+ responses, prompts and credentials are not retained. Older generic failure records
113
+ cannot acquire details that were never recorded.
114
+
102
115
  Detached daemon and desktop-host diagnostics are saved to
103
116
  `$SIMY_HOME/logs/daemon.log` and `$SIMY_HOME/logs/desktop-host.log`
104
117
  (`SIMY_HOME` defaults to `~/.simy`). Each log is limited to 1 MiB plus two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.73",
3
+ "version": "0.5.74",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.73";
16
+ export const CLI_VERSION = "0.5.74";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -231,6 +231,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
231
231
  if (!closed && !relay) emitStatus(status);
232
232
  return;
233
233
  }
234
+ await analysisManager.updateProviderStatuses?.(status.providers);
234
235
  const records = await recordReader.list();
235
236
  const projection = await actionAssociations.beginProjection?.({ records });
236
237
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
@@ -2,6 +2,7 @@ import { randomUUID, createHash } from "node:crypto";
2
2
  import { mkdir, appendFile, rename, writeFile, readdir, readFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { defaultSessionRoot } from "./session-store.js";
5
+ import { safeProviderDiagnostic } from "./sqm/provider-diagnostics.js";
5
6
 
6
7
  const writers = new Map();
7
8
  const scopeDir = (root, scope) => join(root, "diagnostics", createHash("sha256").update(JSON.stringify([
@@ -12,7 +13,10 @@ const safeIdentifier = value => typeof value === "string" && /^[a-z][a-z0-9_.-]{
12
13
  // Only structured diagnostics are durable. Never retain prompts, arbitrary error
13
14
  // messages, tokens, source paths, or provider stdout/stderr.
14
15
  export function safeDiagnosticEvent(value, now = () => Date.now()) {
16
+ const providerDiagnostic = safeProviderDiagnostic(value?.provider_diagnostic);
15
17
  return { at: Number.isFinite(Date.parse(value?.at)) ? value.at : new Date(now()).toISOString(),
18
+ ...(/^(?:sqm-check:)?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value?.task_id || "") ? { task_id: value.task_id } : {}),
19
+ ...(Object.keys(providerDiagnostic).length ? { provider_diagnostic: providerDiagnostic } : {}),
16
20
  kind: ["analysis", "check", "preflight"].includes(value?.kind) ? value.kind : "check",
17
21
  stage: safeIdentifier(value?.stage) || "unknown",
18
22
  code: safeIdentifier(value?.code) || "failure_unclassified",
@@ -5,6 +5,7 @@ import { analyzeContinuation, CONTINUATION_ANALYSIS_VERSION } from './continuati
5
5
 
6
6
  const BACKFILL_LIMIT = 10;
7
7
  const MAX_CACHE_ENTRIES = 2000;
8
+ const AUTH_RECOVERY_COOLDOWN_MS = 60_000;
8
9
  const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting', 'uncertain']);
9
10
  const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
10
11
  const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
@@ -49,6 +50,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
49
50
  const known = new Map(document.known.slice(-MAX_CACHE_ENTRIES));
50
51
  const entries = new Map(document.entries.slice(-MAX_CACHE_ENTRIES));
51
52
  const candidates = new Map();
53
+ const providerStatuses = new Map(), signedOutChecks = new Map();
52
54
  for (const [key, entry] of entries) {
53
55
  if (entry.result?.state === 'analyzing') entry.result = { state: 'queued' };
54
56
  }
@@ -79,9 +81,34 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
79
81
  forceAnalysis: forceAnalysis || entry?.forceAnalysis === true && ['queued', 'analyzing'].includes(entry.result?.state),
80
82
  settledResult: entry?.cwd === candidate.cwd ? entry.settledResult : null,
81
83
  requestId: entry?.cwd === candidate.cwd && ['queued', 'analyzing'].includes(entry.result?.state) ? entry.requestId : undefined,
84
+ ...(entry?.revision === revision ? { authRecoveryAttemptedAt: entry.authRecoveryAttemptedAt, authRecoverySignedOutAt: entry.authRecoverySignedOutAt } : {}),
82
85
  result: { state: 'queued' } });
83
86
  return true;
84
87
  };
88
+ // A successful login probe must postdate the failed inference. Cache hits and
89
+ // opening a login terminal are not recovery evidence. One automatic attempt
90
+ // survives restarts; only a new observed sign-out/sign-in cycle can rearm it.
91
+ const recoverAuthentication = () => {
92
+ let dirty = false;
93
+ for (const [key, candidate] of candidates) {
94
+ const entry = entries.get(key), status = providerStatuses.get(candidate.provider);
95
+ const checkedAt = Date.parse(status?.checkedAt);
96
+ if (!eligible(candidate) || candidate.isRunning === true || jobs.has(key) ||
97
+ entry?.revision !== continuationAnalysisRevision(candidate) ||
98
+ !['uncertain', 'failed'].includes(entry.result?.state) || entry.result?.reasonCode !== 'provider_sign_in_required' ||
99
+ status?.state !== 'ready' || !Number.isFinite(checkedAt) || checkedAt <= (entry.completedAt || entry.result.analyzedAt || 0)) continue;
100
+ const signedOutAt = signedOutChecks.get(candidate.provider) || 0;
101
+ if (entry.authRecoveryAttemptedAt !== undefined &&
102
+ (signedOutAt <= (entry.authRecoverySignedOutAt || 0) || now() - entry.authRecoveryAttemptedAt < AUTH_RECOVERY_COOLDOWN_MS)) continue;
103
+ if (enqueue(candidate, true, true)) {
104
+ const queued = entries.get(key);
105
+ queued.authRecoveryAttemptedAt = now();
106
+ queued.authRecoverySignedOutAt = signedOutAt;
107
+ dirty = true;
108
+ }
109
+ }
110
+ return dirty;
111
+ };
85
112
  const finishedAnalysis = (result, previous) => RESULT_STATES.has(result.state) && result.awaitingTurn !== true && result.contextDigest
86
113
  ? { ...result, awaitingTurn: false, analyzedAt: result.contextDigest && result.contextDigest === previous?.contextDigest ? previous.analyzedAt : now() }
87
114
  : previous;
@@ -136,6 +163,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
136
163
  }
137
164
  const settledResult = finishedAnalysis(result, selected.previousAnalysis || entry.settledResult);
138
165
  entries.set(selected.key, { revision: selected.revision, analysisVersion: CONTINUATION_ANALYSIS_VERSION, cwd: current.cwd, settledResult,
166
+ completedAt: now(), authRecoveryAttemptedAt: selected.entry.authRecoveryAttemptedAt, authRecoverySignedOutAt: selected.entry.authRecoverySignedOutAt,
139
167
  requestId: selected.entry.requestId, result: { ...result, analyzedAt: settledResult?.analyzedAt || now() } });
140
168
  await persist(); changed();
141
169
  });
@@ -183,6 +211,22 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
183
211
  });
184
212
  };
185
213
  return {
214
+ async updateProviderStatuses(statuses = {}) {
215
+ await exclusive(async () => {
216
+ if (closed) return;
217
+ for (const provider of ['codex', 'claude']) {
218
+ const status = statuses[provider];
219
+ if (!status) continue;
220
+ const checkedAt = Date.parse(status.checkedAt);
221
+ const previousAt = Date.parse(providerStatuses.get(provider)?.checkedAt);
222
+ if (Number.isFinite(previousAt) && (!Number.isFinite(checkedAt) || checkedAt < previousAt)) continue;
223
+ providerStatuses.set(provider, status);
224
+ if (status.state === 'signed_out' && Number.isFinite(checkedAt)) signedOutChecks.set(provider, checkedAt);
225
+ }
226
+ if (recoverAuthentication()) { await persist(); changed(); }
227
+ });
228
+ startDrain();
229
+ },
186
230
  async update(sessions) {
187
231
  await exclusive(async () => {
188
232
  if (closed) return;
@@ -230,6 +274,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
230
274
  }
231
275
  // Empty discovery during provider startup must not consume backfill.
232
276
  if (sorted.length) document.initialized = true;
277
+ dirty = recoverAuthentication() || dirty;
233
278
  if (dirty) await persist();
234
279
  });
235
280
  startDrain();
@@ -64,7 +64,7 @@ async function unavailableAnalysis(candidate, error, inspectProvider, signal) {
64
64
  if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
65
65
  if (error?.code === 'provider_sign_in_required' || status?.state === 'signed_out') {
66
66
  const label = candidate.provider === 'claude' ? 'Claude Code' : 'Codex';
67
- return uncertain(`Sign in to ${label} to check this conversation and continue its unfinished work. After signing in, retry this check.`, 'provider_sign_in_required');
67
+ return uncertain(`Sign in to ${label}. SIMY rechecks this conversation automatically when sign-in is detected. You can also retry manually. Unfinished work can continue automatically only when Auto Continue is on.`, 'provider_sign_in_required');
68
68
  }
69
69
  return uncertain('The local AI app could not check this turn. Check its connection, then retry.', 'analysis_unavailable');
70
70
  }
@@ -1,4 +1,5 @@
1
1
  import { diagnosticLogger } from "../diagnostic-log.js";
2
+ import { safeProviderDiagnostic } from "./provider-diagnostics.js";
2
3
  import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
@@ -47,7 +48,9 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
47
48
  await writeFile(temp, JSON.stringify(item), { mode: 0o600 });
48
49
  await rename(temp, target);
49
50
  if (["failed", "unavailable"].includes(item.state)) await diagnosticLogger({ root, scope }).record({
50
- kind: item.kind, stage: entry.errorStage || item.phase || "unknown", code: item.errorCode, provider: item.provider, http_status: entry.httpStatus, at: item.finishedAt || item.startedAt });
51
+ kind: item.kind, stage: item.errorStage || item.phase || "unknown", code: item.errorCode, provider: item.provider,
52
+ task_id: item.id, provider_diagnostic: item.providerDiagnostic,
53
+ http_status: item.errorStatus ?? item.httpStatus, at: item.finishedAt || item.startedAt });
51
54
  } finally { await rm(temp, { force: true }); cache.delete(dir); }
52
55
  const items = await list(scope);
53
56
  for (const kind of ["analysis", "check"]) {
@@ -78,6 +81,8 @@ function metadata(entry) {
78
81
  }
79
82
  if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
80
83
  if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
84
+ const providerDiagnostic = safeProviderDiagnostic(entry.providerDiagnostic);
85
+ if (Object.keys(providerDiagnostic).length) item.providerDiagnostic = providerDiagnostic;
81
86
  return item;
82
87
  }
83
88
 
@@ -0,0 +1,35 @@
1
+ const SUBTYPES = new Set(["success", "error_max_turns", "error_during_execution", "error_max_budget_usd", "error_max_structured_output_retries", "unknown"]);
2
+ const SIGNALS = new Set(["SIGTERM", "SIGKILL", "SIGABRT", "SIGSEGV", "SIGINT", "SIGHUP", "SIGPIPE"]);
3
+
4
+ // Reuse at every persistence boundary. Provider envelopes may contain prompts,
5
+ // paths, request IDs or credentials; none of their free-form text is retained.
6
+ export function safeProviderDiagnostic(value) {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
8
+ const result = {};
9
+ if (typeof value.subtype === "string") result.subtype = SUBTYPES.has(value.subtype) ? value.subtype : "unknown";
10
+ if (Number.isInteger(value.exitCode) && value.exitCode >= 0 && value.exitCode <= 255) result.exitCode = value.exitCode;
11
+ if (SIGNALS.has(value.signal)) result.signal = value.signal;
12
+ if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
13
+ if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
14
+ if (typeof value.model === "string" && /^(?:gpt-6-(?:astra)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
15
+ return result;
16
+ }
17
+
18
+ export const PROVIDER_FAILURE_HINTS = Object.freeze({
19
+ provider_turn_limit_reached: "The local AI reached its turn limit before completing structured output. Retry this analysis; if it repeats, update the local AI app or report this error code.",
20
+ provider_structured_output_exhausted: "The local AI exhausted its structured-output repair attempts. Retry the analysis; if it repeats, report this error code and update the local AI app.",
21
+ provider_budget_exceeded: "The local AI stopped at its configured budget. Check that provider's budget before retrying.",
22
+ provider_execution_error: "The local AI reported an execution error. Check its local status and retry the analysis.",
23
+ provider_invalid_json: "The local AI returned invalid JSON. Retry the analysis; if it repeats, update the local AI app.",
24
+ provider_invalid_envelope: "The local AI returned an unsupported response envelope. Update the local AI app and retry.",
25
+ provider_missing_structured_output: "The local AI returned no schema-constrained output. Update the local AI app and retry this analysis.",
26
+ provider_non_object_output: "The local AI returned a structured value that was not an object. Retry this analysis; if it repeats, report this error code.",
27
+ provider_process_failed: "The local AI process exited before completing the review. Check the recorded exit code and the local AI app, then retry.",
28
+ provider_spawn_failed: "The local AI process could not start. Check its installation and local execution permissions.",
29
+ provider_output_too_large: "The local AI response exceeded the output-size limit. Reduce the evidence size before retrying.",
30
+ provider_network_unavailable: "The local AI could not reach its provider. Check its network or proxy connection, then retry.",
31
+ provider_schema_rejected: "The local AI rejected the output schema. Update the local AI app; if it repeats, report this error code.",
32
+ provider_rate_limited: "The local AI provider is rate limited. Wait for its limit to reset before retrying.",
33
+ provider_tool_use_blocked: "The local AI attempted to use a tool during a read-only analysis. The analysis was stopped.",
34
+ analysis_cancelled: "The local AI analysis was cancelled.",
35
+ });
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
 
6
6
  import { inspectProviderStatus } from "../desktop-status.js";
7
7
  import { inspectDesktopExecutor } from "../desktop-executor.js";
8
+ import { safeProviderDiagnostic, PROVIDER_FAILURE_HINTS } from "./provider-diagnostics.js";
8
9
  import {
9
10
  webApiErrorMessage,
10
11
  webApiHeaders,
@@ -21,13 +22,14 @@ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
21
22
  export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
22
23
 
23
24
  export class SqmLocalReviewError extends Error {
24
- constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "" } = {}) {
25
+ constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "", providerDiagnostic } = {}) {
25
26
  super(message);
26
27
  this.name = "SqmLocalReviewError";
27
28
  this.status = status;
28
29
  this.code = code;
29
30
  this.stage = stage; this.jobId = jobId; this.failureRecorded = failureRecorded;
30
31
  this.validationFeedback = typeof validationFeedback === "string" ? validationFeedback.slice(0, 2000) : "";
32
+ if (providerDiagnostic) this.providerDiagnostic = safeProviderDiagnostic(providerDiagnostic);
31
33
  }
32
34
  }
33
35
 
@@ -134,7 +136,7 @@ export function createSqmLocalReviewWorker({
134
136
  item.state = state; item.finishedAt = new Date().toISOString();
135
137
  item.result = state === "completed" ? "SQM review submitted." : "";
136
138
  item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
137
- if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; }
139
+ if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; item.providerDiagnostic = failure.providerDiagnostic; }
138
140
  await persist(scope, item);
139
141
  }
140
142
  onChange();
@@ -379,9 +381,11 @@ async function runSingleProviderReview(
379
381
  ].join("\n\n");
380
382
  const deadline = deadlineAt ?? Date.now() + executionTimeoutMs;
381
383
  const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
382
- let stdout;
384
+ let processResult;
383
385
  let selectedModel;
384
- for (const model of models) {
386
+ let modelIndex = 0, retryAttempt = 0, claudeTurnLimit = 1;
387
+ while (modelIndex < models.length) {
388
+ const model = models[modelIndex];
385
389
  await beforeDispatch();
386
390
  if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
387
391
  const remaining = deadline - Date.now();
@@ -389,7 +393,7 @@ async function runSingleProviderReview(
389
393
  // Never consume a partial/stale output file left by a rejected model.
390
394
  await rm(outputPath, { force: true });
391
395
  try {
392
- stdout = await spawnReview(
396
+ processResult = await spawnReview(
393
397
  inspection.executable,
394
398
  provider === "claude" ? [
395
399
  "--print", "--output-format", "json",
@@ -397,7 +401,7 @@ async function runSingleProviderReview(
397
401
  "--no-session-persistence", "--tools", "",
398
402
  "--setting-sources", "", "--settings", '{"disableAllHooks":true}',
399
403
  "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
400
- ...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", "1"] : []),
404
+ ...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", String(claudeTurnLimit)] : []),
401
405
  ] : [
402
406
  "exec",
403
407
  "--model",
@@ -416,27 +420,43 @@ async function runSingleProviderReview(
416
420
  "-",
417
421
  ],
418
422
  prompt,
419
- { spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model,
423
+ { spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
420
424
  terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
421
425
  );
422
426
  selectedModel = model;
423
427
  break;
424
428
  } catch (error) {
429
+ // Repair only an explicit native structured-generation limit. Keep
430
+ // initial calls unchanged, the original deadline and dispatch guard,
431
+ // and never retry authentication/schema/process/security failures.
432
+ if (provider === "claude" && readOnlyAnalysis && retryAttempt === 0
433
+ && ["provider_turn_limit_reached", "provider_structured_output_exhausted"].includes(error.code)) {
434
+ retryAttempt = 1;
435
+ if (error.code === "provider_turn_limit_reached") claudeTurnLimit = 3;
436
+ continue;
437
+ }
425
438
  if (error.code !== "provider_model_unsupported" || model === models.at(-1)) throw error;
439
+ modelIndex += 1;
426
440
  }
427
441
  }
428
- const envelope = provider === "claude" ? JSON.parse(stdout) : null;
429
- if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
430
- if (readOnlyAnalysis && envelope?.is_error === true && providerSignInRequired(envelope.result)) {
431
- throw new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' });
442
+ let output;
443
+ if (provider === "claude") {
444
+ const envelope = parseProviderJson(processResult.stdout, processResult.diagnostic);
445
+ const failure = claudeEnvelopeFailure(envelope, processResult.diagnostic);
446
+ if (failure) throw failure;
447
+ if (!Object.hasOwn(envelope, "structured_output")) throw providerFailure("provider_missing_structured_output", processResult.diagnostic);
448
+ output = envelope.structured_output;
449
+ } else {
450
+ let text;
451
+ try { text = await readFile(outputPath, "utf8"); }
452
+ catch (error) {
453
+ if (error.code === "ENOENT") throw providerFailure("provider_missing_structured_output", processResult.diagnostic);
454
+ throw error;
432
455
  }
433
- throw new Error("Claude Code did not complete a structured review");
456
+ output = parseProviderJson(text, processResult.diagnostic);
434
457
  }
435
- const output = provider === "claude"
436
- ? envelope?.structured_output
437
- : JSON.parse(await readFile(outputPath, "utf8"));
438
458
  if (!output || Array.isArray(output) || typeof output !== "object") {
439
- throw new Error(`${label} returned a non-object structured result`);
459
+ throw providerFailure("provider_non_object_output", processResult.diagnostic);
440
460
  }
441
461
  return {
442
462
  output,
@@ -444,7 +464,7 @@ async function runSingleProviderReview(
444
464
  };
445
465
  } catch (error) {
446
466
  if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
447
- throw new SqmLocalReviewError(`Local ${label} review failed: ${errorMessage(error)}`, {
467
+ throw new SqmLocalReviewError(`Local ${label} review failed before producing a structured result.`, {
448
468
  code: `${provider}_local_review_failed`,
449
469
  });
450
470
  } finally {
@@ -465,120 +485,156 @@ function codexAnalysisArguments(system) {
465
485
  '-c', `developer_instructions=${JSON.stringify(system)}`];
466
486
  }
467
487
 
468
- function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model }) {
488
+ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt }) {
469
489
  return new Promise((resolve, reject) => {
470
- if (signal?.aborted) return reject(new Error('Local analysis was cancelled'));
471
- const child = spawnImpl(command, args, {
472
- env: safeReviewEnvironment(environment, provider),
473
- cwd,
474
- windowsHide: true,
475
- stdio: ["pipe", "pipe", "pipe"],
476
- });
477
- let stdout = "";
478
- let stderr = "";
479
- let stderrPending = "";
480
- let settled = false;
481
- let terminalError = null;
482
- let killTimer = null;
490
+ if (signal?.aborted) return reject(providerFailure("analysis_cancelled"));
491
+ let child;
492
+ try {
493
+ child = spawnImpl(command, args, {
494
+ env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
495
+ });
496
+ } catch { reject(providerFailure("provider_spawn_failed", { model })); return; }
497
+ let stdout = "", stderrPending = "", outputBytes = 0;
498
+ let settled = false, terminalError = null, killTimer = null;
499
+ let diagnostic = safeProviderDiagnostic({ model, retryAttempt });
483
500
  const halt = error => {
484
501
  if (settled || terminalError) return;
485
502
  terminalError = error;
486
- child.kill?.('SIGTERM');
503
+ child.kill?.("SIGTERM");
487
504
  if (!settled) killTimer = setTimeout(() => {
488
- child.kill?.('SIGKILL');
489
- // Do not acknowledge cancellation while an owned provider is still
490
- // shutting down. A broken process API is nevertheless bounded.
491
- if (!settled) killTimer = setTimeout(() => finish(new Error('Local analysis process did not confirm termination')), terminationGraceMs);
505
+ child.kill?.("SIGKILL");
506
+ // Keep the original timeout/cancellation if the process API never
507
+ // acknowledges termination; do not replace it with an opaque error.
508
+ if (!settled) killTimer = setTimeout(() => finish(terminalError), terminationGraceMs);
492
509
  }, terminationGraceMs);
493
510
  };
494
- const cancelled = () => halt(new Error('Local analysis was cancelled'));
495
- const timer = setTimeout(() => {
496
- halt(readOnlyAnalysis ? new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' }) : new Error(`${label} timed out after ${executionTimeoutMs}ms`));
497
- }, executionTimeoutMs);
511
+ const cancelled = () => halt(providerFailure("analysis_cancelled"));
512
+ const timer = setTimeout(() => halt(providerFailure("analysis_timeout")), executionTimeoutMs);
498
513
  timer.unref?.();
499
514
  const finish = (error = null) => {
500
515
  if (settled) return;
501
516
  settled = true;
502
- clearTimeout(timer);
503
- clearTimeout(killTimer);
504
- signal?.removeEventListener('abort', cancelled);
505
- error ? reject(error) : resolve(stdout);
517
+ clearTimeout(timer); clearTimeout(killTimer);
518
+ signal?.removeEventListener("abort", cancelled);
519
+ diagnostic = safeProviderDiagnostic({ ...diagnostic, outputBytes });
520
+ if (error) {
521
+ error.providerDiagnostic = safeProviderDiagnostic({ ...error.providerDiagnostic, ...diagnostic });
522
+ reject(error);
523
+ } else resolve({ stdout, diagnostic });
524
+ };
525
+ const nativeFailure = message => {
526
+ if (providerSignInRequired(message)) return providerFailure("provider_sign_in_required");
527
+ if (provider === "codex" && providerModelUnsupported(message, model)) return modelUnsupportedError(model);
528
+ const code = nativeProviderFailureCode(message);
529
+ return code ? providerFailure(code) : null;
530
+ };
531
+ const checkStderr = line => {
532
+ // Only native CLI error lines, never arbitrary warnings or model prose.
533
+ const match = line.match(/^\s*(?:ERROR:|Error:|API Error:)\s*(.*)$/);
534
+ if (!match) return;
535
+ let message = match[1];
536
+ try {
537
+ const event = JSON.parse(message);
538
+ if (event.type !== "error") return;
539
+ message = event.error?.message ?? event.message;
540
+ } catch { /* Older CLIs print a plain native ERROR line. */ }
541
+ const error = nativeFailure(message);
542
+ if (error) halt(error);
506
543
  };
507
544
  child.stdout?.setEncoding?.("utf8");
508
- child.stdout?.on?.("data", (chunk) => {
509
- if (settled) return;
545
+ child.stdout?.on?.("data", chunk => {
546
+ if (settled || terminalError) return;
547
+ outputBytes = Math.min(2147483647, outputBytes + Buffer.byteLength(chunk, "utf8"));
548
+ if (outputBytes > 2 * 1024 * 1024) { halt(providerFailure("provider_output_too_large")); return; }
510
549
  stdout += chunk;
511
- if (readOnlyAnalysis && provider === 'codex') {
512
- for (const line of stdout.split('\n').filter(Boolean)) {
550
+ if (readOnlyAnalysis && provider === "codex") {
551
+ for (const line of stdout.split("\n").filter(Boolean)) {
513
552
  let event;
514
553
  try { event = JSON.parse(line); } catch { continue; }
515
- if (['error', 'turn.failed'].includes(event.type)) {
516
- const message = event.message ?? event.error?.message;
517
- if (providerSignInRequired(message)) {
518
- halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
519
- return;
520
- }
521
- if (providerModelUnsupported(message, model)) {
522
- halt(modelUnsupportedError(model));
523
- return;
524
- }
554
+ if (["error", "turn.failed"].includes(event.type)) {
555
+ const error = nativeFailure(event.message ?? event.error?.message);
556
+ if (error) { halt(error); return; }
525
557
  }
526
- if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
527
- halt(new Error('Read-only analysis attempted to use a tool'));
528
- return;
558
+ if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || "")) {
559
+ halt(providerFailure("provider_tool_use_blocked")); return;
529
560
  }
530
561
  }
531
562
  }
532
- if (Buffer.byteLength(stdout, "utf8") > 2 * 1024 * 1024) {
533
- halt(new Error(`${label} structured output exceeded the size limit`));
534
- }
535
563
  });
536
- child.stdin?.on?.("error", finish);
564
+ child.stdin?.on?.("error", () => halt(providerFailure("provider_process_failed")));
537
565
  child.stderr?.setEncoding?.("utf8");
538
- child.stderr?.on?.("data", (chunk) => {
539
- if (!readOnlyAnalysis) stderr = `${stderr}${chunk}`.slice(-4000);
540
- if (provider === "codex") {
541
- stderrPending = `${stderrPending}${chunk}`.slice(-8000);
542
- const lines = stderrPending.split(/\r?\n/);
543
- stderrPending = lines.pop() || "";
544
- for (const line of lines) checkStderr(line);
545
- }
566
+ child.stderr?.on?.("data", chunk => {
567
+ if (settled || terminalError) return;
568
+ stderrPending = `${stderrPending}${chunk}`.slice(-8000);
569
+ const lines = stderrPending.split(/\r?\n/);
570
+ stderrPending = lines.pop() || "";
571
+ for (const line of lines) checkStderr(line);
546
572
  });
547
- const checkStderr = line => {
548
- // Native CLI diagnostics only; never match warnings or assistant text.
549
- const match = line.match(/^\s*ERROR:\s*(.*)$/);
550
- if (!match) return;
551
- let message = match[1];
552
- try {
553
- const event = JSON.parse(message);
554
- if (event.type !== "error") return;
555
- message = event.error?.message ?? event.message;
556
- } catch { /* Older CLIs print a plain native ERROR line. */ }
557
- if (providerSignInRequired(message)) {
558
- halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
559
- } else if (providerModelUnsupported(message, model)) {
560
- halt(modelUnsupportedError(model));
561
- }
562
- };
563
- child.on("error", finish);
564
- child.on("close", (code) => {
565
- if (provider === "codex") checkStderr(stderrPending);
566
- if (readOnlyAnalysis && provider === 'claude' && !terminalError) {
573
+ child.on("error", () => finish(providerFailure("provider_spawn_failed")));
574
+ child.on("close", (exitCode, exitSignal) => {
575
+ diagnostic = safeProviderDiagnostic({ ...diagnostic, exitCode, signal: exitSignal });
576
+ checkStderr(stderrPending);
577
+ if (provider === "claude") {
567
578
  let envelope;
568
- try { envelope = JSON.parse(stdout); } catch { /* Non-JSON failure remains unavailable. */ }
569
- if (envelope?.is_error === true && providerSignInRequired(envelope.result)) {
570
- finish(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
571
- return;
579
+ try { envelope = JSON.parse(stdout); } catch { /* Classified after a successful process exit. */ }
580
+ if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
581
+ const models = Object.keys(envelope.modelUsage || {});
582
+ diagnostic = safeProviderDiagnostic({ ...diagnostic, subtype: envelope.subtype,
583
+ model: models.length === 1 ? models[0] : envelope.model });
584
+ terminalError ||= claudeEnvelopeFailure(envelope, diagnostic);
572
585
  }
573
586
  }
574
- finish(terminalError || (code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`)));
587
+ finish(terminalError || (exitCode === 0 ? null : providerFailure("provider_process_failed")));
575
588
  });
576
- signal?.addEventListener('abort', cancelled, { once: true });
589
+ signal?.addEventListener("abort", cancelled, { once: true });
577
590
  if (signal?.aborted) { cancelled(); return; }
578
591
  child.stdin?.end(prompt);
579
592
  });
580
593
  }
581
594
 
595
+ function providerFailure(code, providerDiagnostic) {
596
+ return new SqmLocalReviewError(PROVIDER_FAILURE_HINTS[code]
597
+ || (code === "provider_sign_in_required" ? "Sign in to the local AI app again." : "The local AI analysis exceeded its time limit."),
598
+ { code, providerDiagnostic });
599
+ }
600
+
601
+ function parseProviderJson(text, diagnostic) {
602
+ try { return JSON.parse(text); }
603
+ catch { throw providerFailure("provider_invalid_json", diagnostic); }
604
+ }
605
+
606
+ function claudeEnvelopeFailure(envelope, diagnostic) {
607
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return providerFailure("provider_invalid_envelope", diagnostic);
608
+ if (typeof envelope.subtype !== "string" || (Object.hasOwn(envelope, "is_error") && typeof envelope.is_error !== "boolean")) return providerFailure("provider_invalid_envelope", diagnostic);
609
+ if (envelope.is_error === true || envelope.subtype !== "success") {
610
+ // Only inspect native failure envelopes, never a successful model result.
611
+ const messages = [envelope.result, ...(Array.isArray(envelope.errors) ? envelope.errors.slice(0, 8) : [])];
612
+ for (const message of messages) if (providerSignInRequired(message)) return providerFailure("provider_sign_in_required", diagnostic);
613
+ for (const message of messages) {
614
+ const code = nativeProviderFailureCode(message);
615
+ if (code) return providerFailure(code, diagnostic);
616
+ }
617
+ // An explicit auth/schema/network cause takes precedence over a limit
618
+ // subtype, so a mixed failure envelope cannot trigger a semantic retry.
619
+ const subtypeCode = {
620
+ error_max_turns: "provider_turn_limit_reached", error_max_budget_usd: "provider_budget_exceeded",
621
+ error_max_structured_output_retries: "provider_structured_output_exhausted",
622
+ }[envelope.subtype];
623
+ if (subtypeCode) return providerFailure(subtypeCode, diagnostic);
624
+ return providerFailure("provider_execution_error", diagnostic);
625
+ }
626
+ return null;
627
+ }
628
+
629
+ function nativeProviderFailureCode(message) {
630
+ if (typeof message !== "string") return null;
631
+ const text = message.slice(0, 4000);
632
+ if (/\b(?:invalid|unsupported)\s+(?:json\s+|output\s+)?schema\b|\bschema validation (?:failed|error)\b/i.test(text)) return "provider_schema_rejected";
633
+ if (/\b429\b.*(?:rate|limit)|\brate limit(?:ed| exceeded)\b/i.test(text)) return "provider_rate_limited";
634
+ if (/^(?:API Error:\s*)?(?:Connection error\b|(?:connect )?(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN)\b|fetch failed\b|network (?:error|unavailable)\b)/i.test(text.trim())) return "provider_network_unavailable";
635
+ return null;
636
+ }
637
+
582
638
  function providerModelUnsupported(message, model) {
583
639
  if (typeof message !== "string" || typeof model !== "string") return false;
584
640
  const rejection = message.trim().match(/^The ['"]([^'"]+)['"] model is not supported(?: when using Codex with a ChatGPT account)?\.?$/i);
@@ -648,7 +704,8 @@ function reviewFailure(error, stage, provider) {
648
704
  else if (stage === "analysis" || stage === "adjudication") code = `${provider}_local_review_failed`;
649
705
  }
650
706
  return { stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
651
- status: Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null };
707
+ status: Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null,
708
+ providerDiagnostic: safeProviderDiagnostic(error?.providerDiagnostic) };
652
709
  }
653
710
 
654
711
  function formatFailure(failure, failureReported = null) {
@@ -660,9 +717,11 @@ function formatFailure(failure, failureReported = null) {
660
717
  : failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."
661
718
  : failure.status === 401 ? "Reconnect your SIMY session."
662
719
  : failure.code.endsWith("_local_unavailable") ? "Install or update the selected local AI app."
720
+ : PROVIDER_FAILURE_HINTS[failure.code] ? PROVIDER_FAILURE_HINTS[failure.code]
663
721
  : failure.code.endsWith("_local_review_failed") ? "The local AI app did not complete a valid structured review."
664
722
  : "Open analysis details to inspect the task state.";
665
- return `${failure.stage} · ${failure.code}${failure.status ? ` · HTTP ${failure.status}` : ""}\n${hint}${failureReported === false ? "\nThe failure could not be reported to SIMY Web. The web task may still show as analyzing until its lease expires." : ""}`;
723
+ const diagnostic = Object.entries(safeProviderDiagnostic(failure.providerDiagnostic)).map(([key, value]) => `${key}=${value}`).join(" · ");
724
+ return `${failure.stage} · ${failure.code}${failure.status ? ` · HTTP ${failure.status}` : ""}\n${hint}${failure.providerDiagnostic?.retryAttempt === 1 ? "\nOne automatic retry was attempted." : ""}${diagnostic ? `\n${diagnostic}` : ""}${failureReported === false ? "\nThe failure could not be reported to SIMY Web. The web task may still show as analyzing until its lease expires." : ""}`;
666
725
  }
667
726
 
668
727
  function analysisTaskUrl(workspaceUrl, id) {