@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
@@ -18,12 +18,15 @@ async function readCheckpointInput(input) {
18
18
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
19
19
  const [action, ...args] = argv;
20
20
  if (!action || action === '--help') {
21
+ output('Progress: --continuation-policy progress [--hard-max-minutes 1440] [--hard-max-continuations 50] [--provider-token-ceiling 15000000 (Codex only)]. Soft thresholds warn; approvals, missing usage and no verified progress still stop.');
22
+ output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
23
+ output('simy delivery extend --provider PROVIDER --session ID --task-id ID --hard-max-minutes N|--hard-max-continuations N|--provider-token-ceiling N');
21
24
  output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-tokens N (Codex only)]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
22
25
  output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original budgets.');
23
26
  return 0;
24
27
  }
25
28
  const options = {};
26
- const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'max-no-progress-minutes', 'max-tokens', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
29
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'max-no-progress-minutes', 'max-tokens', 'continuation-policy', 'hard-max-minutes', 'hard-max-continuations', 'provider-token-ceiling', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
27
30
  for (let i = 0; i < args.length; i += 2) {
28
31
  const key = args[i]?.replace(/^--/, '');
29
32
  if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
@@ -32,8 +35,9 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
32
35
  const base = { root, cwd, provider: options.provider, session: options.session };
33
36
  let result;
34
37
  if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
35
- else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'],
38
+ else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
36
39
  ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
40
+ ['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling'],
37
41
  ['max-no-progress-minutes', 'maxNoProgressMinutes'], ['max-tokens', 'maxTokens']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
38
42
  else if (action === 'handoff') result = await handoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
39
43
  else if (action === 'handoff_cancel') result = await cancelHandoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
@@ -49,15 +53,17 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
49
53
  managed = { ...managed, ...Object.fromEntries([['max-tokens', 'maxTokens'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
50
54
  .filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) };
51
55
  }
52
- result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed,
53
- ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
56
+ result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed, continuationPolicy: options['continuation-policy'],
57
+ ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
58
+ ['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
54
59
  }
55
60
  else if (action === 'checkpoint') {
56
61
  if (!options.file) throw new Error('--file is required.');
57
62
  const data = options.file === '-' ? await readCheckpointInput(input) : await readFile(options.file, 'utf8');
58
63
  if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
59
64
  result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
60
- } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], waitId: options['wait-id'], answer: options.answer, action });
65
+ } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready', 'extend', 'continuation_policy'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
66
+ limits: Object.fromEntries([['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])), waitId: options['wait-id'], answer: options.answer, action });
61
67
  else if (action === 'verify') result = await verifyDelivery({ ...base, taskId: options['task-id'], criterionId: options['criterion-id'] });
62
68
  else throw new Error('Unknown delivery command.');
63
69
  output(JSON.stringify(result, null, 2));
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { canReplanDelivery, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canChangeDeliveryContinuation, canReplanDelivery, deliveryBudgetState, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { originalSessionTarget } from '../session-supervision/original-session-target.js';
5
5
 
6
6
  export const HOOK_SETUP = Object.freeze({
@@ -32,6 +32,15 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
32
32
  provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
33
33
  objective: r.run.objective, state: r.run.state, reason: r.run.reason,
34
34
  continuations: r.run.continuations, maxContinuations: r.run.maxContinuations, deadline: r.run.deadline,
35
+ continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
36
+ startedAt: r.run.startedAt,
37
+ continuationLimits: deliveryBudgetState(r.run, now),
38
+ canChangeContinuation: canChangeDeliveryContinuation(r.run),
39
+ hardDeadline: r.run.hardDeadline || null, hardMaxContinuations: r.run.hardMaxContinuations || null,
40
+ budgetWarnings: deliveryBudgetState(r.run, now).warnings,
41
+ budgetCapability: r.run.managed?.budgetCapability || null,
42
+ outcomeProgress: r.run.managed?.outcomeProgress || null,
43
+ budgetExtensionHistory: r.run.budgetExtensionHistory || [],
35
44
  needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted', 'verification_pending'].includes(r.run.state), updatedAt: r.run.updatedAt,
36
45
  managed: Boolean(r.run.managed), taskId: r.run.managed?.taskId || null, actionId: r.run.managed?.actionId || null,
37
46
  actionDueAt: r.run.managed?.actionDueAt ?? null,
@@ -67,12 +76,12 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
67
76
  r.run.managed.resumeChannel === 'codex_app_server' && r.run.managed.autoRecovery),
68
77
  canResume: ['blocked', 'recovery_required', 'paused'].includes(r.run.state) && r.run.managed?.phase !== 'needs_clarification' &&
69
78
  !r.run.managed?.pendingApproval && !r.run.managed?.dependencyWait && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status) &&
70
- !managedBudgetReason(r.run, now) && now < r.run.deadline && r.run.continuations < r.run.maxContinuations,
79
+ !managedBudgetReason(r.run, now),
71
80
  canApprove: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
72
81
  canReject: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
73
82
  canReplan: canReplanDelivery(r.run, r.provider, now),
74
83
  canAnswer: r.run.managed?.phase === 'needs_clarification' && r.run.state === 'blocked' && Boolean(r.run.managed.understandingQuestion?.id) &&
75
- now < r.run.deadline && r.run.continuations < r.run.maxContinuations && r.run.failures < r.run.maxFailures,
84
+ !managedBudgetReason(r.run, now),
76
85
  canResolveDependency: r.run.state === 'blocked' && r.run.blocker === 'external_dependency' && Boolean(r.run.managed?.dependencyWait?.id) &&
77
86
  !managedBudgetReason(r.run, now) && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status),
78
87
  canSetPolicy: Boolean(r.run.managed?.autonomyPolicy) && !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
- import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryBudgetState, listRecords, readRecord, managedBudgetReason, managedDispatchDeadline, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
5
  import { resolveBackendExecutable } from '../backend-executable.js';
6
6
  import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
7
7
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
@@ -38,7 +38,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
38
38
  if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
39
39
  throw new Error('Managed recovery lease changed before provider dispatch.');
40
40
  }
41
- if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
41
+ if (currentTime() >= managedDispatchDeadline(current.run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
42
42
  if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
43
43
  if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
44
44
  const managed = current.run.managed;
@@ -78,12 +78,13 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
78
78
  lastDependencyResolution: run.managed.lastDependencyResolution,
79
79
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
80
80
  currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
81
- budgets: { continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures, deadline: run.deadline } };
81
+ budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
82
+ softDeadline: run.deadline, softMaxContinuations: run.maxContinuations } };
82
83
  const prompt = run.managed.autonomyPolicy ? `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nExecute ONLY currentDecision.action. Its expectedVerification and rollback apply to this turn. Stay within provider permissions. End with ONLY one JSON checkpoint. ${assessedCheckpointContract(run.managed.decision)} Do not write SIMY state directly.` : `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nStay within the original task and provider permissions. End with ONLY one JSON object: {"status":"in_progress|completed|blocked|error","summary":"...","nextStep":"...","remainingWork":"...","proposedAction":{"kind":"read|local_edit|local_test|external|destructive|scope_change|unknown","scope":"...","summary":"..."},"evidence":[],"acceptanceMet":false}. For blocked include blocker (authorization, missing_information, external_dependency), and for authorization include approval {kind,scope,summary}. Do not claim completion without checking frozen acceptance. SIMY independently verifies supported criteria. Do not write SIMY state directly. If approval or input is needed, return blocked and stop.`;
83
84
  const sandbox = run.managed.autonomyPolicy && run.managed.decision?.action.kind === 'read' ? 'read-only' : run.managed.autoRecoverySandbox;
84
85
  const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
85
86
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
86
- if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
87
+ if (currentTime() >= managedDispatchDeadline(run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
87
88
  await beforeDispatch();
88
89
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
89
90
  if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
@@ -221,10 +222,10 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
221
222
  const abort = () => controller.abort();
222
223
  if (signal?.aborted) controller.abort();
223
224
  else signal?.addEventListener('abort', abort, { once: true });
224
- const timeout = setTimeout(abort, Math.max(1, claimed.run.deadline - currentTime()));
225
+ const timeout = setTimeout(abort, Math.max(1, managedDispatchDeadline(claimed.run) - currentTime()));
225
226
  const stopWatch = watchLease(root, record.provider, record.session, current => {
226
227
  if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
227
- managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
228
+ managedBudgetReason(current.run, currentTime(), { checkProgress: false }) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
228
229
  }, () => controller.abort());
229
230
  try {
230
231
  if (!controller.signal.aborted) await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
@@ -263,7 +264,8 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
263
264
  onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
264
265
  onUsage: observed => {
265
266
  usage = observed;
266
- if (claimed.run.managed.maxTokens && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= claimed.run.managed.maxTokens) {
267
+ const tokenCeiling = deliveryBudgetState(claimed.run).tokenCeiling;
268
+ if (tokenCeiling && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= tokenCeiling) {
267
269
  tokenBudgetExceeded = true; controller.abort();
268
270
  }
269
271
  },
@@ -299,7 +301,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
299
301
  errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
300
302
  finally { clearTimeout(timeout); stopWatch(); signal?.removeEventListener('abort', abort); }
301
303
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
302
- if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
304
+ if (deliveryBudgetState(claimed.run).tokenCeiling && !usage && !result.busy && dispatched) {
303
305
  result = { ...result, usageUnavailable: true };
304
306
  }
305
307
  if (result.code === 0) {
@@ -332,7 +334,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
332
334
  live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
333
335
  live.managed.usageAvailable = true;
334
336
  live.managed.lastUsage = { ...usage, attemptId: leaseId };
335
- } else if (!result.busy && live.managed.maxTokens && dispatched) {
337
+ } else if (!result.busy && deliveryBudgetState(live).tokenCeiling && dispatched) {
336
338
  live.managed.usageAvailable = false; live.managed.usageBlocked = true;
337
339
  }
338
340
  const attempt = live.managed.executionAttempt;
@@ -357,7 +359,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
357
359
  }
358
360
  live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
359
361
  }
360
- if (tokenBudgetExceeded || live.managed.maxTokens && live.managed.tokensUsed >= live.managed.maxTokens) {
362
+ live.budgetWarnings = deliveryBudgetState(live).warnings;
363
+ const tokenCeiling = deliveryBudgetState(live).tokenCeiling;
364
+ if (tokenBudgetExceeded || tokenCeiling && live.managed.tokensUsed >= tokenCeiling) {
361
365
  live.state = 'budget_exhausted'; live.managed.autoRecovery = false;
362
366
  live.reason = 'Observed provider token budget exhausted; inspect any interrupted action in the original conversation.';
363
367
  } else if (live.managed.usageBlocked) {
@@ -52,7 +52,7 @@ function desktopActionError(action, error) {
52
52
  ? "Wait for running tasks to finish before switching organizations."
53
53
  : "Could not switch organization. Refresh organizations and try again.";
54
54
  }
55
- if (action === "sqm_set_enabled") {
55
+ if (["sqm_set_enabled", "sqm_set_engine_fallback", "sqm_retry_environment"].includes(action)) {
56
56
  return "Could not save SQM settings. Check your SIMY connection and try again.";
57
57
  }
58
58
  return "Action could not finish. Refresh the status and try again; active tasks must finish before quitting.";
@@ -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: [] }));
@@ -452,6 +453,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
452
453
  }
453
454
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
454
455
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
456
+ else if (action === "sqm_set_engine_fallback") await agent.setSqmEngineFallback({ enabled: command.enabled, allowedProviders: command.allowedProviders });
457
+ else if (action === "sqm_retry_environment") await agent.retrySqmEnvironment();
455
458
  else if (action === "organization_cancel") agent.cancelOrganizationSelection();
456
459
  else if (action === "login" || action === "organization_select" || action === "authorization_open") {
457
460
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
@@ -498,9 +501,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
498
501
  else if (action === "codex_hooks" || action === "claude_hooks") {
499
502
  const provider = action === "codex_hooks" ? "codex" : "claude";
500
503
  await (dependencies.openHookSettings || openHookSetup)(provider);
501
- } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready"].includes(action)) {
504
+ } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_extend", "delivery_continuation_policy"].includes(action)) {
502
505
  await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
503
- taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
506
+ taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, limits: command.limits, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
504
507
  } else if (action === "delivery_verify") {
505
508
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
506
509
  taskId: command.taskId, criterionId: command.criterionId });
@@ -516,7 +519,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
516
519
  }
517
520
  if (command.enabled) await authorizeWork(commandScope);
518
521
  await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
519
- provider: command.provider, session: command.session, enabled: command.enabled });
522
+ provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
520
523
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
521
524
  // Acknowledge the durable preference before discovery or analysis.
522
525
  emit({ type: "result", id: command.id, ok: true });
@@ -552,7 +555,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
552
555
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
553
556
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
554
557
  autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
555
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
558
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
556
559
  await refreshSessions(true);
557
560
  } else if (action === "session_analyze") {
558
561
  const candidate = await selectedSession(command, { reuseDiscovery: true });
@@ -566,7 +569,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
566
569
  enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
567
570
  settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
568
571
  maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
569
- maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
572
+ maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling } });
570
573
  await refreshSessions(true);
571
574
  } else if (action === "session_answer") {
572
575
  await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
@@ -1,4 +1,4 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
@@ -8,16 +8,33 @@ export function sqmSettingsPath(root, origin, session) {
8
8
  return join(root, 'sqm-settings', `${key}.json`);
9
9
  }
10
10
  export async function readSqmEnabled(root, origin, session) {
11
+ return (await readSqmSettings(root, origin, session)).enabled;
12
+ }
13
+ export async function readSqmSettings(root, origin, session) {
11
14
  const path = sqmSettingsPath(root, origin, session);
12
- if (!path) return false;
13
- try { return JSON.parse(await readFile(path, 'utf8')).enabled === true; }
14
- catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return false; throw error; }
15
+ if (!path) return { enabled: false, enable_engine_fallback: false, allowed_fallback_providers: [] };
16
+ try {
17
+ const settings = JSON.parse(await readFile(path, 'utf8'));
18
+ return { enabled: settings.enabled === true, enable_engine_fallback: settings.enable_engine_fallback === true,
19
+ allowed_fallback_providers: Array.isArray(settings.allowed_fallback_providers)
20
+ ? [...new Set(settings.allowed_fallback_providers.filter(provider => ['codex', 'claude'].includes(provider)))] : [] };
21
+ } catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return { enabled: false, enable_engine_fallback: false, allowed_fallback_providers: [] }; throw error; }
15
22
  }
16
23
  export async function writeSqmEnabled(root, origin, session, enabled) {
24
+ return writeSqmSettings(root, origin, session, { enabled });
25
+ }
26
+ export async function writeSqmEngineFallback(root, origin, session, { enabled, allowedProviders } = {}) {
27
+ if (typeof enabled !== 'boolean' || !Array.isArray(allowedProviders) || allowedProviders.some(provider => !['codex', 'claude'].includes(provider))) {
28
+ throw new Error('Select allowed local SQM fallback providers explicitly.');
29
+ }
30
+ return writeSqmSettings(root, origin, session, { enable_engine_fallback: enabled, allowed_fallback_providers: [...new Set(allowedProviders)] });
31
+ }
32
+ async function writeSqmSettings(root, origin, session, update) {
17
33
  const path = sqmSettingsPath(root, origin, session);
18
- if (!path || typeof enabled !== 'boolean') throw new Error('Sign in to an organization before enabling SQM.');
34
+ if (!path || ('enabled' in update && typeof update.enabled !== 'boolean')) throw new Error('Sign in to an organization before enabling SQM.');
35
+ const current = await readSqmSettings(root, origin, session);
19
36
  await mkdir(join(root, 'sqm-settings'), { recursive: true, mode: 0o700 });
20
- const temporary = `${path}.${process.pid}.tmp`;
21
- await writeFile(temporary, JSON.stringify({ enabled }), { mode: 0o600 });
37
+ const temporary = `${path}.${randomUUID()}.tmp`;
38
+ await writeFile(temporary, JSON.stringify({ ...current, ...update }), { mode: 0o600 });
22
39
  await rename(temporary, path);
23
40
  }
@@ -0,0 +1,65 @@
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open, lstat } from "node:fs/promises";
3
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
4
+ import path from "node:path";
5
+
6
+ // Shared SQM/Purpose identity. Inspection never creates or repairs a key.
7
+ export async function loadDeviceSigningIdentity(location, { create = false } = {}) {
8
+ const parent = path.dirname(location);
9
+ if (create) await mkdir(parent, { recursive: true, mode: 0o700 });
10
+ try {
11
+ const directory = await lstat(parent);
12
+ if (!directory.isDirectory() || directory.isSymbolicLink() || (process.platform !== "win32" && (directory.mode & 0o022) !== 0)) throw new Error("Unsafe signing key directory.");
13
+ const details = await lstat(location);
14
+ assertPrivateFile(details);
15
+ const handle = await open(location, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
16
+ try {
17
+ const opened = await handle.stat();
18
+ assertPrivateFile(opened);
19
+ if (opened.ino !== details.ino || opened.dev !== details.dev || opened.size > 4096) throw new Error("Signing key changed while opening.");
20
+ const raw = await handle.readFile("utf8");
21
+ const after = await handle.stat();
22
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error("Signing key changed while reading.");
23
+ const parsed = JSON.parse(raw);
24
+ const privateKey = createPrivateKey({ key: decode(parsed.private_key), format: "der", type: "pkcs8" });
25
+ const publicKeyDer = createPublicKey(privateKey).export({ format: "der", type: "spki" });
26
+ const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
27
+ if (privateKey.asymmetricKeyType !== "ed25519" || parsed.algorithm !== "ed25519" ||
28
+ parsed.key_id !== keyId || !decode(parsed.public_key).equals(publicKeyDer)) throw new Error("Signing key pair does not match.");
29
+ return { privateKey, publicKeyDer, keyId };
30
+ } finally { await handle.close(); }
31
+ } catch (error) {
32
+ if (error.code !== "ENOENT") throw error;
33
+ if (!create) return null;
34
+ }
35
+ const pair = generateKeyPairSync("ed25519");
36
+ const publicKeyDer = pair.publicKey.export({ format: "der", type: "spki" });
37
+ const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
38
+ let handle;
39
+ try {
40
+ handle = await open(location, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
41
+ } catch (error) {
42
+ if (error.code === "EEXIST") return loadDeviceSigningIdentity(location);
43
+ throw error;
44
+ }
45
+ try {
46
+ await handle.writeFile(`${JSON.stringify({ algorithm: "ed25519", key_id: keyId,
47
+ private_key: pair.privateKey.export({ format: "der", type: "pkcs8" }).toString("base64"),
48
+ public_key: publicKeyDer.toString("base64"), created_at: new Date().toISOString() })}\n`);
49
+ await handle.sync();
50
+ } finally { await handle.close(); }
51
+ return { privateKey: pair.privateKey, publicKeyDer, keyId };
52
+ }
53
+
54
+ function assertPrivateFile(details) {
55
+ if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1 ||
56
+ (process.platform !== "win32" && ((details.mode & 0o077) !== 0 ||
57
+ (typeof process.getuid === "function" && details.uid !== process.getuid())))) throw new Error("Signing key must be a private regular file.");
58
+ }
59
+
60
+ function decode(value) {
61
+ if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) throw new Error("Invalid signing key encoding.");
62
+ const bytes = Buffer.from(value, "base64");
63
+ if (bytes.toString("base64") !== value) throw new Error("Invalid signing key encoding.");
64
+ return bytes;
65
+ }
@@ -0,0 +1,72 @@
1
+ import { randomUUID, createHash } from "node:crypto";
2
+ import { mkdir, appendFile, rename, writeFile, readdir, readFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { defaultSessionRoot } from "./session-store.js";
5
+ import { safeProviderDiagnostic } from "./sqm/provider-diagnostics.js";
6
+
7
+ const writers = new Map();
8
+ const scopeDir = (root, scope) => join(root, "diagnostics", createHash("sha256").update(JSON.stringify([
9
+ scope?.webOrigin, scope?.accountId, scope?.organizationId,
10
+ ])).digest("hex"));
11
+ const safeIdentifier = value => typeof value === "string" && /^[a-z][a-z0-9_.-]{0,79}$/i.test(value) ? value : null;
12
+
13
+ // Only structured diagnostics are durable. Never retain prompts, arbitrary error
14
+ // messages, tokens, source paths, or provider stdout/stderr.
15
+ export function safeDiagnosticEvent(value, now = () => Date.now()) {
16
+ const providerDiagnostic = safeProviderDiagnostic(value?.provider_diagnostic);
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 } : {}),
20
+ kind: ["analysis", "check", "preflight"].includes(value?.kind) ? value.kind : "check",
21
+ stage: safeIdentifier(value?.stage) || "unknown",
22
+ code: safeIdentifier(value?.code) || "failure_unclassified",
23
+ provider: ["codex", "claude"].includes(value?.provider) ? value.provider : null,
24
+ http_status: Number.isInteger(value?.http_status) && value.http_status >= 400 && value.http_status <= 599 ? value.http_status : null };
25
+ }
26
+
27
+ export function diagnosticLogger({ root = defaultSessionRoot(), scope, maxBytes = 64 * 1024, segments = 4, now } = {}) {
28
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return { record: async () => false };
29
+ const dir = scopeDir(root, scope), cacheKey = `${dir}:${maxBytes}:${segments}`;
30
+ if (writers.has(cacheKey)) return writers.get(cacheKey);
31
+ const id = randomUUID();
32
+ let queue = Promise.resolve(), total = 0, bytes = 0, segment = 0, firstAt = null;
33
+ const writer = { record(value) {
34
+ const task = queue.catch(() => {}).then(async () => {
35
+ const event = safeDiagnosticEvent(value, now), line = `${JSON.stringify(event)}\n`;
36
+ await mkdir(dir, { recursive: true, mode: 0o700 });
37
+ if (bytes + Buffer.byteLength(line) > maxBytes && bytes > 0) {
38
+ segment += 1; bytes = 0;
39
+ if (segment >= segments) await rm(join(dir, `${id}.${segment - segments}.jsonl`), { force: true });
40
+ }
41
+ await appendFile(join(dir, `${id}.${segment}.jsonl`), line, { mode: 0o600 });
42
+ bytes += Buffer.byteLength(line); total += 1; firstAt ||= event.at;
43
+ const meta = join(dir, `${id}.json`), temp = `${meta}.tmp`;
44
+ await writeFile(temp, JSON.stringify({ schema_version: 1, total_failures: total, first_failure_at: firstAt, last_failure_at: event.at }), { mode: 0o600 });
45
+ await rename(temp, meta);
46
+ return true;
47
+ });
48
+ queue = task; return task;
49
+ } };
50
+ writers.set(cacheKey, writer); return writer;
51
+ }
52
+
53
+ export async function readDiagnosticLog({ root = defaultSessionRoot(), scope, limit = 20 } = {}) {
54
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return { total_failures: 0, first_failure_at: null, recent: [] };
55
+ const dir = scopeDir(root, scope);
56
+ let names;
57
+ try { names = await readdir(dir); } catch (error) { if (error.code === "ENOENT") return { total_failures: 0, first_failure_at: null, recent: [] }; throw error; }
58
+ let total = 0, first = null; const events = [];
59
+ for (const name of names.filter(name => /^[a-f0-9-]{36}(?:\.\d+\.jsonl|\.json)$/.test(name))) {
60
+ const content = await readFile(join(dir, name), "utf8").catch(() => "");
61
+ if (name.endsWith(".json")) {
62
+ try { const meta = JSON.parse(content); if (Number.isSafeInteger(meta.total_failures) && meta.total_failures >= 0) total += meta.total_failures;
63
+ if (Number.isFinite(Date.parse(meta.first_failure_at)) && (!first || meta.first_failure_at < first)) first = meta.first_failure_at; } catch {}
64
+ } else {
65
+ for (const line of content.slice(0, 128 * 1024).split("\n")) {
66
+ try { const event = JSON.parse(line); if (Number.isFinite(Date.parse(event.at))) events.push(safeDiagnosticEvent(event)); } catch {}
67
+ }
68
+ }
69
+ }
70
+ return { total_failures: total, first_failure_at: first,
71
+ recent: events.sort((a, b) => b.at.localeCompare(a.at)).slice(0, Math.max(0, Math.min(100, limit))) };
72
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,55 @@
1
+ import { purposeSigningKeyPath, purposeVerificationCapability } from "./purpose-local-proof.js";
2
+ import { readFile } from "node:fs/promises";
3
+ import { connectionDiscoveryPath } from "./connection-discovery.js";
4
+ import { readSession, isSessionValid, defaultSessionRoot } from "./session-store.js";
5
+ import { inspectProviderStatus } from "./desktop-status.js";
6
+ import { readDiagnosticLog } from "./diagnostic-log.js";
7
+
8
+ export async function collectDiagnostics({ webOrigin, cliVersion, root = defaultSessionRoot(),
9
+ readSessionImpl = readSession, inspectProvider = inspectProviderStatus, fetchImpl = fetch,
10
+ readFileImpl = readFile, alive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } }, now = Date.now() } = {}) {
11
+ const session = await readSessionImpl(webOrigin, root).catch(() => null);
12
+ const valid = isSessionValid(session, now, webOrigin);
13
+ let daemon = { state: "not_found", cli_version: null, version_matches: null };
14
+ try {
15
+ const record = JSON.parse(await readFileImpl(connectionDiscoveryPath(webOrigin, root), "utf8"));
16
+ if (record.web_origin !== webOrigin || !Number.isInteger(record.port) || record.port < 1 || record.port > 65535 ||
17
+ !Number.isInteger(record.pid) || record.pid <= 0) throw new Error("Invalid discovery");
18
+ if (!alive(record.pid)) daemon.state = "stale_record";
19
+ else {
20
+ const response = await fetchImpl(`http://127.0.0.1:${record.port}/v1/health`, {
21
+ headers: { Origin: webOrigin }, signal: AbortSignal.timeout(1500), redirect: "error" });
22
+ const health = response.ok ? await response.json() : null;
23
+ if (health?.web_origin !== webOrigin || health?.ok !== true) throw new Error("Unverified daemon");
24
+ const version = typeof health.cli_version === "string" && /^\d+\.\d+\.\d+$/.test(health.cli_version) ? health.cli_version : null;
25
+ daemon = { state: "reachable", cli_version: version, version_matches: version ? version === cliVersion : null,
26
+ background: health.daemon === true };
27
+ }
28
+ } catch (error) { if (error.code !== "ENOENT") daemon.state = "unverified"; }
29
+ const providers = Object.fromEntries(await Promise.all(["codex", "claude"].map(async provider => {
30
+ const result = await inspectProvider(provider).catch(() => ({ state: "unknown" }));
31
+ return [provider, { state: ["ready", "signed_out", "missing", "outdated", "unknown"].includes(result?.state) ? result.state : "unknown",
32
+ version: typeof result?.version === "string" && /^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(result.version) ? result.version : null,
33
+ model_access: "unknown" }];
34
+ })));
35
+ const scope = session?.account_id && session?.organization_id ? { webOrigin, accountId: session.account_id, organizationId: session.organization_id } : null;
36
+ const failures = await readDiagnosticLog({ root, scope }).catch(() => ({ status: "unavailable", recent: [] }));
37
+ return { schema_version: 1, cli_version: cliVersion, web_origin: webOrigin, daemon,
38
+ session: { state: valid ? "signed_in" : session ? "expired" : "signed_out",
39
+ expires_at: Number.isFinite(Date.parse(session?.expires_at)) ? session.expires_at : null },
40
+ providers, failures,
41
+ purpose_verification: await purposeVerificationCapability(purposeSigningKeyPath(root)).then(value => ({ state: value ? "available" : "not_initialized", key_id: value?.key_id || null, execution: "explicit_frozen_recipes_only" })).catch(() => ({ state: "unavailable", key_id: null, execution: "explicit_frozen_recipes_only" })),
42
+ actions: [
43
+ ...(!valid ? [{ id: "reconnect_cli", command: "simy" }] : []),
44
+ ...(["stale_record", "not_found", "unverified"].includes(daemon.state) ? [{ id: "inspect_daemon", command: "simy doctor" }] : []),
45
+ ...(daemon.version_matches === false ? [{ id: "update_when_idle", command: "simy update --check" }] : []),
46
+ ...Object.entries(providers).filter(([, provider]) => provider.state === "signed_out")
47
+ .map(([provider]) => ({ id: `login_${provider}`, command: provider === "codex" ? "codex login" : "claude auth login" })),
48
+ ] };
49
+ }
50
+
51
+ export async function runDoctor(options) {
52
+ const diagnostics = await collectDiagnostics(options);
53
+ console.log(JSON.stringify(diagnostics, null, 2));
54
+ return 0;
55
+ }
package/src/index.js CHANGED
@@ -52,6 +52,11 @@ try {
52
52
 
53
53
  if (!configuredWebOrigin) {
54
54
  // Invalid host input is reported above before update discovery or startup.
55
+ } else if (args.has("--version") || args.has("-v")) {
56
+ console.log(packageInfo.version);
57
+ } else if (dispatchArgv[0] === "doctor") {
58
+ const { runDoctor } = await import("./doctor.js");
59
+ process.exitCode = await runDoctor({ webOrigin: configuredWebOrigin, cliVersion: packageInfo.version });
55
60
  } else if (dispatchArgv[0] === "update") {
56
61
  const checkOnly = args.has("--check");
57
62
  const result = await runCliUpdateGate({
@@ -120,6 +125,8 @@ async function runCommand(installMode) {
120
125
  if (args.has("--help") || args.has("-h")) {
121
126
  console.log(`Usage:
122
127
  simy Start the local SIMY agent
128
+ simy --version Print the installed version without starting or updating
129
+ simy doctor Read-only version, connection, provider and failure diagnostics
123
130
  simy --daemon Start it in the background
124
131
  simy --no-tui Start the foreground HTTP agent without the interactive console
125
132
  simy sqm min-check Run the fast deterministic SQM diff preflight