@awak-app/simy-cli 0.5.72 → 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.
Files changed (51) hide show
  1. package/README.md +97 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +191 -11
  4. package/src/backend-executable.js +21 -1
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +14 -10
  16. package/src/desktop-host.js +9 -6
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +72 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +7 -0
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/independent-audit.js +9 -0
  29. package/src/orchestrator/instruction.js +6 -1
  30. package/src/orchestrator/loop.js +38 -41
  31. package/src/orchestrator/outcome-monitor.js +58 -38
  32. package/src/orchestrator/outcome-progress.js +2 -0
  33. package/src/purpose-local-proof.js +380 -0
  34. package/src/runner.js +224 -36
  35. package/src/session-supervision/analysis-manager.js +45 -0
  36. package/src/session-supervision/codex-app-server.js +3 -2
  37. package/src/session-supervision/continuation-analysis.js +1 -1
  38. package/src/session-supervision/index.js +16 -4
  39. package/src/sqm/bundle-store.js +11 -7
  40. package/src/sqm/claude-plugin.js +2 -0
  41. package/src/sqm/command.js +36 -4
  42. package/src/sqm/diagnostics.js +1 -1
  43. package/src/sqm/evidence-client.js +23 -7
  44. package/src/sqm/history-store.js +9 -0
  45. package/src/sqm/index.js +104 -14
  46. package/src/sqm/proof.js +3 -23
  47. package/src/sqm/provider-diagnostics.js +35 -0
  48. package/src/sqm/request-recovery.js +82 -0
  49. package/src/sqm/review-worker.js +218 -112
  50. package/src/sqm/session.js +33 -3
  51. package/src/startup-preflight.js +44 -0
@@ -3,7 +3,9 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import { inspectProviderStatus } from "../desktop-status.js";
6
7
  import { inspectDesktopExecutor } from "../desktop-executor.js";
8
+ import { safeProviderDiagnostic, PROVIDER_FAILURE_HINTS } from "./provider-diagnostics.js";
7
9
  import {
8
10
  webApiErrorMessage,
9
11
  webApiHeaders,
@@ -20,13 +22,14 @@ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
20
22
  export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
21
23
 
22
24
  export class SqmLocalReviewError extends Error {
23
- 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 } = {}) {
24
26
  super(message);
25
27
  this.name = "SqmLocalReviewError";
26
28
  this.status = status;
27
29
  this.code = code;
28
30
  this.stage = stage; this.jobId = jobId; this.failureRecorded = failureRecorded;
29
31
  this.validationFeedback = typeof validationFeedback === "string" ? validationFeedback.slice(0, 2000) : "";
32
+ if (providerDiagnostic) this.providerDiagnostic = safeProviderDiagnostic(providerDiagnostic);
30
33
  }
31
34
  }
32
35
 
@@ -88,7 +91,8 @@ export function createSqmLocalReviewWorker({
88
91
  leaseSeconds = DEFAULT_LEASE_SECONDS,
89
92
  heartbeatIntervalMs = Math.max(10_000, Math.floor(leaseSeconds * 500)),
90
93
  getProvider = () => "codex",
91
- runReview = (request, provider) => runStructuredProviderReview(provider, request),
94
+ runReview = (request, provider, options) => runStructuredProviderReview(provider, request, { ...options, beforeDispatch: options?.beforeLeaseDispatch }),
95
+ getEnvironmentKey = () => null,
92
96
  beginWork = () => () => {},
93
97
  onError = () => {},
94
98
  onChange = () => {},
@@ -104,7 +108,7 @@ export function createSqmLocalReviewWorker({
104
108
  let activeJobId = null;
105
109
  let pollPromise = null;
106
110
  let closed = false;
107
- let issue = null;
111
+ let issue = null, pausedEnvironment = null, pauseReason = null;
108
112
  let issueContext = null;
109
113
  const failureDetails = new WeakMap();
110
114
  const pauses = new Map();
@@ -132,7 +136,7 @@ export function createSqmLocalReviewWorker({
132
136
  item.state = state; item.finishedAt = new Date().toISOString();
133
137
  item.result = state === "completed" ? "SQM review submitted." : "";
134
138
  item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
135
- if (failure) { item.phase = failure.stage; item.errorCode = failure.code; }
139
+ if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; item.providerDiagnostic = failure.providerDiagnostic; }
136
140
  await persist(scope, item);
137
141
  }
138
142
  onChange();
@@ -148,11 +152,11 @@ export function createSqmLocalReviewWorker({
148
152
 
149
153
  // Repair only a rejected model result, under the same live lease. Network,
150
154
  // authorization, and persistence failures must not repeat provider execution.
151
- const validatedReview = async (request, provider, code, submit, setStage) => {
155
+ const validatedReview = async (request, provider, code, submit, setStage, reviewOptions) => {
152
156
  let nextRequest = request;
153
157
  for (let attempt = 0; ; attempt += 1) {
154
158
  setStage(false);
155
- const review = await runReview(nextRequest, provider);
159
+ const review = await runReview(nextRequest, provider, reviewOptions);
156
160
  setStage(true);
157
161
  try {
158
162
  return { review, response: await submit(review) };
@@ -180,22 +184,33 @@ export function createSqmLocalReviewWorker({
180
184
  heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
181
185
  heartbeatTimer.unref?.();
182
186
  let stage = "analysis";
187
+ const reviewOptions = { providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
188
+ beforeLeaseDispatch: () => api.request({ action: "heartbeat", ...leaseBody(claim, provider) }) };
189
+ const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
190
+ ? { reported_executor_provider: review.provider || selectedProvider } : {};
183
191
  try {
184
192
  const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => api.request({
185
193
  action: "advance",
186
194
  ...leaseBody(claim, provider),
187
195
  analysis_output: analysis.output,
188
196
  executor_model: analysis.model,
189
- }), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
197
+ ...providerReport(analysis),
198
+ }), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); }, reviewOptions);
199
+ const analysisProvider = analysis.provider || provider;
200
+ const item = history.find(item => item.id === claim.job_id);
201
+ if (item) item.provider = analysisProvider;
190
202
  if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
191
203
  stage = "adjudication";
192
- const { response: completed } = await validatedReview(advanced.request, provider, "invalid_sqm_adjudication_output", adjudication => api.request({
204
+ const { review: adjudication, response: completed } = await validatedReview(advanced.request, analysisProvider, "invalid_sqm_adjudication_output", adjudication => api.request({
193
205
  action: "complete",
194
206
  ...leaseBody(claim, provider),
195
207
  analysis_output: analysis.output,
196
208
  adjudication_output: adjudication.output,
197
209
  executor_model: adjudication.model || analysis.model,
198
- }), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
210
+ ...providerReport(adjudication, analysisProvider),
211
+ ...(claim.capabilities?.reported_executor_provider === true ? { analysis_executor_provider: analysisProvider, analysis_executor_model: analysis.model } : {}),
212
+ }), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); }, reviewOptions);
213
+ if (item) item.provider = adjudication.provider || analysisProvider;
199
214
  await updateTask(claim.job_id, "completed", historyScope);
200
215
  return completed;
201
216
  } catch (error) {
@@ -221,7 +236,7 @@ export function createSqmLocalReviewWorker({
221
236
  return {
222
237
  snapshot() {
223
238
  const pause = currentPause();
224
- return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
239
+ return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
225
240
  ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
226
241
  },
227
242
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
@@ -229,9 +244,13 @@ export function createSqmLocalReviewWorker({
229
244
  const item = visibleHistory().find(item => item.key === key);
230
245
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
231
246
  },
232
- resetHistory() { if (!pollPromise) { history.splice(0); issue = null; } },
247
+ retry() { if (closed || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
248
+ resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
233
249
  poll() {
234
250
  if (closed || pollPromise) return pollPromise || Promise.resolve(null);
251
+ const environmentKey = getEnvironmentKey();
252
+ if (pausedEnvironment !== null && pausedEnvironment === environmentKey) return Promise.resolve(null);
253
+ if (pausedEnvironment !== null) { pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; }
235
254
  const provider = getProvider();
236
255
  if (provider == null) return Promise.resolve(null);
237
256
  assertProvider(provider);
@@ -273,6 +292,9 @@ export function createSqmLocalReviewWorker({
273
292
  history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
274
293
  await persist(scope, item);
275
294
  }
295
+ if (["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "provider_sign_in_required", "provider_status_unknown"].includes(error.code)) {
296
+ pausedEnvironment = environmentKey; pauseReason = error.code;
297
+ }
276
298
  issueContext = key;
277
299
  issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
278
300
  onError(error);
@@ -296,7 +318,33 @@ export function runStructuredCodexReview(request, options) {
296
318
  return runStructuredProviderReview("codex", request, options);
297
319
  }
298
320
 
299
- export async function runStructuredProviderReview(
321
+ export async function runStructuredProviderReview(provider, request, options = {}) {
322
+ const fallbackProviders = options.enableEngineFallback === true && options.providerReportingSupported === true && options.readOnlyAnalysis !== false && Array.isArray(options.allowedFallbackProviders)
323
+ ? [...new Set(options.allowedFallbackProviders.filter(candidate => ["codex", "claude"].includes(candidate) && candidate !== provider))] : [];
324
+ if (!fallbackProviders.length) return runSingleProviderReview(provider, request, options);
325
+ const deadlineAt = Date.now() + (options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS);
326
+ const attempts = [];
327
+ for (const candidate of [provider, ...fallbackProviders]) {
328
+ if (options.signal?.aborted) throw new SqmLocalReviewError("Local analysis was cancelled.", { code: "analysis_cancelled" });
329
+ if (Date.now() >= deadlineAt) throw new SqmLocalReviewError("Local analysis timed out.", { code: "analysis_timeout" });
330
+ if (candidate !== provider) {
331
+ await options.beforeDispatch?.(candidate);
332
+ const readiness = await (options.inspectProviderStatus || inspectProviderStatus)(candidate);
333
+ if (readiness.state !== "ready") throw new SqmLocalReviewError("Fallback provider is not signed in and ready.", {
334
+ code: readiness.state === "signed_out" ? "provider_sign_in_required" : "provider_status_unknown" });
335
+ }
336
+ try {
337
+ const result = await runSingleProviderReview(candidate, request, { ...options, deadlineAt,
338
+ beforeDispatch: () => options.beforeDispatch?.(candidate) });
339
+ return { ...result, provider: candidate, provider_attempts: [...attempts, { provider: candidate, code: "completed" }] };
340
+ } catch (error) {
341
+ attempts.push({ provider: candidate, code: error.code });
342
+ if (!["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable"].includes(error.code) || candidate === fallbackProviders.at(-1)) throw error;
343
+ }
344
+ }
345
+ }
346
+
347
+ async function runSingleProviderReview(
300
348
  provider,
301
349
  request,
302
350
  {
@@ -308,6 +356,7 @@ export async function runStructuredProviderReview(
308
356
  readOnlyAnalysis = true,
309
357
  terminationGraceMs = 1_000,
310
358
  beforeDispatch = async () => {},
359
+ deadlineAt,
311
360
  } = {},
312
361
  ) {
313
362
  assertProvider(provider);
@@ -330,11 +379,13 @@ export async function runStructuredProviderReview(
330
379
  "Return only JSON that strictly matches the provided output schema.",
331
380
  request.prompt,
332
381
  ].join("\n\n");
333
- const deadline = Date.now() + executionTimeoutMs;
382
+ const deadline = deadlineAt ?? Date.now() + executionTimeoutMs;
334
383
  const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
335
- let stdout;
384
+ let processResult;
336
385
  let selectedModel;
337
- for (const model of models) {
386
+ let modelIndex = 0, retryAttempt = 0, claudeTurnLimit = 1;
387
+ while (modelIndex < models.length) {
388
+ const model = models[modelIndex];
338
389
  await beforeDispatch();
339
390
  if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
340
391
  const remaining = deadline - Date.now();
@@ -342,7 +393,7 @@ export async function runStructuredProviderReview(
342
393
  // Never consume a partial/stale output file left by a rejected model.
343
394
  await rm(outputPath, { force: true });
344
395
  try {
345
- stdout = await spawnReview(
396
+ processResult = await spawnReview(
346
397
  inspection.executable,
347
398
  provider === "claude" ? [
348
399
  "--print", "--output-format", "json",
@@ -350,7 +401,7 @@ export async function runStructuredProviderReview(
350
401
  "--no-session-persistence", "--tools", "",
351
402
  "--setting-sources", "", "--settings", '{"disableAllHooks":true}',
352
403
  "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
353
- ...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", "1"] : []),
404
+ ...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", String(claudeTurnLimit)] : []),
354
405
  ] : [
355
406
  "exec",
356
407
  "--model",
@@ -369,27 +420,43 @@ export async function runStructuredProviderReview(
369
420
  "-",
370
421
  ],
371
422
  prompt,
372
- { spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model,
423
+ { spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
373
424
  terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
374
425
  );
375
426
  selectedModel = model;
376
427
  break;
377
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
+ }
378
438
  if (error.code !== "provider_model_unsupported" || model === models.at(-1)) throw error;
439
+ modelIndex += 1;
379
440
  }
380
441
  }
381
- const envelope = provider === "claude" ? JSON.parse(stdout) : null;
382
- if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
383
- if (readOnlyAnalysis && envelope?.is_error === true && providerSignInRequired(envelope.result)) {
384
- 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;
385
455
  }
386
- throw new Error("Claude Code did not complete a structured review");
456
+ output = parseProviderJson(text, processResult.diagnostic);
387
457
  }
388
- const output = provider === "claude"
389
- ? envelope?.structured_output
390
- : JSON.parse(await readFile(outputPath, "utf8"));
391
458
  if (!output || Array.isArray(output) || typeof output !== "object") {
392
- throw new Error(`${label} returned a non-object structured result`);
459
+ throw providerFailure("provider_non_object_output", processResult.diagnostic);
393
460
  }
394
461
  return {
395
462
  output,
@@ -397,7 +464,7 @@ export async function runStructuredProviderReview(
397
464
  };
398
465
  } catch (error) {
399
466
  if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
400
- throw new SqmLocalReviewError(`Local ${label} review failed: ${errorMessage(error)}`, {
467
+ throw new SqmLocalReviewError(`Local ${label} review failed before producing a structured result.`, {
401
468
  code: `${provider}_local_review_failed`,
402
469
  });
403
470
  } finally {
@@ -418,120 +485,156 @@ function codexAnalysisArguments(system) {
418
485
  '-c', `developer_instructions=${JSON.stringify(system)}`];
419
486
  }
420
487
 
421
- 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 }) {
422
489
  return new Promise((resolve, reject) => {
423
- if (signal?.aborted) return reject(new Error('Local analysis was cancelled'));
424
- const child = spawnImpl(command, args, {
425
- env: safeReviewEnvironment(environment, provider),
426
- cwd,
427
- windowsHide: true,
428
- stdio: ["pipe", "pipe", "pipe"],
429
- });
430
- let stdout = "";
431
- let stderr = "";
432
- let stderrPending = "";
433
- let settled = false;
434
- let terminalError = null;
435
- 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 });
436
500
  const halt = error => {
437
501
  if (settled || terminalError) return;
438
502
  terminalError = error;
439
- child.kill?.('SIGTERM');
503
+ child.kill?.("SIGTERM");
440
504
  if (!settled) killTimer = setTimeout(() => {
441
- child.kill?.('SIGKILL');
442
- // Do not acknowledge cancellation while an owned provider is still
443
- // shutting down. A broken process API is nevertheless bounded.
444
- 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);
445
509
  }, terminationGraceMs);
446
510
  };
447
- const cancelled = () => halt(new Error('Local analysis was cancelled'));
448
- const timer = setTimeout(() => {
449
- halt(readOnlyAnalysis ? new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' }) : new Error(`${label} timed out after ${executionTimeoutMs}ms`));
450
- }, executionTimeoutMs);
511
+ const cancelled = () => halt(providerFailure("analysis_cancelled"));
512
+ const timer = setTimeout(() => halt(providerFailure("analysis_timeout")), executionTimeoutMs);
451
513
  timer.unref?.();
452
514
  const finish = (error = null) => {
453
515
  if (settled) return;
454
516
  settled = true;
455
- clearTimeout(timer);
456
- clearTimeout(killTimer);
457
- signal?.removeEventListener('abort', cancelled);
458
- 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);
459
543
  };
460
544
  child.stdout?.setEncoding?.("utf8");
461
- child.stdout?.on?.("data", (chunk) => {
462
- 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; }
463
549
  stdout += chunk;
464
- if (readOnlyAnalysis && provider === 'codex') {
465
- for (const line of stdout.split('\n').filter(Boolean)) {
550
+ if (readOnlyAnalysis && provider === "codex") {
551
+ for (const line of stdout.split("\n").filter(Boolean)) {
466
552
  let event;
467
553
  try { event = JSON.parse(line); } catch { continue; }
468
- if (['error', 'turn.failed'].includes(event.type)) {
469
- const message = event.message ?? event.error?.message;
470
- if (providerSignInRequired(message)) {
471
- halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
472
- return;
473
- }
474
- if (providerModelUnsupported(message, model)) {
475
- halt(modelUnsupportedError(model));
476
- return;
477
- }
554
+ if (["error", "turn.failed"].includes(event.type)) {
555
+ const error = nativeFailure(event.message ?? event.error?.message);
556
+ if (error) { halt(error); return; }
478
557
  }
479
- if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
480
- halt(new Error('Read-only analysis attempted to use a tool'));
481
- 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;
482
560
  }
483
561
  }
484
562
  }
485
- if (Buffer.byteLength(stdout, "utf8") > 2 * 1024 * 1024) {
486
- halt(new Error(`${label} structured output exceeded the size limit`));
487
- }
488
563
  });
489
- child.stdin?.on?.("error", finish);
564
+ child.stdin?.on?.("error", () => halt(providerFailure("provider_process_failed")));
490
565
  child.stderr?.setEncoding?.("utf8");
491
- child.stderr?.on?.("data", (chunk) => {
492
- if (!readOnlyAnalysis) stderr = `${stderr}${chunk}`.slice(-4000);
493
- if (provider === "codex") {
494
- stderrPending = `${stderrPending}${chunk}`.slice(-8000);
495
- const lines = stderrPending.split(/\r?\n/);
496
- stderrPending = lines.pop() || "";
497
- for (const line of lines) checkStderr(line);
498
- }
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);
499
572
  });
500
- const checkStderr = line => {
501
- // Native CLI diagnostics only; never match warnings or assistant text.
502
- const match = line.match(/^\s*ERROR:\s*(.*)$/);
503
- if (!match) return;
504
- let message = match[1];
505
- try {
506
- const event = JSON.parse(message);
507
- if (event.type !== "error") return;
508
- message = event.error?.message ?? event.message;
509
- } catch { /* Older CLIs print a plain native ERROR line. */ }
510
- if (providerSignInRequired(message)) {
511
- halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
512
- } else if (providerModelUnsupported(message, model)) {
513
- halt(modelUnsupportedError(model));
514
- }
515
- };
516
- child.on("error", finish);
517
- child.on("close", (code) => {
518
- if (provider === "codex") checkStderr(stderrPending);
519
- 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") {
520
578
  let envelope;
521
- try { envelope = JSON.parse(stdout); } catch { /* Non-JSON failure remains unavailable. */ }
522
- if (envelope?.is_error === true && providerSignInRequired(envelope.result)) {
523
- finish(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
524
- 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);
525
585
  }
526
586
  }
527
- finish(terminalError || (code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`)));
587
+ finish(terminalError || (exitCode === 0 ? null : providerFailure("provider_process_failed")));
528
588
  });
529
- signal?.addEventListener('abort', cancelled, { once: true });
589
+ signal?.addEventListener("abort", cancelled, { once: true });
530
590
  if (signal?.aborted) { cancelled(); return; }
531
591
  child.stdin?.end(prompt);
532
592
  });
533
593
  }
534
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
+
535
638
  function providerModelUnsupported(message, model) {
536
639
  if (typeof message !== "string" || typeof model !== "string") return false;
537
640
  const rejection = message.trim().match(/^The ['"]([^'"]+)['"] model is not supported(?: when using Codex with a ChatGPT account)?\.?$/i);
@@ -601,7 +704,8 @@ function reviewFailure(error, stage, provider) {
601
704
  else if (stage === "analysis" || stage === "adjudication") code = `${provider}_local_review_failed`;
602
705
  }
603
706
  return { stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
604
- 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) };
605
709
  }
606
710
 
607
711
  function formatFailure(failure, failureReported = null) {
@@ -613,9 +717,11 @@ function formatFailure(failure, failureReported = null) {
613
717
  : failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."
614
718
  : failure.status === 401 ? "Reconnect your SIMY session."
615
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]
616
721
  : failure.code.endsWith("_local_review_failed") ? "The local AI app did not complete a valid structured review."
617
722
  : "Open analysis details to inspect the task state.";
618
- 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." : ""}`;
619
725
  }
620
726
 
621
727
  function analysisTaskUrl(workspaceUrl, id) {
@@ -18,10 +18,18 @@ export async function ensureSqmSession(
18
18
  startAgentImpl = startAgent,
19
19
  openAuthorizationUrlImpl = openAuthorizationUrl,
20
20
  createAgentShutdownImpl = createAgentShutdown,
21
+ expectedScope,
22
+ signal,
23
+ rejectedToken,
21
24
  } = {},
22
25
  ) {
23
26
  let session = await readSessionImpl(webOrigin);
24
- if (sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) return session;
27
+ const pinnedScope = expectedScope || sqmSessionScope(session, webOrigin);
28
+ signal?.throwIfAborted();
29
+ if (session?.token !== rejectedToken && sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
30
+ assertSqmSessionScope(session, webOrigin, pinnedScope);
31
+ return session;
32
+ }
25
33
 
26
34
  const agent = await startAgentImpl({ requestedPort: 0, webOrigin, quiet: true });
27
35
  const shutdown = createAgentShutdownImpl(agent);
@@ -35,19 +43,41 @@ export async function ensureSqmSession(
35
43
 
36
44
  const deadline = now() + timeoutMs;
37
45
  while (now() < deadline) {
46
+ signal?.throwIfAborted();
38
47
  await sleep(Math.min(500, Math.max(1, deadline - now())));
39
48
  session = await readSessionImpl(webOrigin);
40
- if (sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
49
+ if (session?.token !== rejectedToken && sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
50
+ assertSqmSessionScope(session, webOrigin, pinnedScope);
41
51
  logger.log("SIMY CLI authorization completed. Continuing SQM check.");
42
52
  return session;
43
53
  }
44
54
  }
45
- throw new Error("SIMY authorization timed out. Complete the browser sign-in and rerun the SQM command.");
55
+ const error = new Error("SIMY authorization timed out. Complete the browser sign-in and rerun the SQM command.");
56
+ error.code = "authorization_timeout";
57
+ throw error;
46
58
  } finally {
47
59
  await shutdown();
48
60
  }
49
61
  }
50
62
 
63
+ export function sqmSessionScope(session, webOrigin) {
64
+ return { account_id: session?.account_id || session?.auth_user_id || null,
65
+ organization_id: session?.organization_id || session?.org_id || null,
66
+ device_id: session?.device_id || null, api_base_url: session?.api_base_url || null,
67
+ web_origin: new URL(webOrigin).origin };
68
+ }
69
+
70
+ export function assertSqmSessionScope(session, webOrigin, expected) {
71
+ const current = sqmSessionScope(session, webOrigin);
72
+ for (const field of ["account_id", "organization_id", "device_id", "api_base_url", "web_origin"]) {
73
+ if (expected?.[field] && expected[field] !== current[field]) {
74
+ const error = new Error(`SQM authorization changed its ${field}; resume the original authorized scope.`);
75
+ error.code = "authorization_scope_changed";
76
+ throw error;
77
+ }
78
+ }
79
+ }
80
+
51
81
  function sqmSessionReady(session, webOrigin, now, isSessionValidImpl) {
52
82
  return Boolean(
53
83
  isSessionValidImpl(session, now, webOrigin) &&