@awak-app/simy-cli 0.7.26 → 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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.26",
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": {
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.26";
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
+ }
@@ -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,6 +118,13 @@ 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;
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
+ }
119
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 &&
@@ -262,7 +271,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
262
271
  // Recovery requires positive process-death evidence and a serialized second
263
272
  // observation. Unknown/legacy owners are never reclaimed by age.
264
273
  const lockToken = await waitForStateLock(lock, inspectProcess);
265
- let temporary;
274
+ let temporary, observation, result;
266
275
  try {
267
276
  const stored = await read(file, MAX_STATE_BYTES);
268
277
  const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
@@ -294,9 +303,29 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
294
303
  await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
295
304
  }
296
305
  }
297
- }
298
- 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;
299
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;
300
329
  }
301
330
  export async function readRecord(root = rootDirectory(), provider, session) {
302
331
  const path = recordPath(root, provider, session);
@@ -863,7 +892,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
863
892
  if (following && text(objective) === prior.objective) throw new Error('Follow-up requires a new objective; use the original task controls for unfinished work.');
864
893
  if (hasPendingUserStop(root, previous) || previous.providerArchived || previous.automaticStartBlocked || previous.autoContinue === false && previous.autoContinueEpoch ||
865
894
  prior.managed?.resumeLease || prior.managed?.liveWorkspaceLease || prior.managed?.outcomeAudit?.lease || prior.managed?.executionUncertain || prior.managed?.pendingApproval || prior.managed?.nativeWait ||
866
- ['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.');
867
896
  const gateRoot = rootFromDelivery(root), gate = readDispatchGate(gateRoot);
868
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);
869
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.');
@@ -20,6 +20,7 @@ import { routeLegacyHooks } from './legacy-hook-routing.js';
20
20
  import { activateLocalDesktop, watchLocalRuntime, installRuntimeWatchdog } from './runtime-watchdog.js';
21
21
  import { desktopRuntimeStatus, installLocalDesktop } from './desktop-runtime.js';
22
22
  import { consistencyMonitorStatus, consistencyFindings } from './consistency-monitor.js';
23
+ import { observabilityStatus } from '../cli-observability.js';
23
24
 
24
25
  async function readCheckpointInput(input) {
25
26
  const chunks = [];
@@ -45,6 +46,12 @@ function parseDeliveryOptions(args, allowed) {
45
46
 
46
47
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), webOrigin, input = process.stdin, output = console.log } = {}) {
47
48
  const [action, ...args] = argv;
49
+ if (action === 'observability') {
50
+ const [operation, ...flags] = args;
51
+ if (operation !== 'status' || flags.length) throw new Error('Use simy delivery observability status. Change the single device-consent checkbox in SIMY settings.');
52
+ const sessionRoot = dirname(root);
53
+ output(JSON.stringify(await observabilityStatus(sessionRoot), null, 2)); return 0;
54
+ }
48
55
  if (action === 'history-recovery') {
49
56
  const [operation,...flags] = args;
50
57
  const options = parseDeliveryOptions(flags,new Set(['audit','digest']));
@@ -211,6 +218,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
211
218
  }
212
219
  if (action === 'activity') return runActivityCommand(args, { root, output });
213
220
  if (!action || action === '--help') {
221
+ output('simy delivery observability status — show the state-only telemetry status for this device. The single default-OFF consent checkbox is in SIMY settings; disabling it stops uploads and discards pending telemetry. It never enables Auto-continue or starts a task.');
214
222
  output('simy delivery followup --provider codex --session ID --previous-run-id ID --objective TEXT — explicitly supervise the current goal after an exact finished run. A new goal archives the prior run; the same ordinary reported_done goal retains its identity and budgets for read-only reconciliation. Uses the existing supervisor; never reopens stopped, failed or budget-exhausted work.');
215
223
  output('simy delivery auto on|off|status --provider codex|claude — inspect or override CLI automatic start for this project directory (on by default). The next nonempty prompt in a conversation without a loop starts checkpoint tracking. Existing loops are preserved; provider hooks must already be enabled.');
216
224
  output('simy delivery redelegate-native --provider codex --session ID --task-id ID --consent-ref REF — explicitly delegate current native local edit/test permissions to an exact legacy task. Preserves enrollment, history, budgets and stops; does not resume or grant external actions.');
@@ -763,7 +763,6 @@ export async function startDesktopHost({ input = process.stdin, output = process
763
763
  scope: requestedScope,
764
764
  deviceSession: localCliApiSession(),
765
765
  });
766
- stopNotifier.observe(records);
767
766
  const projection = await actionAssociations.beginProjection?.({ records });
768
767
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
769
768
  await actionAssociations.decorate(status.deliveryHooks, projection);
@@ -822,7 +821,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
822
821
  status = { ...structuredClone(latestConnectionStatus), deliveryHooks: status.deliveryHooks, sessionSupervision: status.sessionSupervision };
823
822
  }
824
823
  status.teamThreads = teamThreads?.snapshot();
825
- if (!closed && !relay) emitStatus(status);
824
+ if (!closed && !relay) {
825
+ // Native stop admission waits for this authorized projection, so an old
826
+ // running frame cannot suppress a newly stopped conversation.
827
+ stopNotifier.observe(records);
828
+ emitStatus(status);
829
+ }
826
830
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
827
831
  return publishing;
828
832
  };
@@ -7,7 +7,7 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
7
7
  import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
8
8
  import { realpath } from 'node:fs/promises';
9
9
  import { randomUUID } from 'node:crypto';
10
- import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
10
+ import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, associationBlocksFollowup, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
11
11
  import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
12
12
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
13
13
  import { loadPurposeRuleSet } from './purpose-instruction.js';
@@ -336,7 +336,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
336
336
  // implicit reset through the auto-continue preference.
337
337
  followup: !archived && supported && item.provider === 'codex' &&
338
338
  run?.state === 'reported_done' && !run.managed && run.id && !record.automaticStartBlocked &&
339
- !record.associationTaskId && !(record.autoContinue === false && record.autoContinueEpoch)
339
+ !associationBlocksFollowup(record) && !(record.autoContinue === false && record.autoContinueEpoch)
340
340
  ? { previousRunId: run.id } : null,
341
341
  ...autoContinueState(record, isRunning, availability),
342
342
  lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
@@ -1,15 +1,20 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { deliveryStop } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
3
 
4
+ const pendingManualChecks = run => (run.managed.acceptanceCriteria || []).filter(item =>
5
+ item.check?.type === 'manual' && !Object.hasOwn(run.managed.verified || {}, item.id));
6
+
4
7
  // Auto-continue works while nobody is watching, so a person must learn when it
5
8
  // stops. Map a stopped run to a notification kind; the native window owns copy.
6
9
  export function stopNotificationKind(run, now = Date.now()) {
7
10
  if (!run?.managed?.autoContinueEpoch && !run?.managed?.repairRequest) return null;
11
+ if (run.stopReason?.actor === 'user') return null;
8
12
  // The clock keeps aging while paused; the persisted human decision takes
9
13
  // precedence over a threshold that was reached only after that decision.
10
- if (run.state === 'paused') return run.reason === 'Paused by user.' ? null : deliveryStop(run, now) ? 'paused' : null;
14
+ if (run.state === 'paused') return /^(Paused by user\.|Automatic continuation paused by user\.|User interrupted the provider\.|Stopped by user\.)$/.test(run.reason || '') ? null : deliveryStop(run, now) ? 'paused' : null;
11
15
  if (run.state === 'auto_resuming' && !run.managed.resumeLease?.owner) return 'needs_decision';
12
- if (run.state === 'blocked' || run.state === 'verification_pending' || run.state === 'reported_done') return 'needs_decision';
16
+ if (run.state === 'blocked') return run.blocker === 'external_dependency' || run.managed.dependencyWait ? null : 'needs_decision';
17
+ if (run.state === 'verification_pending') return pendingManualChecks(run).length ? 'needs_decision' : null;
13
18
  if (run.state === 'recovery_required' && run.managed.autoRecovery !== true) return 'needs_decision';
14
19
  if (run.state === 'failed' || run.state === 'budget_exhausted') return 'failed';
15
20
  if (run.state === 'completed') return 'finished';
@@ -19,24 +24,35 @@ export function stopNotificationKind(run, now = Date.now()) {
19
24
  // The first observation is a baseline: restarting SIMY must not replay alerts
20
25
  // for runs that stopped earlier. Each distinct stop is announced once.
21
26
  export function createStopNotifier({ emit, now = Date.now } = {}) {
22
- // Retain one stop fingerprint per currently observed task, rather than an
23
- // event cache whose eviction could replay the entire historical baseline.
27
+ // Retain one stop fingerprint per task for this process. A temporarily empty
28
+ // discovery result must not turn the same historical stop into a new alert.
24
29
  const seen = new Map();
25
30
  let baseline = true;
26
31
  return {
27
32
  observe(records) {
28
33
  for (const record of records) {
29
- const run = record?.run, kind = stopNotificationKind(run, now());
34
+ if (record.providerArchived || record.localHidden) continue;
35
+ const run = record?.run;
36
+ const kind = stopNotificationKind(run, now());
30
37
  const key = JSON.stringify([record.provider, record.session, run?.managed?.taskId]);
31
- if (!kind) continue;
32
- const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId, run.id, kind, run.blocker, run.reason])).digest('hex').slice(0, 32);
38
+ if (!kind) {
39
+ if (run?.state === 'active' || run?.state === 'auto_resuming') seen.delete(key);
40
+ continue;
41
+ }
42
+ // Revisions and generated summaries change during maintenance without
43
+ // creating a new human task. Only a different stop/explicit request does.
44
+ const request = run.managed.pendingApproval || run.managed.understandingQuestion;
45
+ const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId,
46
+ run.id, run.managed.autoContinueEpoch, kind, run.blocker || null, request?.id || null,
47
+ run.managed.userQuestion || null])).digest('hex').slice(0, 32);
33
48
  if (seen.get(key) === id) continue;
34
49
  seen.set(key, id);
50
+ const detail = request?.text || request?.summary || run.managed.userQuestion ||
51
+ (run.state === 'verification_pending' ? pendingManualChecks(run).map(item => item.description).filter(Boolean).join('; ') : null);
35
52
  if (!baseline) emit({ type: 'notify', id, kind, provider: record.provider, session: record.session,
36
- objective: String(run.objective || '').slice(0, 200) });
53
+ objective: String(run.objective || '').slice(0, 200),
54
+ ...(detail ? { detail: String(detail).slice(0, 500) } : {}) });
37
55
  }
38
- // Temporary provider absence or a diagnostic pass is not a new stop.
39
- // Retain fingerprints until a materially different outcome is observed.
40
56
  baseline = false;
41
57
  },
42
58
  };
@@ -28,7 +28,14 @@ export async function claudePluginFiles() {
28
28
  for (const name of ["loop-kernel.mjs", "loop-kernel/index.mjs", "loop-kernel/policy.json", "loop-kernel/pin.json", "loop-kernel/outcome-review.mjs", "loop-kernel/work-frontier.mjs", "loop-kernel/task-pool.mjs"]) {
29
29
  files.set(`hooks/${name}`, await readFile(path.join(SOURCE, "hooks", name), "utf8"));
30
30
  }
31
- files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
31
+ const collector = await readFile(new URL("../cli-observability.js", import.meta.url), "utf8");
32
+ files.set("hooks/cli-observability.mjs", collector.replace("./web-api.js", "./web-api.mjs"));
33
+ files.set("hooks/web-api.mjs", await readFile(new URL("../web-api.js", import.meta.url), "utf8"));
34
+ const deliveryLoop = await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8");
35
+ files.set("hooks/delivery-loop.mjs", deliveryLoop.replace(
36
+ "const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')\n .then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);",
37
+ "import { observeDeliveryRecord } from './cli-observability.mjs';",
38
+ ));
32
39
  files.set("hooks/recovery-incident.mjs", await readFile(path.join(SOURCE, "hooks/recovery-incident.mjs"), "utf8"));
33
40
  files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
34
41
  files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));