@awak-app/simy-cli 0.5.72 → 0.5.73

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 (48) hide show
  1. package/README.md +84 -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 +8 -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 +68 -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/codex-app-server.js +3 -2
  36. package/src/session-supervision/index.js +16 -4
  37. package/src/sqm/bundle-store.js +11 -7
  38. package/src/sqm/claude-plugin.js +2 -0
  39. package/src/sqm/command.js +36 -4
  40. package/src/sqm/diagnostics.js +1 -1
  41. package/src/sqm/evidence-client.js +23 -7
  42. package/src/sqm/history-store.js +4 -0
  43. package/src/sqm/index.js +104 -14
  44. package/src/sqm/proof.js +3 -23
  45. package/src/sqm/request-recovery.js +82 -0
  46. package/src/sqm/review-worker.js +59 -12
  47. package/src/sqm/session.js +33 -3
  48. 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.";
@@ -452,6 +452,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
452
452
  }
453
453
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
454
454
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
455
+ else if (action === "sqm_set_engine_fallback") await agent.setSqmEngineFallback({ enabled: command.enabled, allowedProviders: command.allowedProviders });
456
+ else if (action === "sqm_retry_environment") await agent.retrySqmEnvironment();
455
457
  else if (action === "organization_cancel") agent.cancelOrganizationSelection();
456
458
  else if (action === "login" || action === "organization_select" || action === "authorization_open") {
457
459
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
@@ -498,9 +500,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
498
500
  else if (action === "codex_hooks" || action === "claude_hooks") {
499
501
  const provider = action === "codex_hooks" ? "codex" : "claude";
500
502
  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)) {
503
+ } 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
504
  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) });
505
+ 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
506
  } else if (action === "delivery_verify") {
505
507
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
506
508
  taskId: command.taskId, criterionId: command.criterionId });
@@ -516,7 +518,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
516
518
  }
517
519
  if (command.enabled) await authorizeWork(commandScope);
518
520
  await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
519
- provider: command.provider, session: command.session, enabled: command.enabled });
521
+ provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
520
522
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
521
523
  // Acknowledge the durable preference before discovery or analysis.
522
524
  emit({ type: "result", id: command.id, ok: true });
@@ -552,7 +554,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
552
554
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
553
555
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
554
556
  autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
555
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
557
+ 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
558
  await refreshSessions(true);
557
559
  } else if (action === "session_analyze") {
558
560
  const candidate = await selectedSession(command, { reuseDiscovery: true });
@@ -566,7 +568,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
566
568
  enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
567
569
  settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
568
570
  maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
569
- maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
571
+ maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling } });
570
572
  await refreshSessions(true);
571
573
  } else if (action === "session_answer") {
572
574
  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,68 @@
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
+
6
+ const writers = new Map();
7
+ const scopeDir = (root, scope) => join(root, "diagnostics", createHash("sha256").update(JSON.stringify([
8
+ scope?.webOrigin, scope?.accountId, scope?.organizationId,
9
+ ])).digest("hex"));
10
+ const safeIdentifier = value => typeof value === "string" && /^[a-z][a-z0-9_.-]{0,79}$/i.test(value) ? value : null;
11
+
12
+ // Only structured diagnostics are durable. Never retain prompts, arbitrary error
13
+ // messages, tokens, source paths, or provider stdout/stderr.
14
+ export function safeDiagnosticEvent(value, now = () => Date.now()) {
15
+ return { at: Number.isFinite(Date.parse(value?.at)) ? value.at : new Date(now()).toISOString(),
16
+ kind: ["analysis", "check", "preflight"].includes(value?.kind) ? value.kind : "check",
17
+ stage: safeIdentifier(value?.stage) || "unknown",
18
+ code: safeIdentifier(value?.code) || "failure_unclassified",
19
+ provider: ["codex", "claude"].includes(value?.provider) ? value.provider : null,
20
+ http_status: Number.isInteger(value?.http_status) && value.http_status >= 400 && value.http_status <= 599 ? value.http_status : null };
21
+ }
22
+
23
+ export function diagnosticLogger({ root = defaultSessionRoot(), scope, maxBytes = 64 * 1024, segments = 4, now } = {}) {
24
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return { record: async () => false };
25
+ const dir = scopeDir(root, scope), cacheKey = `${dir}:${maxBytes}:${segments}`;
26
+ if (writers.has(cacheKey)) return writers.get(cacheKey);
27
+ const id = randomUUID();
28
+ let queue = Promise.resolve(), total = 0, bytes = 0, segment = 0, firstAt = null;
29
+ const writer = { record(value) {
30
+ const task = queue.catch(() => {}).then(async () => {
31
+ const event = safeDiagnosticEvent(value, now), line = `${JSON.stringify(event)}\n`;
32
+ await mkdir(dir, { recursive: true, mode: 0o700 });
33
+ if (bytes + Buffer.byteLength(line) > maxBytes && bytes > 0) {
34
+ segment += 1; bytes = 0;
35
+ if (segment >= segments) await rm(join(dir, `${id}.${segment - segments}.jsonl`), { force: true });
36
+ }
37
+ await appendFile(join(dir, `${id}.${segment}.jsonl`), line, { mode: 0o600 });
38
+ bytes += Buffer.byteLength(line); total += 1; firstAt ||= event.at;
39
+ const meta = join(dir, `${id}.json`), temp = `${meta}.tmp`;
40
+ await writeFile(temp, JSON.stringify({ schema_version: 1, total_failures: total, first_failure_at: firstAt, last_failure_at: event.at }), { mode: 0o600 });
41
+ await rename(temp, meta);
42
+ return true;
43
+ });
44
+ queue = task; return task;
45
+ } };
46
+ writers.set(cacheKey, writer); return writer;
47
+ }
48
+
49
+ export async function readDiagnosticLog({ root = defaultSessionRoot(), scope, limit = 20 } = {}) {
50
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return { total_failures: 0, first_failure_at: null, recent: [] };
51
+ const dir = scopeDir(root, scope);
52
+ let names;
53
+ try { names = await readdir(dir); } catch (error) { if (error.code === "ENOENT") return { total_failures: 0, first_failure_at: null, recent: [] }; throw error; }
54
+ let total = 0, first = null; const events = [];
55
+ for (const name of names.filter(name => /^[a-f0-9-]{36}(?:\.\d+\.jsonl|\.json)$/.test(name))) {
56
+ const content = await readFile(join(dir, name), "utf8").catch(() => "");
57
+ if (name.endsWith(".json")) {
58
+ try { const meta = JSON.parse(content); if (Number.isSafeInteger(meta.total_failures) && meta.total_failures >= 0) total += meta.total_failures;
59
+ if (Number.isFinite(Date.parse(meta.first_failure_at)) && (!first || meta.first_failure_at < first)) first = meta.first_failure_at; } catch {}
60
+ } else {
61
+ for (const line of content.slice(0, 128 * 1024).split("\n")) {
62
+ try { const event = JSON.parse(line); if (Number.isFinite(Date.parse(event.at))) events.push(safeDiagnosticEvent(event)); } catch {}
63
+ }
64
+ }
65
+ }
66
+ return { total_failures: total, first_failure_at: first,
67
+ recent: events.sort((a, b) => b.at.localeCompare(a.at)).slice(0, Math.max(0, Math.min(100, limit))) };
68
+ }
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