@awak-app/simy-cli 0.7.23 → 0.7.26

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 (46) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +16 -1
  4. package/src/auto-update.js +3 -2
  5. package/src/browser-handoff.js +14 -3
  6. package/src/cli-contract.js +1 -1
  7. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
  9. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
  10. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
  11. package/src/connection-health.js +253 -0
  12. package/src/connection-overview.js +58 -0
  13. package/src/connection-presentation.js +85 -0
  14. package/src/connection-recovery.js +102 -0
  15. package/src/console/app.js +60 -6
  16. package/src/console/commands.js +3 -0
  17. package/src/console/index.js +1 -0
  18. package/src/delivery-hooks/legacy-hook-routing.js +24 -0
  19. package/src/delivery-hooks/supervisor.js +14 -14
  20. package/src/desktop-host.js +43 -8
  21. package/src/desktop-status.js +9 -0
  22. package/src/direct-executor.js +8 -2
  23. package/src/doctor.js +1 -0
  24. package/src/index.js +12 -2
  25. package/src/remote-connection-guide.js +222 -0
  26. package/src/session-supervision/analysis-manager.js +24 -4
  27. package/src/session-supervision/automatic-continuation.js +69 -18
  28. package/src/session-supervision/codex-app-server.js +52 -7
  29. package/src/session-supervision/codex-peer-opinion.js +27 -5
  30. package/src/session-supervision/consistency-auditor.js +1 -1
  31. package/src/session-supervision/continuation-analysis.js +7 -3
  32. package/src/session-supervision/decision-presentation.js +1 -1
  33. package/src/session-supervision/index.js +21 -13
  34. package/src/session-supervision/outcome-plan.js +76 -0
  35. package/src/session-supervision/purpose-instruction.js +28 -0
  36. package/src/session-supervision/recovery-frontier.js +14 -4
  37. package/src/session-supervision/remote-command-queue.js +15 -0
  38. package/src/session-supervision/remote-control.js +3 -0
  39. package/src/session-supervision/remote-hosts.js +70 -19
  40. package/src/session-supervision/remote-managed-delivery.js +94 -0
  41. package/src/session-supervision/remote-session-list.js +1 -1
  42. package/src/session-supervision/statistics.js +10 -3
  43. package/src/session-supervision/work-state.js +5 -0
  44. package/src/sqm/bundle-store.js +30 -2
  45. package/src/sqm/provider-plugin-status.js +21 -3
  46. package/src/web-origin.js +7 -0
package/README.md CHANGED
@@ -1425,3 +1425,9 @@ interrupted write or approve a pending external action. Explicit task
1425
1425
  stop/pause, provider permission prompts and unanswered user questions remain
1426
1426
  boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
1427
1427
  reversed by waking or booting. A later explicit enable is required.
1428
+
1429
+ A recovery read can inspect the one unknown operation it is bound to while
1430
+ that operation continues to block writes. Observing the result settles local
1431
+ occupancy without accepting the task. Long-running tasks transmit current
1432
+ recovery context and recent work; the full history remains in durable state
1433
+ and can be inspected through `delivery status`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.23",
3
+ "version": "0.7.26",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
40
40
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
41
41
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
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",
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 && node --check ./src/session-supervision/remote-managed-delivery.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
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",
package/src/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
1
2
  import { readLocalPhotoSource } from "./local-photos.js";
2
3
  import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
3
4
  import { observePairedNativeIdentities } from './native-provider-identity.js';
@@ -1217,7 +1218,7 @@ export async function startAgent({
1217
1218
 
1218
1219
  if (req.method === "GET" && url.pathname === "/v1/auth/browser") {
1219
1220
  if (req.headers.host !== `127.0.0.1:${port}`) { json(res, 403, { error: "Invalid local host." }); return; }
1220
- const page = browserHandoffPage();
1221
+ const page = browserHandoffPage(apiOrigin);
1221
1222
  res.writeHead(200, page.headers).end(page.html);
1222
1223
  return;
1223
1224
  }
@@ -1516,6 +1517,7 @@ export async function startAgent({
1516
1517
  const previousSession = session ?? reclaimableSession;
1517
1518
  const authorizedSession = {
1518
1519
  token: String(body.token || ""),
1520
+ email: typeof body.email === "string" && body.email.length <= 254 ? body.email : null,
1519
1521
  device_id: typeof body.device_id === "string" ? body.device_id : null,
1520
1522
  api_origin: apiOrigin,
1521
1523
  api_base_url: apiBaseUrl,
@@ -2857,8 +2859,21 @@ export async function startAgent({
2857
2859
  const { codex, claude } = deferStartupSynchronization
2858
2860
  ? providerStatuses
2859
2861
  : await readProviderStatuses();
2862
+ const overviewSession = session;
2863
+ const overviewAccess = desktopAccess.snapshot();
2864
+ let overviewSqm = { state: 'unknown' };
2865
+ if (auth.state === 'signed_in' && !pendingOrganization) {
2866
+ try { overviewSqm = await readSqmConnectionObservation({ endpoint: process.env.SIMY_SQM_BUNDLE_URL?.trim() || new URL('sqm/knowledge-bundle', resolveWebApiBaseUrl(apiOrigin, overviewSession?.api_base_url)).href,
2867
+ accountId: overviewSession?.account_id || overviewSession?.auth_user_id || overviewSession?.device_id,
2868
+ organizationId: overviewSession?.organization_id || overviewSession?.org_id }, { root: process.env.SIMY_SQM_CACHE_DIR || join(sessionRoot, 'sqm') }); } catch { /* A missing endpoint must not block the status frame. */ }
2869
+ }
2870
+ // An organization switch/sign-out while reading local metadata must not
2871
+ // publish an old tenant's identity or SQM result in a fresh frame.
2872
+ const overview = connectionOverview({ webOrigin: apiOrigin, session, access: session === overviewSession ? overviewAccess : null,
2873
+ signedIn: session === overviewSession && auth.state === 'signed_in', switching: Boolean(pendingOrganization), sqm: overviewSqm });
2860
2874
  return {
2861
2875
  schemaVersion: 1,
2876
+ connectionOverview: overview,
2862
2877
  sampledAt: new Date().toISOString(),
2863
2878
  version: CLI_VERSION,
2864
2879
  webOrigin: apiOrigin,
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { promisify } from "node:util";
17
17
 
18
+ import { daemonRestartCommand } from "./web-origin.js";
18
19
  import { openExternalUrl } from "./browser.js";
19
20
  import {
20
21
  installMacosInstallerRelease,
@@ -1094,7 +1095,7 @@ export function autoUpdateHelpText({ installMode = null, webOrigin = null } = {}
1094
1095
  return [
1095
1096
  "Recommended installation (enables verified CLI auto-update):",
1096
1097
  ` npm install -g ${CLI_PACKAGE_NAME}`,
1097
- " simy --daemon",
1098
+ ` ${daemonRestartCommand(webOrigin)}`,
1098
1099
  ].join("\n");
1099
1100
  }
1100
1101
 
@@ -1131,7 +1132,7 @@ export function updateAction(version, { installMode = "global_npm", webOrigin }
1131
1132
  return {
1132
1133
  id: "install_global_cli",
1133
1134
  label: "Install and restart SIMY",
1134
- command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && simy --daemon`,
1135
+ command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && ${daemonRestartCommand(webOrigin)}`,
1135
1136
  };
1136
1137
  }
1137
1138
 
@@ -1,3 +1,4 @@
1
+ import { normalizeWebOrigin } from "./web-origin.js";
1
2
  import { createDecipheriv, createHash } from "node:crypto";
2
3
 
3
4
  export const BROWSER_HANDOFF_TRANSPORT = "loopback_redirect_v1";
@@ -33,6 +34,14 @@ const copy = ja ? {
33
34
  };
34
35
  document.documentElement.lang = ja ? 'ja' : 'en';
35
36
  const title = document.getElementById('title'), message = document.getElementById('message');
37
+ const environment = document.getElementById('environment');
38
+ const origin = environment.dataset.origin;
39
+ const host = new URL(origin).hostname;
40
+ const label = origin === 'https://app.simy.one' ? (ja ? '本番' : 'Production')
41
+ : origin === 'https://app-dev.simy.one' ? (ja ? '開発' : 'Development')
42
+ : ['localhost', '127.0.0.1', '[::1]'].includes(host) ? (ja ? 'ローカル開発' : 'Local development')
43
+ : (ja ? 'カスタムホスト' : 'Custom host');
44
+ environment.textContent = (ja ? '接続先環境' : 'Connection environment') + ': ' + label + ' · ' + origin;
36
45
  title.textContent = copy.title; message.textContent = copy.waiting;
37
46
  const handoff = new URLSearchParams(location.hash.slice(1)).get('handoff');
38
47
  history.replaceState(null, '', location.pathname);
@@ -54,15 +63,17 @@ history.replaceState(null, '', location.pathname);
54
63
 
55
64
  // A self-contained local page: credentials never enter the URL query, HTTP
56
65
  // request, history or third-party resources. Only the encrypted fragment does.
57
- const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}body[data-state=failed] h1{color:var(--ink)}`;
66
+ const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}#environment{margin-top:16px;font-size:14px;overflow-wrap:anywhere}body[data-state=failed] h1{color:var(--ink)}`;
58
67
  const hash = value => createHash("sha256").update(value).digest("base64");
59
- export function browserHandoffPage() {
68
+ export function browserHandoffPage(webOrigin) {
69
+ const origin = normalizeWebOrigin(webOrigin);
70
+ const escapedOrigin = origin.replace(/[&<>"']/g, value => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[value]);
60
71
  return {
61
72
  headers: {
62
73
  "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store",
63
74
  "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
64
75
  "Content-Security-Policy": `default-src 'none'; script-src 'sha256-${hash(script)}'; style-src 'sha256-${hash(style)}'; connect-src 'self'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
65
76
  },
66
- html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
77
+ html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><p id="environment" data-origin="${escapedOrigin}">Connection environment: ${escapedOrigin}</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
67
78
  };
68
79
  }
@@ -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.23";
17
+ export const CLI_VERSION = "0.7.26";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -1,3 +1,48 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ // Keep every original receipt outside the mutable task record before collapsing
6
+ // repeated inspections of the same contract, stage, decision and lease. This is
7
+ // storage maintenance, never a completed action or permission to replay one.
8
+ export async function compactConsistencyOutbox(root, record) {
9
+ const managed = record.run?.managed, outbox = managed?.consistencyOutbox;
10
+ if (!Array.isArray(outbox) || outbox.length < 2) return false;
11
+ const groups = new Map();
12
+ for (const item of outbox) {
13
+ const compatible = item.stage === 'executing' && item.decisionId && item.leaseId ||
14
+ item.stage === 'inspected' && item.targetTask && item.objectiveDigest;
15
+ const key = compatible
16
+ ? JSON.stringify([item.targetTask, item.version, item.objectiveDigest, item.stage, item.decisionId, item.leaseId, item.advisory === true, item.result])
17
+ : item;
18
+ if (!groups.has(key)) groups.set(key, []);
19
+ groups.get(key).push(item);
20
+ }
21
+ if (groups.size === outbox.length) return false;
22
+ const payload = JSON.stringify({ schema: 1, kind: 'execution_handoff_snapshot', provider: record.provider,
23
+ session: record.session, runId: record.run.id, requests: outbox });
24
+ const digest = createHash('sha256').update(payload).digest('hex');
25
+ const directory = join(root, 'consistency', 'handoff-history');
26
+ const file = join(directory, `${digest}.json`), temporary = `${file}.${randomUUID()}.tmp`;
27
+ await mkdir(directory, { recursive: true, mode: 0o700 });
28
+ try {
29
+ await writeFile(temporary, payload, { mode: 0o600 });
30
+ await rename(temporary, file);
31
+ if (await readFile(file, 'utf8') !== payload) throw new Error('Handoff history could not be retained.');
32
+ } finally { await rm(temporary, { force: true }); }
33
+ managed.consistencyOutbox = [...groups.values()].map(items => {
34
+ if (items.length === 1) return items[0];
35
+ const latest = items.at(-1);
36
+ return { ...latest, findings: [...new Set(items.flatMap(item => item.findings || []))],
37
+ ...Object.fromEntries(['createdAt', 'dueAt'].flatMap(key => {
38
+ const values = items.map(item => item[key]).filter(Number.isFinite);
39
+ return values.length ? [[key, Math.min(...values)]] : [];
40
+ })),
41
+ retainedHistory: { digest, requestCount: items.reduce((count, item) => count + (item.retainedHistory?.requestCount || 1), 0) } };
42
+ });
43
+ return true;
44
+ }
45
+
1
46
  // Durable handoff deadlines concern unowned work, never a live effect lease.
2
47
  // Expiry queues diagnosis; it does not replay a write or grant permission.
3
48
  export const HANDOFF_RECEIPT_MS = 2 * 60_000;
@@ -8,7 +8,7 @@ import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, s
8
8
  import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
9
9
  import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
10
10
  import { bindControllerCompletion } from './completion-binding.mjs';
11
- import { recordHandoffStage } from './consistency-handoff.mjs';
11
+ import { recordHandoffStage, compactConsistencyOutbox } from './consistency-handoff.mjs';
12
12
  import { retainApplicableRules } from './purpose-rule-policy.mjs';
13
13
  import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
14
14
  import { workspaceIdentity } from './purpose-workspace.mjs';
@@ -116,11 +116,13 @@ export async function linkedProvider(root, provider) {
116
116
  const digest = value => createHash('sha256').update(value).digest('hex');
117
117
  const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
118
118
  const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
119
- export function canUpgradeDelivery(run) {
119
+ export function canUpgradeDelivery(run, { automatic = false } = {}) {
120
120
  return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
121
121
  Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
122
122
  (['active', 'paused', 'recovery_required'].includes(run.state) ||
123
- run.state === 'blocked' && run.blocker === 'external_dependency'));
123
+ run.state === 'blocked' && run.blocker === 'external_dependency') &&
124
+ (!automatic || run.stopReason?.actor !== 'user' && (run.state !== 'paused' || run.stopReason?.actor === 'system' ||
125
+ /^(?:Paused because no progress was confirmed in \d+ minutes\.|No verified acceptance progress in \d+ minutes\.|Paused after \d+ steps without confirmed progress\.) Review the original conversation, then resume\.$/.test(run.reason || ''))));
124
126
  }
125
127
  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]');
126
128
  export function recordPath(root, provider, session) {
@@ -275,7 +277,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
275
277
  delete criterion.check.path;
276
278
  }
277
279
  }
278
- const serialized = JSON.stringify(next);
280
+ let serialized = JSON.stringify(next);
281
+ if (Buffer.byteLength(serialized) > MAX_STATE_BYTES / 2 && await compactConsistencyOutbox(root, next))
282
+ serialized = JSON.stringify(next);
279
283
  if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
280
284
  if (serialized === previousBytes) return next;
281
285
  temporary = `${file}.${randomUUID()}.tmp`;
@@ -864,7 +868,8 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
864
868
  const fences = Object.values(gate.taskFences?.records || {}).filter(item => item.taskId === (prior.managed?.taskId || prior.id) && item.identity.provider === provider && item.identity.session === session);
865
869
  if (fences.some(item => !readTaskAdmission({ root: gateRoot, identity: item.identity, taskId: item.taskId }).canResume)) throw new Error('Existing task admission must be reconciled before enrolling follow-up work.');
866
870
  }
867
- if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run)) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
871
+ if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run, { automatic: Boolean(autoContinueEpoch) })) ||
872
+ autoContinueEpoch && hasPendingUserStop(root, previous) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
868
873
  if (previous.run && !upgrading && !following) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
869
874
  if (upgrading && (text(objective) !== previous.run.objective || executionCwd && previous.run.executionCwd && executionCwd !== previous.run.executionCwd)) throw new Error('Upgrade must preserve the original objective and task directory.');
870
875
  const managedConfig = managed && { ...(managed === true ? {} : managed),
@@ -1201,6 +1206,18 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1201
1206
  const localReconciliation = flexibleReasoning(run.managed) && ['read','local_edit','local_test'].includes(uncertainAction?.kind);
1202
1207
  if (run.managed?.executionUncertain && run.managed.recoveryDiagnosisDecisionId === lastWork?.result?.decisionId && lastWork.result.status === 'observed' && (lastWork.result.evidence?.length || lastWork.result.observationSource === 'controller_tool_result' || localReconciliation) && lastWork.action.kind === 'read') {
1203
1208
  run.managed.recoveryEvidence = { decisionId: lastWork.result.decisionId, source: lastWork.result.observationSource || 'provider_reported_read', checkedAt: now };
1209
+ const priorUnit = run.managed.purpose?.evidence?.units?.find(unit =>
1210
+ unit.id === uncertain.decisionId && unit.status === 'unknown' &&
1211
+ (!uncertain.attemptId || unit.attemptId === uncertain.attemptId));
1212
+ if (priorUnit) {
1213
+ priorUnit.status = 'observed';
1214
+ // The read already met the reconciliation guard above, including a
1215
+ // local observation reported without separate evidence references.
1216
+ // Release only local occupancy, never acceptance or external effects.
1217
+ if (['local_edit', 'local_test'].includes(priorUnit.action?.kind)) {
1218
+ priorUnit.localExecutionSettledAt = now;
1219
+ }
1220
+ }
1204
1221
  delete run.managed.executionUncertain;
1205
1222
  resolveRecoveryReceipt(run, uncertain?.attemptId);
1206
1223
  delete run.managed.recoveryDiagnosisDecisionId;
@@ -1873,14 +1890,15 @@ export async function processIdentity(pid) {
1873
1890
  export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
1874
1891
  const unavailable = (code, reason) => ({ ready: false, code, reason });
1875
1892
  if (platform === 'win32') return unavailable('platform_unsupported', 'Claude process verification is unavailable on Windows; monitoring remains available.');
1876
- if (!record) return unavailable('hook_missing', 'SIMY has not observed a session-start event for this conversation. Check the SIMY plugin and hook execution in the original Claude window.');
1893
+ if (!record) return unavailable('hook_missing', 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
1877
1894
  if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
1878
1895
  try { if (record.cwd !== await realpath(cwd)) return unavailable('workspace_mismatch', 'The observed Claude hook belongs to a different folder. Refresh this conversation.'); }
1879
1896
  catch { return unavailable('workspace_unavailable', 'The Claude conversation folder could not be verified. Check the folder and refresh.'); }
1880
1897
  if (record.hookEndedAt || record.lastEvent === 'SessionEnd') return unavailable('session_ended', 'This Claude session has ended. Return to the original conversation to continue.');
1898
+ if (!record.hookStartedAt && record.hookRecoveryBlocked) return unavailable('process_changed', 'A different Claude process reported this conversation. Reopen the original conversation to establish its hook owner.');
1881
1899
  if (!record.hookStartedAt) return unavailable(record.hookObservedAt ? 'process_unverified' : 'hook_missing', record.hookObservedAt
1882
1900
  ? 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.'
1883
- : 'SIMY has not observed a session-start event for this conversation. Check the SIMY plugin and hook execution in the original Claude window.');
1901
+ : 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
1884
1902
  const owner = record.hookSessionProcess || record.process; // Existing verified records remain compatible.
1885
1903
  if (!owner?.identity || !Number.isSafeInteger(owner.pid) || owner.pid < 2) return unavailable('process_unverified', 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.');
1886
1904
  if (record.process?.pid !== owner.pid || record.process?.identity !== owner.identity) return unavailable('process_changed', 'A different Claude process reported this conversation. Refresh the original conversation.');
@@ -2019,6 +2037,22 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2019
2037
  backgroundIdle: event === 'Stop' && Array.isArray(input.background_tasks) && input.background_tasks.length === 0 && Array.isArray(input.session_crons) && input.session_crons.length === 0 };
2020
2038
  }
2021
2039
  record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
2040
+ if (provider === 'claude' && event !== 'SessionStart' && identity && record.process &&
2041
+ (record.process.pid !== identity.pid || record.process.identity !== identity.identity)) {
2042
+ record.hookRecoveryBlocked = 'process_changed';
2043
+ }
2044
+ // A plugin loaded after the conversation opened can miss SessionStart.
2045
+ // A real prompt/stop hook proves the same exact session and provider parent
2046
+ // without fabricating a start event or adopting an existing task/owner.
2047
+ if (provider === 'claude' && ['UserPromptSubmit', 'Stop'].includes(event) &&
2048
+ !record.run && !record.hookStartedAt && !record.hookSessionProcess && !record.hookEndedAt && !record.hookRecoveryBlocked &&
2049
+ !record.parentSession && !env.SIMY_PARENT_SESSION && !env.CODEX_THREAD_ID &&
2050
+ !record.providerArchived && identity?.identity && Number.isSafeInteger(identity.pid) && identity.pid > 1 &&
2051
+ (!record.process || record.process.pid === identity.pid && record.process.identity === identity.identity)) {
2052
+ record.hookStartedAt = now;
2053
+ record.hookSessionProcess = { ...identity };
2054
+ record.hookRecovery = { source: event, recoveredAt: now, reason: 'session_start_missing' };
2055
+ }
2022
2056
  if (identity) {
2023
2057
  // Legacy records have no frozen hook owner; a new process must not inherit
2024
2058
  // an old SessionStart admission through a later prompt or stop event.
@@ -2028,6 +2062,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2028
2062
  }
2029
2063
  if (event === 'SessionStart') {
2030
2064
  record.hookObservedAt = now;
2065
+ delete record.hookRecovery;
2066
+ delete record.hookRecoveryBlocked;
2031
2067
  delete record.hookEndedAt;
2032
2068
  if (identity) { record.hookStartedAt = now; record.hookSessionProcess = { ...identity }; }
2033
2069
  else { delete record.hookStartedAt; delete record.hookSessionProcess; delete record.process; }
@@ -37,7 +37,7 @@ export function compactWorkHistory(ledger, historyLimit = 64) {
37
37
  }
38
38
 
39
39
  /** Pure projection of the existing evidence.units; never grants authority or dispatches. */
40
- export function planWorkFrontier(ledger, { prObservations = {}, stopped = false, preferredUnitId = null } = {}) {
40
+ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false, preferredUnitId = null, reconcileUnknownUnitId = null } = {}) {
41
41
  if (!ledger || !Array.isArray(ledger.units)) throw new Error('Existing work-unit ledger required.');
42
42
  const units = ledger.units;
43
43
  const ids = new Set();
@@ -88,9 +88,11 @@ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false,
88
88
  }
89
89
  if (!reason) {
90
90
  const conflict = [...holders, ...proposals.map(id => byId.get(id))].find(other => other.id !== unit.id && conflicts(unit, other) &&
91
- // A read establishes the result of an observed effect. Check all
92
- // other holders; a label must never bypass live/unknown writers.
93
- !(unit.action?.kind === 'read' && unit.writeResources.length === 0 && other.status === 'observed'));
91
+ // Inspect observed effects, or one controller-bound unknown effect.
92
+ // Keep all writers fenced and check every other resource holder.
93
+ !(unit.action?.kind === 'read' && unit.writeResources.length === 0 &&
94
+ (other.status === 'observed' || unit.id === preferredUnitId &&
95
+ other.status === 'unknown' && other.id === reconcileUnknownUnitId)));
94
96
  if (conflict) reason = `resource_conflict:${conflict.id}`;
95
97
  }
96
98
  if (!reason) { state = 'proposed'; proposals.push(unit.id); }
@@ -103,7 +105,7 @@ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false,
103
105
  const inputDigest = hash({ purposeRevision: ledger.purposeRevision, unitsOmitted: ledger.unitsOmitted,
104
106
  units: units.map(({ id, owner, status, purposeRevision, dependsOn, readResources, writeResources, waitReason, pr, action, effectReconciledRevision, localExecutionSettledAt }) =>
105
107
  ({ id, owner, status, purposeRevision, dependsOn, readResources, writeResources, waitReason, pr, action, effectReconciledRevision, localExecutionSettledAt })),
106
- prObservations, stopped, preferredUnitId });
108
+ prObservations, stopped, preferredUnitId, reconcileUnknownUnitId });
107
109
  return { version: 1, inputDigest, proposals, rows, authority: 'proposal_only' };
108
110
  }
109
111
 
@@ -66,6 +66,42 @@ export function confirmDispatch({ root = detachmentRoot(), attemptId }) {
66
66
  if (!attempt || !Object.values(state.receipts).some(item => item.attemptId === attemptId && item.resultRef)) fail('result_required');
67
67
  attempt.phase = 'confirmed'; state.revision++; atomicSave(path(root), state); return state; });
68
68
  }
69
+ // Transport settlement is independent of acceptance of the provider's report.
70
+ // Only an observed terminal event for the exact owned turn may use this path.
71
+ export function queueProviderTerminalReceipt({ root = detachmentRoot(), attemptId, providerSession, providerExecutionId, status, response = '' }) {
72
+ if (!['completed', 'failed', 'interrupted'].includes(status) || typeof response !== 'string' ||
73
+ !providerSession || !providerExecutionId) fail('terminal_evidence_invalid');
74
+ root = realpathSync(root);
75
+ const state = read(root), attempt = state.attempts[digest(attemptId)];
76
+ if (!attempt?.provenance || attempt.provenance.providerSession !== providerSession) fail('terminal_identity_mismatch');
77
+ const ref = join(root, 'update-detachment', 'provider-results', digest([attemptId, providerExecutionId]) + '.json');
78
+ const evidence = { schemaVersion: 1, kind: 'provider_terminal', attemptId, providerSession, providerExecutionId, status,
79
+ taskHash: attempt.taskHash, identityHash: attempt.identityHash, generation: attempt.generation,
80
+ responseDigest: digest(response), response: response.slice(0, 65536), truncated: response.length > 65536 };
81
+ const prior = readState(ref);
82
+ if (prior && digest(prior) !== digest(evidence)) fail('terminal_evidence_conflict');
83
+ if (!prior) atomicSave(ref, evidence);
84
+ const item = { schemaVersion: 1, kind: 'terminal_receipt', attemptId, providerExecutionId, resultRef: ref,
85
+ receiptId: `terminal:${attemptId}:${providerExecutionId}`, evidenceDigest: digest(evidence) };
86
+ atomicSave(join(root, 'update-detachment', 'intake', digest(item) + '.json'), item);
87
+ try { flushDispatchIntake(root); } catch (error) { if (error.code !== 'state_busy') throw error; }
88
+ return ref;
89
+ }
90
+ function applyTerminalReceipt(state, item, root) {
91
+ const ref = item.resultRef, attempt = state.attempts[digest(item.attemptId)];
92
+ const expected = join(resolve(root), 'update-detachment', 'provider-results', digest([item.attemptId, item.providerExecutionId]) + '.json');
93
+ if (ref !== expected || realpathSync(ref) !== ref) fail('terminal_evidence_invalid');
94
+ const evidence = readState(ref);
95
+ if (!evidence || digest(evidence) !== item.evidenceDigest || evidence.schemaVersion !== 1 || evidence.kind !== 'provider_terminal' ||
96
+ !['completed', 'failed', 'interrupted'].includes(evidence.status) || evidence.attemptId !== item.attemptId ||
97
+ evidence.providerExecutionId !== item.providerExecutionId || !attempt?.provenance ||
98
+ evidence.providerSession !== attempt.provenance.providerSession || evidence.taskHash !== attempt.taskHash ||
99
+ evidence.identityHash !== attempt.identityHash || evidence.generation !== attempt.generation) fail('terminal_identity_mismatch');
100
+ const priorTurns = Object.values(state.receipts).filter(row => row.attemptId === item.attemptId).map(row => row.providerExecutionId).filter(Boolean);
101
+ if (priorTurns.some(id => id !== item.providerExecutionId)) fail('terminal_evidence_conflict');
102
+ applyReceipt(state, item);
103
+ attempt.phase = 'confirmed';
104
+ }
69
105
  export function dispatchWithFence(owner, { taskId, attemptId = randomUUID(), identity = null, provenance = null }, send) {
70
106
  if (!owner || typeof send !== 'function' || typeof taskId !== 'string' || !taskId || typeof attemptId !== 'string' || !attemptId) fail('dispatch_identity_required');
71
107
  return withLock(path(owner.root), () => {
@@ -165,7 +201,9 @@ export function flushDispatchIntake(root = detachmentRoot()) {
165
201
  for (const name of names) {
166
202
  if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
167
203
  const item = readState(join(directory, name));
168
- if (item?.kind === 'receipt') applyReceipt(state, item); else applyIdentityIntake(state, item);
204
+ if (item?.kind === 'receipt') applyReceipt(state, item);
205
+ else if (item?.kind === 'terminal_receipt') applyTerminalReceipt(state, item, root);
206
+ else applyIdentityIntake(state, item);
169
207
  applied.push(name);
170
208
  }
171
209
  if (applied.length) { state.revision++; atomicSave(path(root), state); for (const name of applied) unlinkSync(join(directory, name)); }