@awak-app/simy-cli 0.7.25 → 0.7.27

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 (43) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +22 -0
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +262 -0
  6. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -11
  8. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
  9. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
  10. package/src/connection-health.js +253 -0
  11. package/src/connection-presentation.js +85 -0
  12. package/src/connection-recovery.js +102 -0
  13. package/src/console/app.js +60 -6
  14. package/src/console/commands.js +3 -0
  15. package/src/console/index.js +1 -0
  16. package/src/delivery-hooks/command.js +8 -0
  17. package/src/delivery-hooks/legacy-hook-routing.js +24 -0
  18. package/src/delivery-hooks/supervisor.js +14 -14
  19. package/src/desktop-host.js +49 -10
  20. package/src/direct-executor.js +8 -2
  21. package/src/doctor.js +1 -0
  22. package/src/index.js +12 -2
  23. package/src/remote-connection-guide.js +222 -0
  24. package/src/session-supervision/analysis-manager.js +24 -4
  25. package/src/session-supervision/automatic-continuation.js +69 -18
  26. package/src/session-supervision/codex-app-server.js +52 -7
  27. package/src/session-supervision/codex-peer-opinion.js +27 -5
  28. package/src/session-supervision/consistency-auditor.js +1 -1
  29. package/src/session-supervision/continuation-analysis.js +7 -3
  30. package/src/session-supervision/decision-presentation.js +1 -1
  31. package/src/session-supervision/index.js +23 -15
  32. package/src/session-supervision/outcome-plan.js +76 -0
  33. package/src/session-supervision/purpose-instruction.js +28 -0
  34. package/src/session-supervision/recovery-frontier.js +14 -4
  35. package/src/session-supervision/remote-command-queue.js +15 -0
  36. package/src/session-supervision/remote-control.js +3 -0
  37. package/src/session-supervision/remote-hosts.js +70 -19
  38. package/src/session-supervision/remote-managed-delivery.js +94 -0
  39. package/src/session-supervision/remote-session-list.js +1 -1
  40. package/src/session-supervision/statistics.js +10 -3
  41. package/src/session-supervision/stop-notifications.js +26 -10
  42. package/src/session-supervision/work-state.js +5 -0
  43. package/src/sqm/claude-plugin.js +8 -1
package/README.md CHANGED
@@ -345,6 +345,8 @@ from the same maintained source as Codex; only provider terminology and hook/man
345
345
  format differ. Installing source files does not imply that either provider has enabled
346
346
  the plugin. Existing Claude settings, unrelated marketplaces, and plugins are preserved.
347
347
 
348
+ State-only feature-improvement collection ships with the generated Claude plugin, but uploads require the paired desktop CLI runtime to refresh device consent and drain its local outbox. Without that paired authenticated runtime, the plugin remains default-off and does not enable Auto-continue.
349
+
348
350
  After that first installation, the daemon checks installed workflows at startup
349
351
  and every hour, including after a CLI automatic upgrade. It updates only existing
350
352
  platform installations and refreshes an already enabled SIMY Codex plugin through
@@ -1425,3 +1427,9 @@ interrupted write or approve a pending external action. Explicit task
1425
1427
  stop/pause, provider permission prompts and unanswered user questions remain
1426
1428
  boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
1427
1429
  reversed by waking or booting. A later explicit enable is required.
1430
+
1431
+ A recovery read can inspect the one unknown operation it is bound to while
1432
+ that operation continues to block writes. Observing the result settles local
1433
+ occupancy without accepting the task. Long-running tasks transmit current
1434
+ recovery context and recent work; the full history remains in durable state
1435
+ 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.25",
3
+ "version": "0.7.27",
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,4 +1,5 @@
1
1
  import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
2
+ import { flushObservability, observeHeartbeat, syncObservabilityConsent } from './cli-observability.js';
2
3
  import { readLocalPhotoSource } from "./local-photos.js";
3
4
  import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
4
5
  import { observePairedNativeIdentities } from './native-provider-identity.js';
@@ -939,6 +940,27 @@ export async function startAgent({
939
940
  deviceHeartbeatSucceeded = true;
940
941
  connectionStatus.success();
941
942
  notifyDesktopStatusChange();
943
+ // Delivery state persistence writes the bounded local outbox. Draining
944
+ // it here reuses the authenticated existing runtime; it never creates
945
+ // a daemon, changes Auto-continue, or blocks the device heartbeat.
946
+ const observabilityPaired = typeof requestedSession.account_id === 'string' && requestedSession.account_id.length > 0 &&
947
+ typeof requestedSession.device_id === 'string' && requestedSession.device_id.length > 0;
948
+ if (observabilityPaired) {
949
+ void syncObservabilityConsent({
950
+ root: sessionRoot,
951
+ apiOrigin,
952
+ apiBaseUrl: requestedSession.api_base_url,
953
+ token: requestedSession.token,
954
+ actor: { accountId: requestedSession.account_id, deviceId: requestedSession.device_id, organizationId: requestedSession.organization_id },
955
+ fetchImpl: dependencies.fetch || globalThis.fetch,
956
+ }).then(() => observeHeartbeat(sessionRoot)).then(() => flushObservability({
957
+ root: sessionRoot,
958
+ apiOrigin,
959
+ apiBaseUrl: requestedSession.api_base_url,
960
+ token: requestedSession.token,
961
+ fetchImpl: dependencies.fetch || globalThis.fetch,
962
+ })).catch(() => {});
963
+ }
942
964
  // Register the device immediately; provider subprocesses must not delay
943
965
  // browser authorization. Publish verified readiness after their probe.
944
966
  const publishVerifiedProviders = async () => {
@@ -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.25";
17
+ export const CLI_VERSION = "0.7.27";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -0,0 +1,262 @@
1
+ // State-only feature-improvement transport. It never imports provider clients or
2
+ // serializes conversation content, paths, titles, logs, or credentials.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { link, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { webApiHeaders, webApiUrl } from './web-api.js';
9
+ // Keep this leaf module package-safe: hooks load it before the full CLI contract.
10
+ const CLI_VERSION = '0.7.27';
11
+
12
+ const STORE = 'cli-observability-state.json';
13
+ const LEGACY_CONFIG = 'cli-observability.json';
14
+ const LEGACY_OUTBOX = 'cli-observability-outbox.json';
15
+ const MAX_BYTES = 256 * 1024, MAX_AGE = 7 * 24 * 60 * 60 * 1000, MAX_FLUSH = 50;
16
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
+ const EVENT_TYPES = new Set(['settings_changed', 'execution_requested', 'execution_started', 'execution_ended', 'result_confirmed', 'observation_gap', 'heartbeat']);
18
+ const EXECUTION = new Set(['idle', 'start_requested', 'started', 'ended', 'unknown']);
19
+ const RESULTS = new Set(['success', 'failed', 'waiting_human', 'reported_done', 'unknown']);
20
+ const REASONS = new Set(['cancelled', 'needs_user_input', 'timeout', 'provider_error', 'transport_error', 'runtime_recovery', 'queue_overflow', 'sequence_gap', 'clock_skew', 'session_expired']);
21
+ const keyFor = (provider, session) => `${provider}:${session}`;
22
+ const file = (root, name) => join(root, name);
23
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
24
+
25
+ async function processIdentity(pid) {
26
+ if (!Number.isSafeInteger(pid) || pid < 2 || process.platform === 'win32') return null;
27
+ try { const { stdout } = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'lstart=,comm='], { timeout: 500, maxBuffer: 2048 }); return stdout.trim() || ''; }
28
+ catch (error) { return error.code === 1 ? '' : null; }
29
+ }
30
+ function environmentFromOrigin(origin) {
31
+ try {
32
+ const url = new URL(origin);
33
+ if (url.protocol !== 'https:' || url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null;
34
+ return url.hostname === 'app-dev.simy.one' ? 'dev' : url.hostname === 'app.simy.one' ? 'prod' : null;
35
+ } catch { return null; }
36
+ }
37
+ async function withStoreLock(root, operation) {
38
+ const lock = file(root, '.cli-observability.lock');
39
+ const owner = {
40
+ pid: process.pid,
41
+ identity: await processIdentity(process.pid),
42
+ token: randomUUID(),
43
+ };
44
+ await mkdir(root, { recursive: true, mode: 0o700 });
45
+ const candidate = `${lock}.${owner.token}.candidate`;
46
+ await writeFile(candidate, JSON.stringify(owner), { mode: 0o600, flag: 'wx' });
47
+ try {
48
+ for (let i = 0; i < 40; i++) {
49
+ try {
50
+ // link(2) publishes an already-complete owner file atomically.
51
+ await link(candidate, lock);
52
+ try {
53
+ return await operation();
54
+ } finally {
55
+ try {
56
+ const held = JSON.parse(await readFile(lock, 'utf8'));
57
+ if (held.token === owner.token) await rm(lock, { force: true });
58
+ } catch {}
59
+ }
60
+ } catch (error) {
61
+ if (error.code !== 'EEXIST') throw error;
62
+ let prior;
63
+ try {
64
+ prior = JSON.parse(await readFile(lock, 'utf8'));
65
+ const current = await processIdentity(prior?.pid);
66
+ if (current !== '' && !(current && prior?.identity && current !== prior.identity)) {
67
+ await sleep(10 + Math.floor(Math.random() * 10));
68
+ continue;
69
+ }
70
+ // Move the exact stale inode away. This avoids a stale contender
71
+ // deleting a replacement lock published by another process.
72
+ const reclaimed = `${lock}.${owner.token}.reclaimed`;
73
+ await rename(lock, reclaimed);
74
+ const moved = JSON.parse(await readFile(reclaimed, 'utf8'));
75
+ if (moved?.token !== prior?.token) {
76
+ // A release/reacquire raced us. Restore only if nobody has already
77
+ // published a replacement; never remove a lock we do not own.
78
+ try { await link(reclaimed, lock); } catch {}
79
+ }
80
+ await rm(reclaimed, { force: true });
81
+ } catch {}
82
+ await sleep(10 + Math.floor(Math.random() * 10));
83
+ }
84
+ }
85
+ } finally {
86
+ await rm(candidate, { force: true });
87
+ }
88
+ throw new Error('Observability store is busy.');
89
+ }
90
+ async function readJson(path, fallback) { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; } }
91
+ async function saveJson(path, value) { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const tmp = `${path}.${randomUUID()}.tmp`; try { await writeFile(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 }); await rename(tmp, path); } finally { await rm(tmp, { force: true }); } }
92
+ function blankConfig() { return { schemaVersion: 1, enabled: false, consentGeneration: null, environment: null, actor: null, conversations: {}, retry: { attempts: 0, at: 0 }, lost: false }; }
93
+ function blankState() { return { schemaVersion: 1, config: blankConfig(), outbox: { schemaVersion: 1, events: [] } }; }
94
+ function validConversation(value) { return value && ['codex', 'claude'].includes(value.provider) && typeof value.session === 'string' && /^[A-Za-z0-9_-]{1,256}$/.test(value.session) && UUID.test(String(value.conversationRef)) && ['dev', 'prod'].includes(value.environment); }
95
+ function validGeneration(value) { return UUID.test(String(value)); }
96
+ async function readStore(root) {
97
+ const existing = await readJson(file(root, STORE), null);
98
+ if (existing?.schemaVersion === 1 && existing.config?.conversations && Array.isArray(existing.outbox?.events)) return existing;
99
+ const oldConfig = await readJson(file(root, LEGACY_CONFIG), null), oldOutbox = await readJson(file(root, LEGACY_OUTBOX), null);
100
+ if (!oldConfig && !oldOutbox) return blankState();
101
+ const state = blankState(); state.config = { ...state.config, ...oldConfig, conversations: oldConfig?.conversations || oldConfig?.scopes || {}, lost: !(oldConfig && oldOutbox) };
102
+ state.outbox = oldOutbox || { schemaVersion: 1, events: [] }; return state;
103
+ }
104
+ async function saveStore(root, state) { await saveJson(file(root, STORE), state); }
105
+ function status(state) { return { enabled: state.config.enabled === true, conversations: Object.values(state.config.conversations).map(({ provider, session, sequence }) => ({ provider, session, sequence })), queued: state.outbox.events.length }; }
106
+ export async function readObservability(root) { return (await readStore(root)).config; }
107
+ export async function observabilityStatus(root) { return status(await readStore(root)); }
108
+ function stateFromRecord(record, now = Date.now()) {
109
+ const raw = record?.run?.state;
110
+ const fresh = Number.isFinite(record?.runtime?.observedAt) && now >= record.runtime.observedAt && now - record.runtime.observedAt <= 45_000;
111
+ const executionState = fresh && record.runtime?.isRunning === true ? 'started' : fresh && record.runtime?.isRunning === false ? 'idle' : ['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(raw) ? 'ended' : 'unknown';
112
+ return { autoContinue: record?.autoContinue === true ? 'on' : record?.autoContinue === false ? 'off' : 'unknown', executionState,
113
+ result: raw === 'reported_done' ? 'reported_done' : 'unknown', ...(fresh && record.runtime?.isRunning === true ? { runtimeObservedAt: new Date(record.runtime.observedAt).toISOString() } : {}),
114
+ ...({ stopped: 'cancelled', budget_exhausted: 'timeout', paused: 'needs_user_input', blocked: 'needs_user_input', recovery_required: 'runtime_recovery' }[raw] ? { reasonCode: ({ stopped: 'cancelled', budget_exhausted: 'timeout', paused: 'needs_user_input', blocked: 'needs_user_input', recovery_required: 'runtime_recovery' })[raw] } : {}) };
115
+ }
116
+ export const observabilityStateFromRecord = stateFromRecord;
117
+ function transition(previous, next) {
118
+ if (!previous || previous.autoContinue !== next.autoContinue) return previous ? 'settings_changed' : null;
119
+ if (previous.executionState !== next.executionState) return next.executionState === 'started' ? 'execution_started' : next.executionState === 'ended' ? 'execution_ended' : 'observation_gap';
120
+ return previous.result !== next.result && next.result !== 'unknown' ? 'result_confirmed' : null;
121
+ }
122
+ function event(conversation, key, type, state, now) {
123
+ if (!EVENT_TYPES.has(type) || !EXECUTION.has(state.executionState) || !RESULTS.has(state.result)) throw new Error('Invalid observability event.');
124
+ const request = { schema_version: 1, event_id: randomUUID(), consent_generation: conversation.consentGeneration, provider: conversation.provider, environment: conversation.environment, conversation_ref: conversation.conversationRef, sequence: conversation.sequence, event_type: type, occurred_at: new Date(now).toISOString(), auto_continue: state.autoContinue, execution_state: state.executionState, result: state.result, cli_version: CLI_VERSION };
125
+ if (state.reasonCode && REASONS.has(state.reasonCode)) request.reason_code = state.reasonCode;
126
+ if (type === 'execution_started' && state.runtimeObservedAt) request.runtime_observed_at = state.runtimeObservedAt;
127
+ if (state.missing === true) request.missing = true;
128
+ return { key, createdAt: now, request };
129
+ }
130
+ function limits(options = {}) { return { maxBytes: options.maxBytes ?? MAX_BYTES, maxAge: options.maxAge ?? MAX_AGE }; }
131
+ function trim(state, now, options) {
132
+ const dropped = new Map(), { maxBytes, maxAge } = limits(options), remember = (item, reason) => { if (item?.key && !dropped.has(item.key)) dropped.set(item.key, reason); };
133
+ const retained = []; for (const item of state.outbox.events) { if (!Number.isFinite(item.createdAt) || item.createdAt < now - maxAge) remember(item, 'sequence_gap'); else retained.push(item); } state.outbox.events = retained;
134
+ while (Buffer.byteLength(JSON.stringify(state.outbox), 'utf8') > maxBytes && state.outbox.events.length) remember(state.outbox.events.shift(), 'queue_overflow');
135
+ return dropped;
136
+ }
137
+ function enqueueGap(state, key, reason, now) { const c = state.config.conversations[key]; if (!validConversation(c)) return; c.sequence = Number.isSafeInteger(c.sequence) && c.sequence >= 0 ? c.sequence + 1 : 1; state.outbox.events.push(event(c, key, 'observation_gap', { autoContinue: c.last?.autoContinue || 'unknown', executionState: 'unknown', result: 'unknown', reasonCode: reason, missing: true }, now)); }
138
+ function materializeGaps(state, dropped, now, options) {
139
+ for (const [key, reason] of dropped) enqueueGap(state, key, reason, now);
140
+ // Gap markers are compact; preserve them in preference to ordinary events.
141
+ const { maxBytes } = limits(options);
142
+ const lateDrops = new Map();
143
+ while (Buffer.byteLength(JSON.stringify(state.outbox), 'utf8') > maxBytes) {
144
+ const index = state.outbox.events.findIndex(item => item.request?.event_type !== 'observation_gap');
145
+ if (index < 0) break;
146
+ const [removed] = state.outbox.events.splice(index, 1);
147
+ if (removed?.key && !dropped.has(removed.key) && !lateDrops.has(removed.key)) lateDrops.set(removed.key, 'queue_overflow');
148
+ }
149
+ // A marker may itself be the only remaining item and be larger than an
150
+ // artificially tiny test limit; production's 10 MiB limit easily holds it.
151
+ for (const [key, reason] of lateDrops) enqueueGap(state, key, reason, now);
152
+ }
153
+ function resetForIdentity(state, actor) { state.config = { ...blankConfig(), actor }; state.outbox = { schemaVersion: 1, events: [] }; }
154
+ function sameActor(a, b) { return a && b && a.origin === b.origin && a.accountId === b.accountId && a.deviceId === b.deviceId && a.organizationId === b.organizationId; }
155
+ export async function syncObservabilityConsent({ root, apiOrigin, apiBaseUrl, token, actor, fetchImpl = globalThis.fetch }) {
156
+ const environment = environmentFromOrigin(apiOrigin);
157
+ // Collection is defined only for canonical SIMY dev/prod origins. Fixtures,
158
+ // loopback hosts, and arbitrary origins stay inert without a network call.
159
+ if (!environment) {
160
+ const preflight = await readStore(root);
161
+ if (!preflight.config.enabled && preflight.outbox.events.length === 0) return status(preflight);
162
+ return withStoreLock(root, async () => {
163
+ const state = await readStore(root);
164
+ state.config = blankConfig();
165
+ state.outbox = { schemaVersion: 1, events: [] };
166
+ await saveStore(root, state);
167
+ return status(state);
168
+ });
169
+ }
170
+ const response = await fetchImpl(webApiUrl('devices/heartbeat/?observability=consent', { webOrigin: apiOrigin, apiBaseUrl }), { method: 'POST', headers: webApiHeaders({ token }), body: '{}', signal: AbortSignal.timeout(5_000) });
171
+ const payload = await response.json().catch(() => null); if (!response.ok) throw new Error('SIMY Web could not read observability consent.');
172
+ const identity = { origin: apiOrigin, accountId: actor?.accountId || null, deviceId: actor?.deviceId || null, organizationId: actor?.organizationId || null };
173
+ const generation = payload?.consent_generation;
174
+ const active = payload?.enabled === true && validGeneration(generation) && typeof identity.accountId === 'string' && identity.accountId.length > 0 && typeof identity.deviceId === 'string' && identity.deviceId.length > 0;
175
+ // A negative first response is deliberately side-effect free. The periodic
176
+ // device heartbeat can ask the server for consent, but default OFF must not
177
+ // create a collector directory that races ordinary temporary-root cleanup.
178
+ const preflight = await readStore(root);
179
+ const needsCleanup = preflight.config.enabled || validGeneration(preflight.config.consentGeneration) ||
180
+ preflight.outbox.events.length > 0 || Object.keys(preflight.config.conversations).length > 0;
181
+ if (!active && !needsCleanup) return status(preflight);
182
+ return withStoreLock(root, async () => {
183
+ const state = await readStore(root);
184
+ if (!active) {
185
+ if (state.config.actor && !sameActor(state.config.actor, identity)) resetForIdentity(state, identity); else state.config.actor = identity;
186
+ state.outbox.events = [];
187
+ for (const c of Object.values(state.config.conversations)) { c.sequence = 0; c.last = null; }
188
+ state.config.enabled = false;
189
+ state.config.environment = environment;
190
+ state.config.consentGeneration = null;
191
+ state.config.retry = { attempts: 0, at: 0 };
192
+ await saveStore(root, state);
193
+ return status(state);
194
+ }
195
+ if (state.config.actor && !sameActor(state.config.actor, identity)) resetForIdentity(state, identity); else state.config.actor = identity;
196
+ if (state.config.consentGeneration && state.config.consentGeneration !== generation) {
197
+ state.outbox.events = [];
198
+ for (const c of Object.values(state.config.conversations)) { c.sequence = 0; c.last = null; }
199
+ }
200
+ state.config.enabled = true;
201
+ state.config.environment = environment;
202
+ state.config.consentGeneration = generation;
203
+ state.config.retry = { attempts: 0, at: 0 };
204
+ await saveStore(root, state);
205
+ return status(state);
206
+ });
207
+ }
208
+ // Called only after authoritative record persistence. The lock covers every
209
+ // state/outbox write; it is intentionally never held while network I/O runs.
210
+ export async function observeDeliveryRecord(root, record, now = Date.now(), options = {}) {
211
+ if (!record || !['codex', 'claude'].includes(record.provider) || typeof record.session !== 'string') return false;
212
+ // Default OFF must be inert: do not create a lock, directory, or queue until
213
+ // the paired device has recorded active consent. Recheck under the lock so a
214
+ // concurrent consent change cannot publish an event under a stale generation.
215
+ const preflight = (await readStore(root)).config;
216
+ if (!preflight.enabled || !validGeneration(preflight.consentGeneration) || !['dev', 'prod'].includes(preflight.environment)) return false;
217
+ return withStoreLock(root, async () => { const state = await readStore(root), config = state.config; if (!config.enabled || !validGeneration(config.consentGeneration) || !['dev', 'prod'].includes(config.environment)) return false;
218
+ const key = keyFor(record.provider, record.session); let c = config.conversations[key];
219
+ if (!c) { c = config.conversations[key] = { provider: record.provider, session: record.session, environment: config.environment, conversationRef: randomUUID(), consentGeneration: config.consentGeneration, sequence: 0, last: stateFromRecord(record, now), lastObservedAt: now };
220
+ if (config.lost) { enqueueGap(state, key, 'sequence_gap', now); config.lost = false; await saveStore(root, state); return true; }
221
+ await saveStore(root, state); return false; // baseline is never a historical event
222
+ }
223
+ if (!validConversation(c)) return false; c.consentGeneration = config.consentGeneration; c.environment = config.environment;
224
+ const next = stateFromRecord(record, now), type = transition(c.last, next); c.last = next; c.lastObservedAt = now; if (!type) { await saveStore(root, state); return false; }
225
+ c.sequence = Number.isSafeInteger(c.sequence) && c.sequence >= 0 ? c.sequence + 1 : 1; state.outbox.events.push(event(c, key, type, next, now)); materializeGaps(state, trim(state, now, options), now, options); await saveStore(root, state); return true; });
226
+ }
227
+ export async function observeHeartbeat(root, now = Date.now(), options = {}) {
228
+ const preflight = (await readStore(root)).config;
229
+ if (!preflight.enabled || !validGeneration(preflight.consentGeneration)) return false;
230
+ return withStoreLock(root, async () => {
231
+ const state = await readStore(root), config = state.config;
232
+ if (!config.enabled || !validGeneration(config.consentGeneration)) return false;
233
+ const active = Object.entries(config.conversations).filter(([, conversation]) =>
234
+ validConversation(conversation) && Number.isFinite(conversation.lastObservedAt) &&
235
+ now - conversation.lastObservedAt >= 0 && now - conversation.lastObservedAt <= 90_000);
236
+ if (!active.length) return false;
237
+ const start = Number.isSafeInteger(config.heartbeatCursor) ? config.heartbeatCursor % active.length : 0;
238
+ const selected = Array.from({ length: Math.min(25, active.length) }, (_, index) => active[(start + index) % active.length]);
239
+ config.heartbeatCursor = (start + selected.length) % active.length;
240
+ for (const [key, conversation] of selected) {
241
+ // A newer heartbeat supersedes an unsent one for this conversation.
242
+ state.outbox.events = state.outbox.events.filter(item => !(item.key === key && item.request?.event_type === 'heartbeat'));
243
+ conversation.consentGeneration = config.consentGeneration;
244
+ conversation.sequence = Number.isSafeInteger(conversation.sequence) && conversation.sequence >= 0 ? conversation.sequence + 1 : 1;
245
+ state.outbox.events.push(event(conversation, key, 'heartbeat', conversation.last || { autoContinue: 'unknown', executionState: 'unknown', result: 'unknown' }, now));
246
+ }
247
+ materializeGaps(state, trim(state, now, options), now, options);
248
+ await saveStore(root, state);
249
+ return true;
250
+ });
251
+ }
252
+ function retryAt(now, attempts) { return now + Math.min(60_000, 1_000 * 2 ** Math.min(attempts, 5)) + Math.floor(Math.random() * 500); }
253
+ export async function flushObservability({ root, apiOrigin, apiBaseUrl, token, fetchImpl = globalThis.fetch, now = Date.now() }) {
254
+ const preflight = (await readStore(root)).config;
255
+ if (!preflight.enabled || !validGeneration(preflight.consentGeneration)) return { sent: 0, queued: 0 };
256
+ const snapshot = await withStoreLock(root, async () => { const state = await readStore(root); if (!state.config.enabled || !validGeneration(state.config.consentGeneration) || state.config.retry.at > now) return null; if (state.config.actor?.origin !== apiOrigin) return null; return { generation: state.config.consentGeneration, actor: state.config.actor, events: [...state.outbox.events].sort((left, right) => Number(left.request?.event_type === 'heartbeat') - Number(right.request?.event_type === 'heartbeat')).slice(0, MAX_FLUSH) }; });
257
+ if (!snapshot?.events.length) return { sent: 0, queued: snapshot ? 0 : (await observabilityStatus(root)).queued, ...(snapshot ? {} : { retryPending: true }) };
258
+ const delivered = new Set(), rejected = new Set(); let failed = false, resync = false;
259
+ for (const item of snapshot.events) { try { const response = await fetchImpl(webApiUrl('devices/heartbeat/?observability=event', { webOrigin: apiOrigin, apiBaseUrl }), { method: 'POST', headers: webApiHeaders({ token }, { 'Content-Type': 'application/json' }), body: JSON.stringify(item.request), signal: AbortSignal.timeout(5_000) }); if (response.ok) { delivered.add(item.request.event_id); continue; } if ([400, 409, 422].includes(response.status)) { rejected.add(item.request.event_id); continue; } if ([401, 403].includes(response.status)) resync = true; failed = true; break; } catch { failed = true; break; } }
260
+ return withStoreLock(root, async () => { const state = await readStore(root); if (!state.config.enabled || state.config.consentGeneration !== snapshot.generation || !sameActor(state.config.actor, snapshot.actor)) { state.outbox.events = []; await saveStore(root, state); return { sent: 0, queued: 0 }; }
261
+ state.outbox.events = state.outbox.events.filter(item => !delivered.has(item.request.event_id) && !rejected.has(item.request.event_id)); for (const item of snapshot.events) if (rejected.has(item.request.event_id)) enqueueGap(state, item.key, 'sequence_gap', now); if (resync) state.config.enabled = false; state.config.retry = failed ? { attempts: state.config.retry.attempts + 1, at: retryAt(now, state.config.retry.attempts) } : { attempts: 0, at: 0 }; await saveStore(root, state); return { sent: delivered.size, queued: state.outbox.events.length, ...(failed ? { retryPending: true } : {}) }; });
262
+ }
@@ -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';
@@ -20,6 +20,8 @@ import { homedir } from 'node:os';
20
20
  import { atomicSave, readState } from './update-state-store.mjs';
21
21
  import { captureHookOwner, fenceHook, rootFromDelivery, readDispatchGate, readTaskAdmission } from './update-dispatch-gate.mjs';
22
22
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
23
+ const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')
24
+ .then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);
23
25
  import { execFile } from 'node:child_process';
24
26
  import { promisify } from 'node:util';
25
27
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
@@ -116,11 +118,20 @@ export async function linkedProvider(root, provider) {
116
118
  const digest = value => createHash('sha256').update(value).digest('hex');
117
119
  const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
118
120
  const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
119
- export function canUpgradeDelivery(run) {
121
+ // Candidate discovery reserves an association ID even when no Action exists.
122
+ // Only a confirmed no-match snapshot can distinguish that reservation from
123
+ // existing ownership; missing, bound and unlinked snapshots remain protected.
124
+ export function associationBlocksFollowup(record) {
125
+ return Boolean(record?.associationTaskId) &&
126
+ !(record.actionAssociation?.status === 'no_match' && record.actionAssociation.binding === null);
127
+ }
128
+ export function canUpgradeDelivery(run, { automatic = false } = {}) {
120
129
  return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
121
130
  Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
122
131
  (['active', 'paused', 'recovery_required'].includes(run.state) ||
123
- run.state === 'blocked' && run.blocker === 'external_dependency'));
132
+ run.state === 'blocked' && run.blocker === 'external_dependency') &&
133
+ (!automatic || run.stopReason?.actor !== 'user' && (run.state !== 'paused' || run.stopReason?.actor === 'system' ||
134
+ /^(?: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
135
  }
125
136
  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
137
  export function recordPath(root, provider, session) {
@@ -260,7 +271,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
260
271
  // Recovery requires positive process-death evidence and a serialized second
261
272
  // observation. Unknown/legacy owners are never reclaimed by age.
262
273
  const lockToken = await waitForStateLock(lock, inspectProcess);
263
- let temporary;
274
+ let temporary, observation, result;
264
275
  try {
265
276
  const stored = await read(file, MAX_STATE_BYTES);
266
277
  const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
@@ -275,7 +286,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
275
286
  delete criterion.check.path;
276
287
  }
277
288
  }
278
- const serialized = JSON.stringify(next);
289
+ let serialized = JSON.stringify(next);
290
+ if (Buffer.byteLength(serialized) > MAX_STATE_BYTES / 2 && await compactConsistencyOutbox(root, next))
291
+ serialized = JSON.stringify(next);
279
292
  if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
280
293
  if (serialized === previousBytes) return next;
281
294
  temporary = `${file}.${randomUUID()}.tmp`;
@@ -290,9 +303,29 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
290
303
  await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
291
304
  }
292
305
  }
293
- }
294
- return next;
306
+ // This is intentionally post-persistence and best-effort: visibility
307
+ // recording cannot make an existing delivery state update fail or cause
308
+ // a task to start, stop, or retry.
309
+ // Runtime identity is revalidated by the caller that has a fresh direct
310
+ // observation. Persistence alone is never treated as execution evidence.
311
+ observation = { ...next, runtime: null };
312
+ }
313
+ result = next;
295
314
  } finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
315
+ // Do not wait on process inspection or the collector while holding the
316
+ // delivery state lock. A matching fresh identity is required for start.
317
+ if (observation) void (async () => {
318
+ const runtime = observation.runtime || result?.runtime;
319
+ const verified = result?.autoContinue === true && result.run?.managed &&
320
+ runtime?.isRunning === true && runtime.process?.identity &&
321
+ Number.isSafeInteger(runtime.process?.pid) &&
322
+ await inspectProcess(runtime.process.pid).catch(() => null) === runtime.process.identity;
323
+ await observeDeliveryRecord(dirname(root), {
324
+ ...observation,
325
+ runtime: verified ? runtime : null,
326
+ });
327
+ })().catch(() => {});
328
+ return result;
296
329
  }
297
330
  export async function readRecord(root = rootDirectory(), provider, session) {
298
331
  const path = recordPath(root, provider, session);
@@ -859,12 +892,13 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
859
892
  if (following && text(objective) === prior.objective) throw new Error('Follow-up requires a new objective; use the original task controls for unfinished work.');
860
893
  if (hasPendingUserStop(root, previous) || previous.providerArchived || previous.automaticStartBlocked || previous.autoContinue === false && previous.autoContinueEpoch ||
861
894
  prior.managed?.resumeLease || prior.managed?.liveWorkspaceLease || prior.managed?.outcomeAudit?.lease || prior.managed?.executionUncertain || prior.managed?.pendingApproval || prior.managed?.nativeWait ||
862
- ['pending', 'transferred', 'pending_received'].includes(prior.managed?.handoff?.status) || previous.associationTaskId) throw new Error('Resolve the existing stop, opt-out, ownership, approval or uncertain result before enrolling follow-up work.');
895
+ ['pending', 'transferred', 'pending_received'].includes(prior.managed?.handoff?.status) || associationBlocksFollowup(previous)) throw new Error('Resolve the existing stop, opt-out, ownership, approval or uncertain result before enrolling follow-up work.');
863
896
  const gateRoot = rootFromDelivery(root), gate = readDispatchGate(gateRoot);
864
897
  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
898
  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
899
  }
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.');
900
+ if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run, { automatic: Boolean(autoContinueEpoch) })) ||
901
+ 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
902
  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
903
  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
904
  const managedConfig = managed && { ...(managed === true ? {} : managed),
@@ -1201,6 +1235,18 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1201
1235
  const localReconciliation = flexibleReasoning(run.managed) && ['read','local_edit','local_test'].includes(uncertainAction?.kind);
1202
1236
  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
1237
  run.managed.recoveryEvidence = { decisionId: lastWork.result.decisionId, source: lastWork.result.observationSource || 'provider_reported_read', checkedAt: now };
1238
+ const priorUnit = run.managed.purpose?.evidence?.units?.find(unit =>
1239
+ unit.id === uncertain.decisionId && unit.status === 'unknown' &&
1240
+ (!uncertain.attemptId || unit.attemptId === uncertain.attemptId));
1241
+ if (priorUnit) {
1242
+ priorUnit.status = 'observed';
1243
+ // The read already met the reconciliation guard above, including a
1244
+ // local observation reported without separate evidence references.
1245
+ // Release only local occupancy, never acceptance or external effects.
1246
+ if (['local_edit', 'local_test'].includes(priorUnit.action?.kind)) {
1247
+ priorUnit.localExecutionSettledAt = now;
1248
+ }
1249
+ }
1204
1250
  delete run.managed.executionUncertain;
1205
1251
  resolveRecoveryReceipt(run, uncertain?.attemptId);
1206
1252
  delete run.managed.recoveryDiagnosisDecisionId;
@@ -1873,14 +1919,15 @@ export async function processIdentity(pid) {
1873
1919
  export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
1874
1920
  const unavailable = (code, reason) => ({ ready: false, code, reason });
1875
1921
  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.');
1922
+ 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
1923
  if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
1878
1924
  try { if (record.cwd !== await realpath(cwd)) return unavailable('workspace_mismatch', 'The observed Claude hook belongs to a different folder. Refresh this conversation.'); }
1879
1925
  catch { return unavailable('workspace_unavailable', 'The Claude conversation folder could not be verified. Check the folder and refresh.'); }
1880
1926
  if (record.hookEndedAt || record.lastEvent === 'SessionEnd') return unavailable('session_ended', 'This Claude session has ended. Return to the original conversation to continue.');
1927
+ 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
1928
  if (!record.hookStartedAt) return unavailable(record.hookObservedAt ? 'process_unverified' : 'hook_missing', record.hookObservedAt
1882
1929
  ? '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.');
1930
+ : '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
1931
  const owner = record.hookSessionProcess || record.process; // Existing verified records remain compatible.
1885
1932
  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
1933
  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 +2066,22 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2019
2066
  backgroundIdle: event === 'Stop' && Array.isArray(input.background_tasks) && input.background_tasks.length === 0 && Array.isArray(input.session_crons) && input.session_crons.length === 0 };
2020
2067
  }
2021
2068
  record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
2069
+ if (provider === 'claude' && event !== 'SessionStart' && identity && record.process &&
2070
+ (record.process.pid !== identity.pid || record.process.identity !== identity.identity)) {
2071
+ record.hookRecoveryBlocked = 'process_changed';
2072
+ }
2073
+ // A plugin loaded after the conversation opened can miss SessionStart.
2074
+ // A real prompt/stop hook proves the same exact session and provider parent
2075
+ // without fabricating a start event or adopting an existing task/owner.
2076
+ if (provider === 'claude' && ['UserPromptSubmit', 'Stop'].includes(event) &&
2077
+ !record.run && !record.hookStartedAt && !record.hookSessionProcess && !record.hookEndedAt && !record.hookRecoveryBlocked &&
2078
+ !record.parentSession && !env.SIMY_PARENT_SESSION && !env.CODEX_THREAD_ID &&
2079
+ !record.providerArchived && identity?.identity && Number.isSafeInteger(identity.pid) && identity.pid > 1 &&
2080
+ (!record.process || record.process.pid === identity.pid && record.process.identity === identity.identity)) {
2081
+ record.hookStartedAt = now;
2082
+ record.hookSessionProcess = { ...identity };
2083
+ record.hookRecovery = { source: event, recoveredAt: now, reason: 'session_start_missing' };
2084
+ }
2022
2085
  if (identity) {
2023
2086
  // Legacy records have no frozen hook owner; a new process must not inherit
2024
2087
  // an old SessionStart admission through a later prompt or stop event.
@@ -2028,6 +2091,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2028
2091
  }
2029
2092
  if (event === 'SessionStart') {
2030
2093
  record.hookObservedAt = now;
2094
+ delete record.hookRecovery;
2095
+ delete record.hookRecoveryBlocked;
2031
2096
  delete record.hookEndedAt;
2032
2097
  if (identity) { record.hookStartedAt = now; record.hookSessionProcess = { ...identity }; }
2033
2098
  else { delete record.hookStartedAt; delete record.hookSessionProcess; delete record.process; }