@awak-app/simy-cli 0.7.21 → 0.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # SIMY CLI
2
2
 
3
+ **Copy connection URL / 接続用URLをコピー** copies the current SIMY authentication
4
+ link, including its pending organization selection. It is available while waiting
5
+ for the browser and in settings. Paste it into Chrome if automatic opening fails.
6
+ **Copy workspace URL** continues to copy only the Web workspace address.
7
+ This signs the desktop into SIMY; browser automation uses its own provider connection.
8
+
9
+ Technical coding failures retain their task and retry after a persisted 5–30 minute
10
+ cooldown. Automatic recovery preserves the original goal, usage limits, provider
11
+ connection and explicit stops. Enabling automatic continuation can also adopt an
12
+ ordinary loop waiting for an external dependency, starting with read-only inspection.
13
+ A damaged provider-link file can be repaired by **Connect SIMY** for that provider;
14
+ the original file is retained and the other provider stays disconnected.
15
+
3
16
  ### Experimental delegated approval and input bridge
4
17
 
5
18
  `simy delivery interactions proxy --policy-file /absolute/private/policy.json`
@@ -60,6 +73,22 @@ Provider login is separate from model access; unverified model access remains
60
73
  files scoped to the Web/account/organization, rotate each writer's four 64 KiB
61
74
  segments, and keep lifetime failure counts after event rotation.
62
75
 
76
+ Each AI app card separates **SIMY connection: on / stopped** from CLI, sign-in
77
+ and plugin health. Disconnecting collapses those details and explains that SIMY
78
+ monitoring and automatic continuation are stopped. Details can still be opened;
79
+ reconnecting restores the item statuses. Plugin inspection failure never changes
80
+ the connection setting or hides its disconnect action.
81
+
82
+ Connection settings show **Repair automatically** beside a failed, missing or
83
+ disabled SIMY plugin. Clicking it rechecks only that provider with a longer,
84
+ bounded timeout; a missing or disabled managed plugin is installed or enabled,
85
+ then its actual inventory is checked again. Success is shown only after that
86
+ inventory confirms the plugin is enabled. Repair progress and failure reasons
87
+ are displayed in the card; safe reason codes and elapsed times are saved in
88
+ `$SIMY_HOME/logs/desktop-host.log`. Repair preserves SIMY connection consent,
89
+ provider sign-in and native hook permissions. Project/managed settings and
90
+ conflicting plugin sources require review instead of being overwritten.
91
+
63
92
  The desktop host continues sampling connection and access while optional
64
93
  conversation metadata is pending. It retains the last completed conversation
65
94
  metadata only within the same verified scope, clears it after access or account
@@ -77,17 +106,15 @@ Both providers share one analysis deadline. A deterministic provider failure pau
77
106
  new claims and retains failed history until environment/settings change or **Retry
78
107
  analysis** is selected.
79
108
 
80
- After **6 consecutive SQM analysis failures**, this machine stops claiming new
81
- SQM analyses for the current Web/account/organization. Codex and Claude share the
82
- counter; a completed analysis resets it. Empty polls and user cancellations do
83
- not change it. The pause and partial failure count survive restart, sign-in and
84
- provider changes. A review interrupted by a host exit counts once on restart;
85
- graceful cancellation clears that unfinished marker without changing the count.
86
- Reconnecting or selecting Retry cannot clear this pause.
87
- Installing and running a **newer SIMY version** resets the counter and resumes
88
- claims automatically, subject to normal organization access and analysis settings.
89
- The native organization menu shows when an update is required. This pause does
90
- not change server analysis consent or stop ordinary implementation tasks.
109
+ After **6 consecutive SQM analysis failures**, this machine waits before its next
110
+ SQM claim for the current Web/account/organization. Codex and Claude share the
111
+ counter; a completed analysis resets it. The cooldown increases from 5 to 30
112
+ minutes and survives restart. Empty polls and reconnects do not reset failures.
113
+ Retries resume automatically while analysis and the provider connection remain
114
+ enabled. Authentication requiring the account holder remains a sign-in wait.
115
+ The server's durable recovery migration requeues the same failed job and retains
116
+ its input, history and attempt count; it is required to recover jobs previously
117
+ held by the server's lifetime retry cap.
91
118
 
92
119
  Agentic Loop preflight accepts the task's existing requirement, acceptance
93
120
  criteria, required checks and charter context alongside the repository/provider.
@@ -1004,12 +1031,15 @@ records cannot be recovered.
1004
1031
  History remains visible with local analysis off and after restarting. A saved
1005
1032
  in-flight analysis is shown as Interrupted until this client claims it again;
1006
1033
  it does not contribute to the running count. The web workspace owns retry status.
1007
- Model/provider execution failures permit one recovery claim after a five-minute
1008
- cooldown, even when the app/account identity is unchanged. If that attempt fails
1009
- again, the pause survives reconnects and host restarts until a newer SIMY version
1010
- is installed. Sign-in, local installation, SIMY access, schema and persistence
1011
- errors do not open this automatic provider recovery path. The existing six-failure
1012
- circuit still protects other analysis failures.
1034
+ Model/provider execution failures permit a recovery claim after a five-minute
1035
+ cooldown, even when the app/account identity is unchanged. Repeated provider
1036
+ failures or six consecutive analysis failures schedule further automatic recovery
1037
+ with a persisted 5–30 minute backoff. Reconnects, restarts, empty claims and
1038
+ cancelled work do not reset the failure counters or cooldown. Only a successful
1039
+ review or a newer SIMY version clears the circuit. Authentication interaction,
1040
+ local installation, SIMY access and persistence requirements remain enforced;
1041
+ waiting does not grant permission. Turning analysis off or disconnecting its
1042
+ provider still prevents new work.
1013
1043
 
1014
1044
  Only summary metadata is retained, without analysis prompts, source inputs,
1015
1045
  model responses or credentials. Historical summaries are not a replacement for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "node ./src/index.js",
15
- "test": "node --test --test-concurrency=2",
15
+ "test": "node --import ./test/isolate-simy-home.mjs --test --test-concurrency=2",
16
16
  "test:e2e:console": "node ./scripts/console-e2e.js",
17
17
  "test:e2e:work-activity": "node ./scripts/work-activity-e2e.js",
18
18
  "test:e2e:sqm-complete-loop": "node ./scripts/sqm-complete-loop-e2e.js",
@@ -42,7 +42,7 @@
42
42
  "check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js",
43
43
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
44
44
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
45
- "check:sqm": "node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
45
+ "check:sqm": "node --check ./src/sqm/provider-plugin-repair.js && node --check ./src/sqm/provider-plugin-status.js && node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
46
46
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js && node --check ./scripts/codex-plugin-runtime-e2e.js",
47
47
  "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/connection-diagnostics.js && node --check ./src/launch-authorization.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/login-shell-path.js && node --check ./src/hook-runtime.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/delivery-hooks/recovery.js && node --check ./src/delivery-hooks/supervisor-owner.js && node --check ./src/delivery-hooks/supervisor-watchdog.js && node --check ./src/delivery-hooks/supervisor-service.js && node --check ./scripts/supervisor-watchdog-live-e2e.js && node --check ./src/codex-plugins/simy/hooks/recovery-incident.mjs && node --check ./src/session-supervision/automatic-continuation.js && node --check ./src/session-supervision/stop-notifications.js",
48
48
  "check:package": "node ./scripts/check-package-contents.js",
package/src/agent.js CHANGED
@@ -18,6 +18,7 @@ import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } f
18
18
  import { readSqmEnabled, writeSqmEnabled, readSqmSettings, writeSqmEngineFallback } from "./desktop-sqm-settings.js";
19
19
  import { createServer } from "node:http";
20
20
  import { createHash, randomBytes } from "node:crypto";
21
+ import { canRecoverCodingRun, codingRecoveryDue, codingRecoveryLinksMatch } from './coding-run-automatic-recovery.js';
21
22
  import { createReadStream } from "node:fs";
22
23
  import { dirname, join, resolve } from "node:path";
23
24
 
@@ -35,6 +36,7 @@ import {
35
36
  prepareLocalCodingRunRecovery,
36
37
  reconcileLocalCodingRun,
37
38
  recoverCompletedLocalCodingRun,
39
+ recoverLocalCodingRun,
38
40
  queueLocalCodingGuidance,
39
41
  recheckLocalCodingRun,
40
42
  publishRestoredLocalCodingRun,
@@ -545,7 +547,7 @@ export async function startAgent({
545
547
  return inspection;
546
548
  };
547
549
 
548
- const requireRunProvider = async (run, { backend = run.request.backend } = {}) => {
550
+ const requireRunProvider = async (run, { backend = run.request.backend, automatic = false } = {}) => {
549
551
  const scope = await authorizeDesktopWork();
550
552
  const inspection = await inspectReadyBackend(backend, { force: true });
551
553
  await authorizeDesktopWork(scope);
@@ -555,18 +557,19 @@ export async function startAgent({
555
557
  code: `desktop_executor_${inspection.status}`,
556
558
  });
557
559
  }
558
- await bindRunAuthorization(run, scope, inspection, backend);
560
+ await bindRunAuthorization(run, scope, inspection, backend, !automatic);
559
561
  };
560
562
 
561
563
  const authorizeProviderDispatch = async (backend, scope, link) => {
562
564
  await authorizeDesktopWork(scope);
563
565
  await requireLinkedProvider(backend, link);
564
566
  };
565
- const bindRunAuthorization = async (run, scope, inspection, backend = run.request.backend) => {
567
+ const bindRunAuthorization = async (run, scope, inspection, backend = run.request.backend, rememberConnection = true) => {
566
568
  const links = await readProviderLinks(sessionRoot);
567
569
  links[backend] = inspectedLinks.get(inspection);
568
570
  await authorizeProviderDispatch(backend, scope, links[backend]);
569
571
  runAuthorizations.set(run, { scope, links });
572
+ if (rememberConnection) run.snapshot.metadata.provider_link_epochs = Object.fromEntries(Object.entries(links).map(([provider, link]) => [provider, link.epoch]));
570
573
  };
571
574
  const providerRuntimeOptions = (task, options = {}, scope, link) => {
572
575
  const guard = () => authorizeProviderDispatch(task.backend, scope, link);
@@ -868,13 +871,29 @@ export async function startAgent({
868
871
  }
869
872
  };
870
873
 
874
+ const automaticCodingRecoveries = new WeakSet();
871
875
  const monitorCodingRuns = () => {
872
876
  if (!desktopWorkAllowed()) return Promise.resolve();
873
- if (!daemon || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve();
877
+ if (!isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve();
874
878
  if (codingRunMonitorInFlight) return codingRunMonitorInFlight;
875
879
  codingRunMonitorInFlight = (async () => {
876
880
  for (const run of registry.list()) {
877
- if (!["pr_ready_for_review", "merge_ready"].includes(run.status) || run.operation || run.child) continue;
881
+ if (canRecoverCodingRun(run) && !automaticCodingRecoveries.has(run) && (!run.snapshot.metadata?.automatic_recovery || codingRecoveryDue(run))) {
882
+ automaticCodingRecoveries.add(run);
883
+ void withUpdateWork(updateManager, async () => {
884
+ if (!sameRunScope(run.session, session) || run.apiOrigin !== apiOrigin) return;
885
+ const saved = { charter: run.snapshot.charter, metadata: { ...run.snapshot.metadata } };
886
+ if (!codingRecoveryLinksMatch(saved, await readProviderLinks(sessionRoot))) return;
887
+ await requireRunProvider(run, { automatic: true });
888
+ if (!codingRecoveryLinksMatch(saved, await readProviderLinks(sessionRoot))) return;
889
+ // Each run owns its operation promise; a slow repair must not
890
+ // prevent the monitor from advancing independent tasks.
891
+ await recoverLocalCodingRun(run, codingRuntimeOptions(run));
892
+ }).catch(error => reportStartupConnectionError(error, quiet))
893
+ .finally(() => automaticCodingRecoveries.delete(run));
894
+ continue;
895
+ }
896
+ if (!daemon || !["pr_ready_for_review", "merge_ready"].includes(run.status) || run.operation || run.child) continue;
878
897
  try {
879
898
  await (dependencies.recheckCodingRun || recheckAuthorizedCodingRun)(run);
880
899
  } catch (error) {
@@ -887,7 +906,7 @@ export async function startAgent({
887
906
 
888
907
  const startCodingRunMonitor = () => {
889
908
  if (!desktopWorkAllowed()) return;
890
- if (!daemon || codingRunMonitorTimer) return;
909
+ if (codingRunMonitorTimer) return;
891
910
  codingRunMonitorTimer = setInterval(() => {
892
911
  void monitorCodingRuns().catch((error) => reportStartupConnectionError(error, quiet));
893
912
  }, dependencies.codingRunMonitorIntervalMs || CODING_RUN_MONITOR_INTERVAL_MS);
@@ -1140,6 +1159,7 @@ export async function startAgent({
1140
1159
  getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
1141
1160
  getRecoveryKey: (provider) => providerRecoveryRevisions[provider] ?? 0,
1142
1161
  recordHistory: (scope, item) => sqmHistory.record(scope, item),
1162
+ readHistory: scope => sqmHistory.items(scope),
1143
1163
  onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
1144
1164
  getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
1145
1165
  (provider) => providerLinks[provider]?.connected === true && availableCapabilities?.backends?.[provider] === true &&
@@ -2353,7 +2373,7 @@ export async function startAgent({
2353
2373
  : [],
2354
2374
  required_checks: Array.isArray(body.required_checks) ? body.required_checks : [],
2355
2375
  independent_review_required: body.independent_review_required === true,
2356
- require_human_approval: body.require_human_approval !== false,
2376
+ require_human_approval: body.require_human_approval === true,
2357
2377
  design_summary:
2358
2378
  typeof body.design_summary === "string" ? body.design_summary : "",
2359
2379
  design_review_approved_by:
@@ -3049,7 +3069,7 @@ export async function startAgent({
3049
3069
  expected_tests: [],
3050
3070
  expected_evidence: [],
3051
3071
  required_checks: [],
3052
- require_human_approval: true,
3072
+ require_human_approval: input.require_human_approval === true,
3053
3073
  must_not: [],
3054
3074
  attachments: await referenceLocalAttachmentPaths(input.attachmentPaths || []),
3055
3075
  };
@@ -14,7 +14,7 @@ import {
14
14
  } from "./durable-local-task-steps-contract.js";
15
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
16
16
 
17
- export const CLI_VERSION = "0.7.21";
17
+ export const CLI_VERSION = "0.7.23";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -118,7 +118,9 @@ const text = (value, max = 4000) => typeof value === 'string' ? value.trim().sli
118
118
  const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
119
119
  export function canUpgradeDelivery(run) {
120
120
  return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
121
- Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 && ['active', 'paused', 'recovery_required'].includes(run.state));
121
+ Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
122
+ (['active', 'paused', 'recovery_required'].includes(run.state) ||
123
+ run.state === 'blocked' && run.blocker === 'external_dependency'));
122
124
  }
123
125
  const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
124
126
  export function recordPath(root, provider, session) {
@@ -17,7 +17,7 @@ function classifyBlockers(findings) {
17
17
  const kind = ["machine", "human", "external"].includes(finding.blocker_kind)
18
18
  ? finding.blocker_kind
19
19
  : finding.repairability === "external" ? "external"
20
- : finding.repairability === "manual" ? "human" : "machine";
20
+ : "machine";
21
21
  blockers[kind].push({
22
22
  code: text(finding.code) || "OUTCOME_UNPROVEN",
23
23
  target: text(finding.target) || null,
@@ -11,9 +11,10 @@ export const detachmentRoot = () => resolve(process.env.SIMY_HOME?.trim() || joi
11
11
  export const rootFromDelivery = root => basename(resolve(root)) === 'delivery-hooks' ? dirname(resolve(root)) : resolve(root);
12
12
  const path = root => join(resolve(root), 'update-detachment', 'gate.json');
13
13
  function read(root) {
14
- const state = readState(path(root), { versions: [1, 2] }) || { schemaVersion: 1, revision: 0, generation: 0, connection: 'connected', attempts: {}, receipts: {}, events: {} };
14
+ const state = readState(path(root), { versions: [1, 2, 3] }) || { schemaVersion: 1, revision: 0, generation: 0, connection: 'connected', attempts: {}, receipts: {}, events: {} };
15
15
  if (!Number.isSafeInteger(state.generation) || !Number.isSafeInteger(state.revision) || !CONNECTION_STATES.includes(state.connection) || !['attempts', 'receipts', 'events'].every(key => isMap(state[key])) || (state.hookOwners !== undefined && !isMap(state.hookOwners)) || !Object.values(state.attempts).every(attempt => isMap(attempt) && typeof attempt.id === 'string' && Number.isSafeInteger(attempt.generation) && ['intent','sending','sent','provider_identified','result_saved','confirmed'].includes(attempt.phase))) fail('invalid_state');
16
- if ((state.schemaVersion === 2 && ![2, 3].includes(state.minimumDispatchProtocol)) || (state.minimumDispatchProtocol !== undefined && ![2, 3].includes(state.minimumDispatchProtocol))) fail('writer_protocol_incompatible');
16
+ if ((state.schemaVersion >= 2 && ![2, 3].includes(state.minimumDispatchProtocol)) || (state.minimumDispatchProtocol !== undefined && ![2, 3].includes(state.minimumDispatchProtocol))) fail('writer_protocol_incompatible');
17
+ validateRecoveryFences(state);
17
18
  validateTaskFences(state, root);
18
19
  return state;
19
20
  }
@@ -31,7 +32,7 @@ export function transitionConnection({ root = detachmentRoot(), expectedRevision
31
32
  if (state.revision !== expectedRevision) fail('revision_conflict');
32
33
  const allowed = { connected: ['detaching'], ready: ['connected', 'detaching'], detaching: ['detached', 'recovery_required'], detached: ['reconciling'], reconciling: ['ready', 'recovery_required'], recovery_required: ['reconciling', 'detaching'] };
33
34
  if (!allowed[state.connection]?.includes(connection)) fail('invalid_transition');
34
- if (connection === 'detaching') { state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL; }
35
+ if (connection === 'detaching') { state.generation++; state.schemaVersion = Math.max(2, state.schemaVersion); state.minimumDispatchProtocol = DISPATCH_PROTOCOL; }
35
36
  state.connection = connection; state.revision++;
36
37
  if (eventId) state.events[digest(eventId)] = { revision: state.revision };
37
38
  atomicSave(path(root), state); return state;
@@ -221,7 +222,7 @@ export function prepareUpgradeFence({ root = detachmentRoot(), expectedRevision,
221
222
  const state = read(root); validateWriters(state, writers);
222
223
  if (state.revision !== expectedRevision) fail('revision_conflict');
223
224
  if (!['connected', 'ready', 'recovery_required'].includes(state.connection)) fail('invalid_transition');
224
- state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL;
225
+ state.generation++; state.schemaVersion = Math.max(2, state.schemaVersion); state.minimumDispatchProtocol = DISPATCH_PROTOCOL;
225
226
  state.connection = 'detaching'; state.revision++; atomicSave(path(root), state); return state;
226
227
  });
227
228
  }
@@ -252,6 +253,12 @@ function validateTaskFences(state, root) {
252
253
  }
253
254
  }
254
255
  function assertTaskAdmissionState(state, { taskId, identity = null }) {
256
+ for (const item of Object.values(state.recoveryFences?.items || {})) {
257
+ if (item.taskHash === digest(taskId) || identity && (item.identityHash === digest(identity) ||
258
+ item.sessions.some(target => target.provider === identity.provider && target.session === identity.session))) {
259
+ fail('dispatch_history_quarantined');
260
+ }
261
+ }
255
262
  const records = state.taskFences?.records || {};
256
263
  if (!identity) {
257
264
  if (Object.values(records).some(item => item.taskId === taskId)) fail('task_admission_identity_required');
@@ -276,7 +283,9 @@ export function readTaskAdmission({ root = detachmentRoot(), identity, taskId })
276
283
  identity = storedFenceIdentity(identity, root);
277
284
  if (identity.root !== realpathSync(root)) fail('identity_root_mismatch');
278
285
  const state = read(root), record = state.taskFences?.records[fenceKey(identity, taskId)] || null;
279
- return { revision: state.revision, generation: state.generation, record, canResume: ['connected', 'ready'].includes(state.connection) && (!record || record.status === 'admitted' && record.generation === state.generation && admissionWriterMatches(state, record)) };
286
+ let historyAdmitted = true;
287
+ try { assertTaskAdmissionState(state, {taskId, identity}); } catch (error) { if (error.code !== 'dispatch_history_quarantined' && error.code !== 'task_admission_blocked') throw error; historyAdmitted = false; }
288
+ return { revision: state.revision, generation: state.generation, record, canResume: historyAdmitted && ['connected', 'ready'].includes(state.connection) && (!record || record.status === 'admitted' && record.generation === state.generation && admissionWriterMatches(state, record)) };
280
289
  }
281
290
  function checkAdmissionOwner(state, owner, identity, taskId, expectedRevision) {
282
291
  if (!owner || owner.root !== identity.root || owner.protocol !== DISPATCH_PROTOCOL || owner.generation !== state.generation) fail('owner_fenced');
@@ -295,7 +304,7 @@ export function setTaskAdmissionFence({ owner, identity, taskId, expectedRevisio
295
304
  state.taskFences ||= { version: 1, records: {} };
296
305
  state.taskFences.records[key] = { version: 1, identity, identityHash: digest(identity), taskId, taskHash: digest(taskId), generation: state.generation, revision: (previous?.revision || 0) + 1, status: 'blocked', reason: { ...reason }, createdAt: Date.now(), admission: null };
297
306
  // v2 writers validate minimumDispatchProtocol and cannot ignore taskFences.
298
- state.schemaVersion = 2; state.minimumDispatchProtocol = 3; state.revision++;
307
+ state.schemaVersion = Math.max(2, state.schemaVersion); state.minimumDispatchProtocol = 3; state.revision++;
299
308
  atomicSave(path(identity.root), state); return state;
300
309
  });
301
310
  }
@@ -324,3 +333,77 @@ export async function admitTaskDispatch({ owner, identity, taskId, expectedRevis
324
333
  atomicSave(path(identity.root), state); return state;
325
334
  } finally { release(); }
326
335
  }
336
+
337
+ // Historical evidence without a provable result is retained, never replayed or
338
+ // called completed. A schema fence makes every pre-recovery binary fail closed.
339
+ function validateRecoveryFences(state) {
340
+ if (state.schemaVersion !== 3 && state.recoveryFences === undefined) return;
341
+ const recovery = state.recoveryFences;
342
+ if (state.schemaVersion !== 3 || !recovery || recovery.version !== 1 || !isMap(recovery.items) ||
343
+ typeof recovery.auditRef !== 'string' || !/^[a-f0-9]{64}$/.test(recovery.auditDigest || '')) fail('recovery_fence_invalid');
344
+ for (const [key, item] of Object.entries(recovery.items)) {
345
+ const attempt = state.attempts[key];
346
+ if (!attempt || item.taskHash !== attempt.taskHash || item.identityHash !== attempt.identityHash ||
347
+ !/^[a-f0-9]{64}$/.test(item.attemptDigest || '') || !Array.isArray(item.sessions) ||
348
+ item.sessions.some(target => !['codex','claude'].includes(target.provider) || typeof target.session !== 'string' || !target.session)) fail('recovery_fence_invalid');
349
+ }
350
+ }
351
+ export function unresolvedDispatchAttempts(state) {
352
+ return Object.entries(state.attempts).filter(([key, attempt]) => attempt.phase !== 'confirmed' &&
353
+ state.recoveryFences?.items?.[key]?.attemptDigest !== digest(attempt)).map(([, attempt]) => attempt);
354
+ }
355
+ export function reconcileDispatchHistory({root = detachmentRoot(), expectedRevision, expectedGeneration, auditRef, auditDigest}) {
356
+ // Exclude a live update/reconnect owner as well as simultaneous dispatches.
357
+ const release = acquireLock(join(root, 'update-detachment', 'coordinator'));
358
+ try { return withLock(path(root), () => {
359
+ const state = read(root);
360
+ if (state.revision !== expectedRevision || state.generation !== expectedGeneration) fail('revision_conflict');
361
+ if (state.connection !== 'recovery_required') fail('reconciliation_required');
362
+ const auditPath = resolve(auditRef || '');
363
+ if (!auditPath.startsWith(resolve(root, 'update-detachment', 'recovery') + '/') || realpathSync(auditPath) !== auditPath) fail('recovery_audit_invalid');
364
+ const bytes = readFileSync(auditPath);
365
+ if (createHash('sha256').update(bytes).digest('hex') !== auditDigest) fail('recovery_audit_changed');
366
+ const audit = JSON.parse(bytes);
367
+ if (audit.version !== 1 || audit.gateDigest !== digest(state) || !Array.isArray(audit.items) ||
368
+ audit.items.length !== Object.keys(state.attempts).length) fail('recovery_audit_invalid');
369
+ const items = {}, seen = new Set(), settled = [];
370
+ for (const item of audit.items) {
371
+ const key = digest(item.attemptId), attempt = state.attempts[key];
372
+ if (!attempt || seen.has(key) || item.attemptDigest !== digest(attempt)) fail('recovery_attempt_changed');
373
+ seen.add(key);
374
+ if (item.disposition === 'already_confirmed' && attempt.phase === 'confirmed') continue;
375
+ if (item.disposition === 'saved_terminal_result') {
376
+ const evidence = item.evidence;
377
+ if (!evidence || !attempt.provenance || evidence.ref !== attempt.provenance.recordRef ||
378
+ !Object.values(state.receipts).some(receipt => receipt.attemptId === attempt.id && receipt.generation === attempt.generation && receipt.resultRef === evidence.ref)) fail('recovery_result_unbound');
379
+ if (!resolve(evidence.ref).startsWith(resolve(root, 'direct-tasks') + '/') || realpathSync(evidence.ref) !== evidence.ref) fail('recovery_result_scope');
380
+ const source = readFileSync(evidence.ref);
381
+ if (createHash('sha256').update(source).digest('hex') !== evidence.sha256) fail('recovery_result_changed');
382
+ const result = JSON.parse(source), match = /^(.*):provider:([1-9][0-9]*)$/.exec(attempt.id);
383
+ if (!match || result.id !== match[1] || digest(result.id) !== attempt.taskHash ||
384
+ result.id !== attempt.provenance.taskId || result.invocation_count !== Number(match[2]) ||
385
+ !['succeeded','failed','stopped'].includes(result.status)) fail('recovery_result_unbound');
386
+ settled.push(attempt);
387
+ } else if (item.disposition === 'retain_blocked_unknown') {
388
+ const sessions = [];
389
+ if (attempt.provenance?.providerSession) sessions.push({provider:attempt.provenance.context.provider,session:attempt.provenance.providerSession});
390
+ // Recover the parent identity only from its original canonical record.
391
+ const ref = attempt.provenance?.recordRef;
392
+ if (ref?.startsWith(resolve(root, 'delivery-hooks') + '/')) {
393
+ try { const record = JSON.parse(readFileSync(ref));
394
+ if (record.run?.managed?.taskId === attempt.provenance.taskId && record.provider === attempt.provenance.context.provider &&
395
+ basename(ref) === createHash('sha256').update(record.provider + '\0' + record.session).digest('hex') + '.json') sessions.push({provider:record.provider,session:record.session});
396
+ } catch { /* Missing parent never authorizes an inferred identity. */ }
397
+ }
398
+ items[key] = {taskHash:attempt.taskHash,identityHash:attempt.identityHash,attemptDigest:digest(attempt),sessions};
399
+ } else fail('recovery_disposition_invalid');
400
+ }
401
+ for (const attempt of settled) attempt.phase = 'confirmed';
402
+ state.schemaVersion = 3; state.minimumDispatchProtocol = 3;
403
+ state.generation++; state.revision++;
404
+ state.recoveryFences = {version:1,auditRef:auditPath,auditDigest,items};
405
+ validateRecoveryFences(state);
406
+ atomicSave(path(root), state);
407
+ return {settled:settled.length,blocked:Object.keys(items).length,revision:state.revision,generation:state.generation,replayAttempts:false};
408
+ }); } finally { release(); }
409
+ }
@@ -0,0 +1,40 @@
1
+ import { budgetState } from './orchestrator/budget.js';
2
+
3
+ const BASE_DELAY = 5 * 60_000;
4
+ const MAX_DELAY = 30 * 60_000;
5
+ const MACHINE_STOPS = new Set(['outcome_progress_stalled', 'retry_circuit_open']);
6
+
7
+ export function canRecoverCodingRun(run) {
8
+ const snapshot = run.snapshot;
9
+ return run.status === 'blocked' && !run.stopRequested &&
10
+ !['paused', 'stopped', 'stopping'].includes(run.controlState) &&
11
+ !snapshot.metadata?.local_stop_requested && !run.operation && !run.child &&
12
+ snapshot.final_audit?.requires_human === false &&
13
+ MACHINE_STOPS.has(snapshot.events?.at(-1)?.detail?.code) &&
14
+ !budgetState(snapshot).exhausted;
15
+ }
16
+
17
+ export function scheduleCodingRecovery(snapshot, now = Date.now()) {
18
+ const count = (snapshot.metadata?.automatic_recovery?.count || 0) + 1;
19
+ snapshot.metadata ??= {};
20
+ snapshot.metadata.automatic_recovery = {
21
+ count,
22
+ next_retry_at: now + Math.min(MAX_DELAY, BASE_DELAY * 2 ** Math.min(count - 1, 3)),
23
+ };
24
+ }
25
+
26
+ export function codingRecoveryDue(run, now = Date.now()) {
27
+ return canRecoverCodingRun(run) &&
28
+ Number.isFinite(run.snapshot.metadata?.automatic_recovery?.next_retry_at) &&
29
+ now >= run.snapshot.metadata.automatic_recovery.next_retry_at;
30
+ }
31
+
32
+ export function codingRecoveryLinksMatch(snapshot, links) {
33
+ return [snapshot.charter.backend, snapshot.charter.audit_backend].filter(Boolean).every(provider => {
34
+ const link = links[provider];
35
+ const saved = snapshot.metadata?.provider_link_epochs;
36
+ return link?.connected === true && (saved && Object.hasOwn(saved, provider)
37
+ ? saved[provider] === link.epoch
38
+ : link.epoch === null && link.requiresConsent === false);
39
+ });
40
+ }
@@ -1147,6 +1147,7 @@ function statusLabel(run) {
1147
1147
  if (run.controlState === "stopping") return "Stopping";
1148
1148
  if (run.controlState === "stopped" || run.status === "stopped") return "Stopped";
1149
1149
  if (run.status === "pr_ready_for_review") return "PR ready · Review needed";
1150
+ if (run.status === 'blocked' && run.snapshot?.final_audit?.requires_human === false) return 'Blocked';
1150
1151
  if (run.controlState === "waiting_human" || run.status === "waiting_human") {
1151
1152
  return "Waiting for you";
1152
1153
  }
@@ -1181,6 +1182,10 @@ function nextActionForRun(run, repositoryMissing = false) {
1181
1182
  text: "Review the final evidence and merge the pull request when ready.",
1182
1183
  };
1183
1184
  }
1185
+ const latestRecovery = [...(run.snapshot?.events || [])].reverse().find(event => event.detail?.recovery)?.detail.recovery;
1186
+ if (run.status === "blocked" && ['machine_recovery_pending', 'machine_recovery_exhausted'].includes(latestRecovery?.reason_code) && latestRecovery.requires_user_action === false) {
1187
+ return { tone: latestRecovery.reason_code === 'machine_recovery_pending' ? 'yellow' : 'red', text: latestRecovery.user_message };
1188
+ }
1184
1189
  if (run.controlState === "waiting_human" || run.status === "waiting_human") {
1185
1190
  return { tone: "yellow", text: "Press i, type your guidance or approval, then press Enter." };
1186
1191
  }
@@ -45,6 +45,18 @@ function parseDeliveryOptions(args, allowed) {
45
45
 
46
46
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), webOrigin, input = process.stdin, output = console.log } = {}) {
47
47
  const [action, ...args] = argv;
48
+ if (action === 'history-recovery') {
49
+ const [operation,...flags] = args;
50
+ const options = parseDeliveryOptions(flags,new Set(['audit','digest']));
51
+ const recovery = await import('../update-detachment/history-recovery.js');
52
+ const parentRoot = dirname(root);
53
+ let result;
54
+ if (operation === 'plan') result = await recovery.planDispatchHistoryRecovery({root:parentRoot});
55
+ else if (operation === 'apply') result = await recovery.applyDispatchHistoryRecovery({root:parentRoot,auditRef:options.audit,auditDigest:options.digest});
56
+ else if (operation === 'reconnect') result = await recovery.reconnectRecoveredHistory({root:parentRoot,origin:webOrigin});
57
+ else throw new Error('Use simy delivery history-recovery plan|apply --audit PATH --digest SHA256|reconnect.');
58
+ output(JSON.stringify(result,null,2)); return 0;
59
+ }
48
60
  if (action === 'auto') {
49
61
  const [operation, ...flags] = args;
50
62
  if (!['on', 'off', 'status'].includes(operation)) throw new Error('Use simy delivery auto on|off|status --provider codex|claude in the project directory.');
@@ -1,5 +1,5 @@
1
1
  import { captureDispatchProvenance } from '../update-detachment/provenance.js';
2
- import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, confirmDispatch, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
3
3
  import { bindCompletionArtifacts } from '../session-supervision/completion-artifacts.js';
4
4
  import { recoveryWorkProposal, admitRecoveryWork } from '../session-supervision/recovery-frontier.js';
5
5
  import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
@@ -480,7 +480,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
480
480
  else await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
481
481
  checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, toolEvidence: result.toolEvidence || [], sourceContext: result.sourceContext, controllerEvidence: result.controllerEvidence, now: Date.now() });
482
482
  if (result.dispatchAttemptId) queueDispatchReceipt({ root: rootFromDelivery(root), attemptId: result.dispatchAttemptId, receiptId: `checkpoint:${leaseId}`, resultRef: `delivery-record:${record.provider}:${record.session}:${leaseId}` });
483
- await updateRecord(root, record.provider, record.session, current => {
483
+ const checkpointed = await updateRecord(root, record.provider, record.session, current => {
484
484
  if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
485
485
  current.run.managed.executionAttempt.phase = 'checkpointed';
486
486
  current.run.managed.executionAttempt.checkpointRevision = current.run.revision;
@@ -501,6 +501,14 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
501
501
  }
502
502
  return current;
503
503
  });
504
+ // Settle this successful native turn while its exact durable checkpoint
505
+ // is still current. A later worker replaces executionAttempt, so waiting
506
+ // until an update boundary would lose the original result association.
507
+ const savedAttempt = checkpointed?.run?.managed?.executionAttempt;
508
+ if (result.dispatchAttemptId && checkpointed?.run?.id === claimed.run.id &&
509
+ savedAttempt?.id === leaseId && result.dispatchAttemptId === leaseId &&
510
+ savedAttempt.phase === 'checkpointed' && savedAttempt.checkpointRevision === checkpointed.run.revision)
511
+ confirmDispatch({ root: rootFromDelivery(root), attemptId: result.dispatchAttemptId });
504
512
  } catch (error) { result = { ...result, code: -1, errorCode: 'checkpoint_rejected', diagnostic: safeCheckpointDiagnostic(error) }; }
505
513
  }
506
514
  await updateRecordAfterControl(root, record.provider, record.session, async current => {
@@ -657,8 +665,10 @@ export function createManagedSupervisor(options) {
657
665
  // A queue entry is a wakeup, not authority or a saved action. Re-read
658
666
  // consent, revisions and task identity at admission and each claim.
659
667
  const readRecords = async () => {
660
- const rows = options.readRecords ? await options.readRecords()
661
- : [await readRecord(options.root, record.provider, record.session)];
668
+ const rows = options.readCurrentRecord
669
+ ? [await options.readCurrentRecord(record.provider, record.session)]
670
+ : options.readRecords ? await options.readRecords()
671
+ : [await readRecord(options.root, record.provider, record.session)];
662
672
  return rows.filter(row => row?.provider === record.provider && row.session === record.session &&
663
673
  row.run?.id === record.run.id && row.run?.managed?.taskId === record.run.managed.taskId);
664
674
  };
@@ -7,7 +7,7 @@ export function validDesktopAccessPath(path, slug) {
7
7
  return path === expected || path === `${expected}/`;
8
8
  }
9
9
  export const desktopAccessReady = value => value?.state === 'ready';
10
- const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
10
+ const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'authorization_copy', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
11
  'check_update', 'auto_update_set_enabled', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
12
  'provider_disconnect', 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
@@ -0,0 +1,23 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ // Pass private links over stdin, never in process arguments, status, or logs.
4
+ export function copyDesktopText(text, { platform = process.platform, spawnImpl = spawn } = {}) {
5
+ const command = platform === 'darwin' ? ['pbcopy', []] : platform === 'win32'
6
+ ? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '$inputText = [Console]::In.ReadToEnd(); Set-Clipboard -Value $inputText -ErrorAction Stop']]
7
+ : ['xclip', ['-selection', 'clipboard']];
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawnImpl(command[0], command[1], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
10
+ const timer = setTimeout(() => { child.kill(); finish(false); }, 5000);
11
+ let settled = false;
12
+ function finish(ok) {
13
+ if (settled) return;
14
+ settled = true; clearTimeout(timer);
15
+ if (ok) resolve();
16
+ else { child.kill(); reject(Object.assign(new Error('Clipboard unavailable.'), { code: 'desktop_clipboard_unavailable' })); }
17
+ }
18
+ child.on('error', () => finish(false));
19
+ child.on('close', code => finish(code === 0));
20
+ child.stdin.on('error', () => finish(false));
21
+ child.stdin.end(text);
22
+ });
23
+ }