@awak-app/simy-cli 0.7.26 → 0.7.28

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.
@@ -21,6 +21,7 @@ import { hasPendingUserStop, recordPath, canExecuteAutonomousExternal, automatic
21
21
  import { resolveBackendExecutable } from '../backend-executable.js';
22
22
  import { safeProviderFailure, providerEnvironmentIdentity, checkProviderRetry } from '../session-supervision/provider-failure.js';
23
23
  import { executeCodexAppServer, inspectCodexAppServer } from '../session-supervision/codex-app-server.js';
24
+ import { reconcileNativeHandoff } from '../session-supervision/native-handoff.js';
24
25
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
25
26
  import { createTaskPool } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
26
27
 
@@ -158,11 +159,22 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
158
159
  });
159
160
  }
160
161
 
161
- export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, observeTurn, observeAuthentication, inspectProvider = inspectCodexAppServer, shouldYield } = {}) {
162
+ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, observeTurn, observeAuthentication, inspectProvider = inspectCodexAppServer, shouldYield, nativeHandoff = reconcileNativeHandoff } = {}) {
162
163
  const tickWallStart = Date.now();
163
164
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
164
165
  if (signal?.aborted) return;
165
- const readCandidates = readRecords || (() => listRecords(root));
166
+ const sourceRecords = readRecords || (() => listRecords(root)), nativeOwned = new Set();
167
+ const selected = await sourceRecords(), identity = r => `${r.provider}:${r.session}`;
168
+ const selectedIds = new Set(selected.map(identity));
169
+ // OFF changes the consent epoch and removes a task from a scoped activation.
170
+ // Still reconcile its old native queue on reconnect, without admitting work
171
+ // outside this supervisor's selected scope.
172
+ const cancellationOnly = readRecords ? (await listRecords(root)).filter(r => r.run?.managed?.nativeHandoff && !selectedIds.has(identity(r))) : [];
173
+ for (const record of [...selected, ...cancellationOnly]) {
174
+ if (signal?.aborted || await shouldYield?.()) return;
175
+ if ((!execute || record.run?.managed?.nativeHandoff) && await nativeHandoff({ root, record, signal, authorize: authorizeDispatch, cancelOnly: !selectedIds.has(identity(record)) })) nativeOwned.add(identity(record));
176
+ }
177
+ const readCandidates = async () => (await sourceRecords()).filter(r => !nativeOwned.has(`${r.provider}:${r.session}`));
166
178
  await reconcilePurposeTasks({ root, signal, now, observeTurn, observeAuthentication, readRecords: readCandidates });
167
179
  await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}), records: await readCandidates() });
168
180
  if (signal?.aborted) return;
@@ -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
  };
@@ -28,7 +28,7 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
28
28
  const context = await readContext(candidate, { signal });
29
29
  if (context.awaitingTurn || context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
30
30
  await manager.invalidate(candidate);
31
- throw new Error('This conversation changed; wait for its updated analysis.');
31
+ throw Object.assign(new Error('This conversation changed; wait for its updated analysis.'), { code: 'continuation_context_changed' });
32
32
  }
33
33
  manager.requireRecommendation(candidate, recommendationOptions);
34
34
  await beforeEnroll(candidate);
@@ -119,6 +119,13 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
119
119
  readContext, enroll, beforeEnroll: async selected => { await guard(); await beforeEnroll(selected); },
120
120
  settings: { ...setting.settings, ...(fresh.provider === 'codex' ? { purposeDriven: true } : {}), autoContinueEpoch: candidate.autoContinueEpoch } });
121
121
  }).catch(error => {
122
+ // No enrollment was attempted: the fresh-context check invalidated its
123
+ // recommendation. A settled reread may have the very same digest, so
124
+ // retaining this attempt would suppress that valid start forever.
125
+ if (error?.code === 'continuation_context_changed') {
126
+ attempted.delete(key); retries.delete(key);
127
+ return;
128
+ }
122
129
  if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
123
130
  const recoverable = transientStartFailure(error?.code);
124
131
  if (recoverable) {
@@ -0,0 +1,101 @@
1
+ import WebSocket from 'ws';
2
+ import { homedir } from 'node:os';
3
+ import { join, isAbsolute } from 'node:path';
4
+ import { stat, realpath } from 'node:fs/promises';
5
+
6
+ const READ_METHODS = new Set(['thread/read', 'thread/loaded/list', 'thread/turns/list', 'thread/queue/list']);
7
+ export class CodexNativeConnectionError extends Error {
8
+ constructor(code, message) { super(message); this.name = 'CodexNativeConnectionError'; this.code = code; }
9
+ }
10
+ const failure = (code, message) => new CodexNativeConnectionError(code, message);
11
+
12
+ // Connect to the existing owner. Closing this client never kills the daemon,
13
+ // resumes/unloads a thread, changes its settings, or releases its writer lock.
14
+ export async function openCodexNativeConnection({
15
+ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
16
+ socketPath = join(codexHome, 'app-server-control', 'app-server-control.sock'),
17
+ timeoutMs = 5000, signal,
18
+ } = {}) {
19
+ signal?.throwIfAborted();
20
+ if (!isAbsolute(socketPath) || /[\r\n?#]/.test(socketPath)) throw failure('native_socket_invalid', 'Expected an absolute local Codex socket path.');
21
+ const info = await stat(socketPath);
22
+ if (!info.isSocket() || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('native_socket_invalid', 'Codex socket is not owned by this user.');
23
+ const expectedHome = await realpath(codexHome);
24
+ const ws = new WebSocket(`ws+unix://${socketPath}:/`, { handshakeTimeout: timeoutMs, maxPayload: 8 * 1024 * 1024 });
25
+ const pending = new Map(), listeners = new Set();
26
+ let sequence = 0, closed = false;
27
+ const rejectPending = () => {
28
+ for (const item of pending.values()) { clearTimeout(item.timer); item.reject(failure(item.write ? 'native_submission_unknown' : 'native_connection_closed', item.write ? 'Submission result is unknown; reconcile its client message ID before retrying.' : 'Codex owner connection closed.')); }
29
+ pending.clear();
30
+ };
31
+ const close = () => { if (closed) return; closed = true; signal?.removeEventListener('abort', close); rejectPending(); ws.terminate(); };
32
+ ws.on('error', close);
33
+ ws.on('close', close);
34
+ ws.on('message', bytes => {
35
+ let message;
36
+ try { message = JSON.parse(bytes.toString()); } catch { close(); return; }
37
+ if (!message || typeof message !== 'object' || Array.isArray(message)) { close(); return; }
38
+ if (message.id !== undefined && !message.method) {
39
+ const item = pending.get(message.id); if (!item) return;
40
+ clearTimeout(item.timer); pending.delete(message.id);
41
+ if (message.error) item.reject(failure(message.error.code, String(message.error.message || 'Codex request failed.').slice(0, 300)));
42
+ else item.resolve(message.result);
43
+ } else if (message.method && message.id === undefined) {
44
+ // Approval requests belong to the original client, never this adapter.
45
+ for (const listener of listeners) listener(message.method, message.params || {});
46
+ }
47
+ });
48
+ signal?.addEventListener('abort', close, { once: true });
49
+ const rpc = (method, params = {}, write = false) => {
50
+ if (closed || signal?.aborted) return Promise.reject(failure('native_connection_closed', 'Codex owner connection closed.'));
51
+ const id = ++sequence;
52
+ return new Promise((resolve, reject) => {
53
+ const timer = setTimeout(() => { pending.delete(id); reject(failure(write ? 'native_submission_unknown' : 'native_request_timeout', write ? 'Submission result is unknown; reconcile its client message ID before retrying.' : 'Codex owner request timed out.')); }, timeoutMs);
54
+ pending.set(id, { resolve, reject, timer, write });
55
+ ws.send(JSON.stringify({ id, method, params }), error => { if (error) close(); });
56
+ });
57
+ };
58
+ try {
59
+ await new Promise((resolve, reject) => {
60
+ const timer = setTimeout(() => { cleanup(); reject(failure('native_connection_timeout', 'Codex owner handshake timed out.')); }, timeoutMs);
61
+ const cleanup = () => { clearTimeout(timer); ws.off('open', opened); ws.off('close', ended); };
62
+ const opened = () => { cleanup(); resolve(); };
63
+ const ended = () => { cleanup(); reject(failure('native_connection_closed', 'Codex owner connection closed.')); };
64
+ ws.once('open', opened); ws.once('close', ended);
65
+ if (signal?.aborted) close();
66
+ });
67
+ const initialized = await rpc('initialize', { clientInfo: { name: 'simy_native_continuation', version: '1.0.0' }, capabilities: { experimentalApi: true } });
68
+ if (!initialized?.codexHome || await realpath(initialized.codexHome) !== expectedHome) throw failure('native_owner_mismatch', 'Codex owner belongs to a different home.');
69
+ ws.send(JSON.stringify({ method: 'initialized' }));
70
+ return {
71
+ close,
72
+ onNotification(listener) { listeners.add(listener); return () => listeners.delete(listener); },
73
+ request(method, params) {
74
+ if (!READ_METHODS.has(method)) throw failure('native_method_unsupported', 'Use the explicit native continuation submission operation.');
75
+ return rpc(method, params);
76
+ },
77
+ startQueued(threadId, queuedSubmissionId) {
78
+ if (!threadId || !queuedSubmissionId) throw failure('native_submission_invalid', 'An exact queued submission is required.');
79
+ return rpc('thread/queue/start', { threadId, queuedSubmissionId }, true);
80
+ },
81
+ cancelQueued(threadId, queuedSubmissionId) {
82
+ if (!threadId || !queuedSubmissionId) throw failure('native_submission_invalid', 'An exact queued submission is required.');
83
+ return rpc('thread/queue/delete', { threadId, queuedSubmissionId }, true);
84
+ },
85
+ async enqueue({ threadId, cwd, clientUserMessageId, text, beforeEnqueue }) {
86
+ if (!/^[0-9a-f-]{36}$/i.test(threadId) || !isAbsolute(cwd || '') || typeof clientUserMessageId !== 'string' || !clientUserMessageId.trim() ||
87
+ typeof text !== 'string' || !text.trim() || Buffer.byteLength(text) > 256 * 1024 || typeof beforeEnqueue !== 'function') throw failure('native_submission_invalid', 'Continuation requires exact identity, a stable receipt ID, text and a current authorization check.');
88
+ const result = await rpc('thread/read', { threadId, includeTurns: false });
89
+ const thread = result?.thread;
90
+ if (thread?.id !== threadId || thread.ephemeral || await realpath(thread.cwd) !== await realpath(cwd) || !['idle', 'active'].includes(thread.status?.type)) throw failure('native_thread_unavailable', 'The exact conversation is not loaded in this Codex owner.');
91
+ // The delivery controller supplies the existing OFF/stop/epoch check.
92
+ // Queueing does not steer the active turn or override native policies.
93
+ await beforeEnqueue();
94
+ const response = await rpc('thread/queue/add', { threadId, clientUserMessageId, input: [{ type: 'text', text }] }, true);
95
+ const receipt = response?.queuedSubmission;
96
+ if (!receipt?.id || receipt.clientUserMessageId !== clientUserMessageId) throw failure('native_submission_unknown', 'Codex did not return a matching queue receipt; reconcile before retrying.');
97
+ return { threadId, clientUserMessageId, queuedSubmissionId: receipt.id, status: 'queued' };
98
+ },
99
+ };
100
+ } catch (error) { close(); throw error; }
101
+ }
@@ -8,7 +8,7 @@ import { runStructuredProviderReview } from '../sqm/review-worker.js';
8
8
  import { inspectProviderStatus } from '../desktop-status.js';
9
9
  import { createHash } from 'node:crypto';
10
10
 
11
- export const CONTINUATION_ANALYSIS_VERSION = 11;
11
+ export const CONTINUATION_ANALYSIS_VERSION = 12;
12
12
  const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
13
13
  const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
14
14
  const string = maximum => ({ type: 'string', maxLength: maximum });
@@ -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,
@@ -465,6 +465,9 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
465
465
  });
466
466
  // Consent is durable now. Provider reads and ordinary-loop promotion belong
467
467
  // to the background coordinator, so their failure cannot undo this response.
468
+ if (!enabled && updated.run?.managed?.nativeHandoff) {
469
+ void import('./native-handoff.js').then(({ reconcileNativeHandoff }) => reconcileNativeHandoff({ root, record: updated })).catch(() => {});
470
+ }
468
471
  return updated;
469
472
  }
470
473
 
@@ -0,0 +1,251 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { realpath, mkdir, writeFile, rename, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { openCodexNativeConnection } from './codex-native-connection.js';
5
+ import { readRecord, updateRecord, checkpointDelivery, automaticContinuationAllowed, hasPendingUserStop, managedBudgetReason, assessedCheckpointContract, queuePurposeDiagnosis, rootDirectory, notifyState } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
+ import { failureLimitReached } from '../codex-plugins/simy/hooks/purpose-search.mjs';
7
+ import { requireProviderLink } from '../provider-links.js';
8
+
9
+ const key = r => createHash('sha256').update(JSON.stringify([r.run.id, r.autoContinueEpoch, r.run.controlRevision, r.run.revision, r.run.managed.decision?.id])).digest('hex');
10
+ function approved(m) {
11
+ const a = m?.approvedAction, d = m?.decision;
12
+ return d?.outcome === 'wait' && a?.id === d.id &&
13
+ ['kind', 'scope', 'summary'].every(field => a.action?.[field] === d.action?.[field]) &&
14
+ (!a.dispatchedAt || a.nativeHandoffId && a.nativeHandoffId === m.nativeHandoff?.id);
15
+ }
16
+ function allowed(r, root, settling = false) {
17
+ const m = r?.run?.managed;
18
+ return r?.provider === 'codex' && r.autoContinue === true && automaticContinuationAllowed(r) &&
19
+ r.run.state === 'recovery_required' && !r.run.stopReason && m?.autoRecovery === true && m.resumeChannel === 'codex_app_server' &&
20
+ (m.decision?.outcome === 'proceed' || approved(m)) && ['read', 'local_edit', 'local_test', ...(approved(m) ? ['external'] : [])].includes(m.decision?.action?.kind) &&
21
+ !m.resumeLease && !m.liveWorkspaceLease && !m.outcomeAudit?.lease && !m.pendingApproval && !m.executionUncertain &&
22
+ !m.nativeWait && !m.userQuestion && !m.understandingQuestion && !m.dependencyWait && !m.authenticationWait &&
23
+ !['verifying', 'understanding', 'needs_clarification', 'action_association'].includes(m.phase) &&
24
+ !m.providerWarning && (settling || !m.usageBlocked && !m.maxTokens) && !m.purpose?.nativeBoundary &&
25
+ !hasPendingUserStop(root, r) && (settling || !managedBudgetReason(r.run, Date.now()));
26
+ }
27
+ function countNativeStart(run, handoff) {
28
+ if (handoff.countedAt) return;
29
+ handoff.countedAt = Date.now(); run.continuations += 1;
30
+ }
31
+ function busyRetry(r) {
32
+ const m = r?.run?.managed, last = m?.attempts?.at(-1);
33
+ return last?.outcome === 'busy' && last.id === m.executionAttempt?.id && m.executionAttempt.decisionId === m.decision?.id && m.executionAttempt.phase === 'claimed' && !m.executionAttempt.dispatchedAt;
34
+ }
35
+ function pendingContinuation(r) {
36
+ const attempt = r?.run?.managed?.executionAttempt;
37
+ return busyRetry(r) || ['session_supervision', 'approved_action'].includes(r?.run?.recoveryCause) &&
38
+ (!attempt || attempt.phase === 'checkpointed' || r.run.managed.lastNativeHandoff?.recoveryDecisionId === r.run.managed.decision?.id || r.run.managed.lastNativeHandoff?.checkpointRevision === r.run.revision);
39
+ }
40
+ async function retainHandoff(root, h) {
41
+ if (!/^[a-f0-9]{64}$/.test(h.id)) throw Error('Invalid native handoff identity.');
42
+ const directory = join(root, 'native-handoffs'), file = join(directory, `${h.id}.json`), temporary = `${file}.${randomUUID()}.tmp`;
43
+ await mkdir(directory, { recursive: true, mode: 0o700 });
44
+ try { await writeFile(temporary, JSON.stringify(h), { mode: 0o600 }); await rename(temporary, file); }
45
+ finally { await rm(temporary, { force: true }); }
46
+ return { id: h.id, phase: h.phase, turnId: h.turnId || null, ...(h.checkpointRevision !== undefined ? { checkpointRevision: h.checkpointRevision } : {}), ...(h.recoveryDecisionId ? { recoveryDecisionId: h.recoveryDecisionId } : {}), archive: file };
47
+ }
48
+ async function queue(client, session) {
49
+ const rows = [], seen = new Set(); let cursor;
50
+ do {
51
+ const page = await client.request('thread/queue/list', { threadId: session, limit: 100, ...(cursor ? { cursor } : {}) });
52
+ if (!Array.isArray(page?.data) || rows.length + page.data.length > 1000 || cursor && seen.has(cursor)) throw Error('Incomplete native queue.');
53
+ if (cursor) seen.add(cursor);
54
+ rows.push(...page.data); cursor = page.nextCursor;
55
+ } while (cursor);
56
+ return rows;
57
+ }
58
+ async function findTurn(client, session, handoff) {
59
+ let cursor; const seen = new Set();
60
+ for (let pages = 0; pages < 20; pages++) {
61
+ const page = await client.request('thread/turns/list', { threadId: session, limit: 10, itemsView: 'summary', sortDirection: 'desc', ...(cursor ? { cursor } : {}) });
62
+ if (!Array.isArray(page?.data)) throw Error('Incomplete native turn history.');
63
+ for (const turn of page.data) {
64
+ if (turn.id === handoff.turnId || turn.items?.some(item => item.type === 'userMessage' &&
65
+ (item.clientId ? item.clientId === handoff.clientUserMessageId :
66
+ item.content?.some(part => part.type === 'text' && part.text === handoff.text)))) return turn;
67
+ }
68
+ if (!page.nextCursor || page.data.some(t => t.completedAt && t.completedAt * 1000 < handoff.createdAt - 1000)) return null;
69
+ cursor = page.nextCursor; if (seen.has(cursor)) throw Error('Repeated native history cursor.'); seen.add(cursor);
70
+ }
71
+ return null; // Absence is never permission to resend an uncertain submission.
72
+ }
73
+
74
+ // One durable handoff occupies the same task/decision across host restarts.
75
+ // Return true while it owns continuation so the cold executor cannot race it.
76
+ export async function reconcileNativeHandoff({ root = rootDirectory(), record, openClient = openCodexNativeConnection,
77
+ authorize = async () => {}, checkLink = requireProviderLink, signal, cancelOnly = false } = {}) {
78
+ let current = await readRecord(root, record.provider, record.session), h = current?.run?.managed?.nativeHandoff;
79
+ const replaceable = r => h && (h.checkpointRevision !== undefined || key(r) !== h.id && (h.phase === 'cancelled' || h.phase === 'finished' && r.run.managed.decision?.id !== h.decisionId));
80
+ if (replaceable(current)) {
81
+ current = await updateRecord(root, record.provider, record.session, async r => {
82
+ if (r?.run?.id === current.run.id && r.run.managed.nativeHandoff?.id === h.id && replaceable(r)) {
83
+ r.run.managed.lastNativeHandoff = await retainHandoff(root, r.run.managed.nativeHandoff);
84
+ delete r.run.managed.nativeHandoff;
85
+ }
86
+ return r;
87
+ });
88
+ h = current?.run?.managed?.nativeHandoff;
89
+ }
90
+ if (!h && (cancelOnly || !(allowed(current, root) && pendingContinuation(current)))) return false;
91
+ let client;
92
+ const mutate = fn => updateRecord(root, record.provider, record.session, async r => {
93
+ if (r?.run?.id !== current.run.id) throw Error('Original native handoff task changed.');
94
+ return fn(r);
95
+ });
96
+ try {
97
+ client = await openClient({ signal });
98
+ const view = (await client.request('thread/read', { threadId: record.session, includeTurns: false }))?.thread;
99
+ if (view?.id !== record.session || view.ephemeral || await realpath(view.cwd) !== await realpath(current.cwd) || !['idle', 'active'].includes(view.status?.type)) return !!h;
100
+ let rows = await queue(client, record.session);
101
+ if (!h) {
102
+ // Human follow-ups have priority. Do not append a stale automatic plan.
103
+ if (view.status.type !== 'idle' || rows.length) return true;
104
+ await checkLink({ root, provider: 'codex' }); await authorize(current);
105
+ current = await mutate(async r => {
106
+ if (!allowed(r, root) || !pendingContinuation(r) || key(r) !== key(current)) return r;
107
+ if (!r.run.managed.nativeHandoff) {
108
+ const id = key(r), marker = `[SIMY continuation ${id}]`;
109
+ r.run.managed.nativeHandoff = { id, decisionId: r.run.managed.decision.id, clientUserMessageId: `simy-${id}`, marker, phase: 'prepared', createdAt: Date.now(),
110
+ text: `${marker}\nContinue the existing authorized task in this conversation. Preserve the user's latest corrections, permissions, stops and budgets. Do only this selected action if it still matches the latest user instructions: ${JSON.stringify(r.run.managed.decision.action)}. If superseded, report an updated plan without executing the old action. Task context: ${r.run.managed.plan?.diagnosis || "Continue the original task."} Report what actually happened. ${assessedCheckpointContract(r.run.managed.decision, r.run.managed)} Do not reset this task or change SIMY state directly.` };
111
+ }
112
+ return r;
113
+ });
114
+ h = current?.run?.managed?.nativeHandoff; if (!h) return true;
115
+ }
116
+ const valid = r => allowed(r, root) && key(r) === h.id;
117
+ const own = rows.find(row => row.clientUserMessageId === h.clientUserMessageId);
118
+ if (own && !own.input?.some(part => part.type === 'text' && part.text === h.text)) throw Error('Native submission identity has different input.');
119
+ const turn = h.phase === 'prepared' ? null : await findTurn(client, record.session, h);
120
+ if (turn) {
121
+ current = await mutate(r => {
122
+ const live = r.run.managed.nativeHandoff;
123
+ if (live?.id === h.id) {
124
+ Object.assign(live, { phase: ['completed', 'failed', 'interrupted'].includes(turn.status) ? 'finished' : 'started', turnId: turn.id, turnStatus: turn.status, observedAt: Date.now() });
125
+ countNativeStart(r.run, live);
126
+ }
127
+ return r;
128
+ });
129
+ // A new ON/resume may change consent/control while the old turn runs.
130
+ // Preserve its result, then read it under the new intent instead of
131
+ // accepting its stale next plan or executing its action a second time.
132
+ if (!cancelOnly && ['completed', 'failed', 'interrupted'].includes(turn.status) && key(current) !== h.id) {
133
+ await mutate(r => {
134
+ const live = r.run.managed.nativeHandoff;
135
+ if (live?.id !== h.id || live.checkpointRevision !== undefined || key(r) === h.id || !allowed(r, root)) return r;
136
+ if (queuePurposeDiagnosis(r.run,
137
+ `Read the ${turn.status} native turn ${turn.id} and reconcile its results with the latest user instructions before continuing. Do not repeat effects already performed.`,
138
+ `native-control:${h.id}:${key(r)}`, Date.now(), 'checkpoint_rejected')) live.recoveryDecisionId = r.run.managed.decision.id;
139
+ return r;
140
+ });
141
+ return true;
142
+ }
143
+ if (!cancelOnly && ['completed', 'failed'].includes(turn.status) && allowed(current, root, true) && key(current) === h.id && current.run.managed.nativeHandoff.checkpointRevision === undefined) {
144
+ const final = turn.items?.filter(item => item.type === 'agentMessage' && ['final_answer', 'final', null, undefined].includes(item.phase)).at(-1)?.text;
145
+ try {
146
+ if (turn.status === 'failed') throw Error('Native turn failed; inspect its results before continuing.');
147
+ if (!final || Buffer.byteLength(final) > 16 * 1024) throw Error('Native result is missing or exceeds the checkpoint size limit.');
148
+ await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: current.cwd,
149
+ checkpoint: JSON.parse(final), expectedNativeHandoff: { id: h.id, turnId: turn.id, decisionId: h.decisionId,
150
+ runId: current.run.id, revision: current.run.revision, controlRevision: current.run.controlRevision, epoch: current.autoContinueEpoch } });
151
+ } catch (error) {
152
+ await mutate(r => {
153
+ const live = r.run.managed.nativeHandoff;
154
+ if (live?.id !== h.id || live.checkpointRevision !== undefined || !allowed(r, root, true) || key(r) !== h.id) return r;
155
+ live.resultError = String(error.message).slice(0, 300);
156
+ // Polling or replacement supervisors may observe the same failed
157
+ // result repeatedly. Charge it once, using the existing failure
158
+ // policy before admitting another diagnostic provider turn.
159
+ if (!live.failureCountedAt) {
160
+ live.failureCountedAt = Date.now();
161
+ r.run.failures += 1;
162
+ }
163
+ if (failureLimitReached(r.run)) {
164
+ r.run.state = 'failed'; r.run.recoveryCause = 'provider_error';
165
+ r.run.managed.autoRecovery = false;
166
+ r.run.reason = `Stopped after ${r.run.failures} failed attempts. Open the original conversation to see what went wrong.`;
167
+ r.run.updatedAt = Date.now(); notifyState(r.run, r.run.updatedAt);
168
+ return r;
169
+ }
170
+ // Read the completed turn before planning more work; never replay
171
+ // its effects merely because its final response was not parseable.
172
+ if (queuePurposeDiagnosis(r.run,
173
+ `Read the ${turn.status} native turn ${turn.id} and its actual work results, then continue the original unfinished task. The result could not be read: ${live.resultError}. Do not repeat effects already performed or create extra reports.`,
174
+ `native-result:${h.id}`, Date.now(), 'checkpoint_rejected')) {
175
+ live.recoveryDecisionId = r.run.managed.decision.id;
176
+ }
177
+ return r;
178
+ });
179
+ }
180
+ }
181
+ return true;
182
+ }
183
+ if (!valid(current)) {
184
+ if (own) await mutate(async r => {
185
+ if (r.run.managed.nativeHandoff?.id === h.id && !valid(r)) {
186
+ await client.cancelQueued(record.session, own.id);
187
+ Object.assign(r.run.managed.nativeHandoff, { phase: 'cancelled', cancelledAt: Date.now() });
188
+ const approval = r.run.managed.approvedAction;
189
+ if (approval?.nativeHandoffId === h.id) { delete approval.dispatchedAt; delete approval.nativeHandoffId; }
190
+ }
191
+ return r;
192
+ });
193
+ else if (h.phase === 'prepared') await mutate(r => { if (r.run.managed.nativeHandoff?.id === h.id) r.run.managed.nativeHandoff.phase = 'cancelled'; return r; });
194
+ return true;
195
+ }
196
+ if (cancelOnly) return true;
197
+ if (['started', 'finished', 'cancelled'].includes(h.phase)) return true;
198
+ if (!own && h.phase !== 'prepared') return true;
199
+ if (view.status.type !== 'idle' || rows.some(row => row.clientUserMessageId !== h.clientUserMessageId)) return true;
200
+ if (!own) {
201
+ // Persist intent BEFORE the external send. A crash at either side of the
202
+ // send is reconciled by client ID/history, never by another queue/add.
203
+ let claimed = false;
204
+ current = await mutate(r => {
205
+ if (valid(r) && r.run.managed.nativeHandoff?.phase === 'prepared') { r.run.managed.nativeHandoff.phase = 'submitting';
206
+ if (approved(r.run.managed)) Object.assign(r.run.managed.approvedAction, { dispatchedAt: Date.now(), nativeHandoffId: h.id });
207
+ claimed = true; }
208
+ return r;
209
+ });
210
+ if (!claimed) return true;
211
+ await mutate(async r => {
212
+ const live = r.run.managed.nativeHandoff;
213
+ if (!valid(r) || live?.id !== h.id || live.phase !== 'submitting') return r;
214
+ await checkLink({ root, provider: 'codex' }); await authorize(r);
215
+ const receipt = await client.enqueue({ threadId: record.session, cwd: r.cwd, clientUserMessageId: h.clientUserMessageId,
216
+ text: h.text, beforeEnqueue: async () => { signal?.throwIfAborted(); if (hasPendingUserStop(root, r)) throw Error('User stopped native continuation.'); await checkLink({ root, provider: 'codex' }); await authorize(r); } });
217
+ Object.assign(live, { phase: 'queued', queuedSubmissionId: receipt.queuedSubmissionId });
218
+ return r;
219
+ });
220
+ return true;
221
+ }
222
+ // Claim the exact queued ID durably before asking the owner to consume it.
223
+ let claimed = false; const startClaim = randomUUID();
224
+ // A still-present provider queue ID can be consumed after a crash before
225
+ // start. Never recreate it. The exact ID, not a positional queue head, is
226
+ // passed to Codex; a consumed entry cannot become a second submission.
227
+ current = await mutate(r => { if (valid(r) && ['submitting', 'queued', 'starting'].includes(r.run.managed.nativeHandoff?.phase)) { Object.assign(r.run.managed.nativeHandoff, { phase: 'starting', queuedSubmissionId: own.id, startClaim }); claimed = true; } return r; });
228
+ if (!claimed) return true;
229
+ await mutate(async r => {
230
+ const live = r.run.managed.nativeHandoff;
231
+ if (!valid(r) || live?.id !== h.id || live.phase !== 'starting' || live.startClaim !== startClaim) return r;
232
+ await checkLink({ root, provider: 'codex' }); await authorize(r); signal?.throwIfAborted();
233
+ const fresh = (await client.request('thread/read', { threadId: record.session, includeTurns: false }))?.thread;
234
+ rows = await queue(client, record.session);
235
+ if (fresh?.status?.type !== 'idle' || rows.length !== 1 || rows[0].id !== own.id) { live.phase = 'queued'; return r; }
236
+ signal?.throwIfAborted(); if (hasPendingUserStop(root, r)) return r;
237
+ await checkLink({ root, provider: 'codex' }); await authorize(r);
238
+ const started = await client.startQueued(record.session, own.id);
239
+ if (!started?.turn?.id) throw Error('Native start result is unknown.');
240
+ Object.assign(live, { phase: 'started', turnId: started.turn.id, observedAt: Date.now() });
241
+ countNativeStart(r.run, live);
242
+ return r;
243
+ });
244
+ return true;
245
+ } catch (error) {
246
+ if (signal?.aborted) return true;
247
+ // The intent survives disconnects, missing sockets, and malformed replies.
248
+ // Reconciliation on the next tick cannot replay a sent submission.
249
+ return !!(await readRecord(root, record.provider, record.session))?.run?.managed?.nativeHandoff;
250
+ } finally { await client?.close(); }
251
+ }
@@ -61,6 +61,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
61
61
 
62
62
  for (let record of await readRecords()) {
63
63
  if (signal?.aborted) break;
64
+ if (record.run?.managed?.nativeHandoff) continue;
64
65
  try {
65
66
  let run = record.run;
66
67
  if (run?.managed?.providerCapabilityWait) continue;
@@ -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
  };
@@ -8,7 +8,7 @@ import { hookRuntimeCommand } from "../hook-runtime.js";
8
8
  import { inspectClaudePlugin, installClaudePlugin } from "./claude-plugin.js";
9
9
 
10
10
  export const SQM_AGENT_SKILL_ID = "simy-sqm-pr-check";
11
- export const SQM_AGENT_SKILL_VERSION = "3.2.0";
11
+ export const SQM_AGENT_SKILL_VERSION = "3.3.0";
12
12
  export const SIMY_CODEX_PLUGIN_ID = "simy";
13
13
  export const SIMY_CODEX_PLUGIN_VERSION = "0.1.0";
14
14
 
@@ -42,6 +42,8 @@ Prefer the fixed local \`$HOME/.simy/bin/simy\` (or \`$SIMY_HOME/bin/simy\`) whe
42
42
  5. Verify the proof's repository, base, HEAD and working-tree/diff identity and its bound signature with SQM tooling. Use separate artifact paths outside the diff, and rerun after bound inputs change.
43
43
  6. Include proof paths, outcome reasons, matched modules, evaluated rules and scenario statuses from \`executions\` in the PR evidence, separating blocking and advisory results. A pass with \`no_applicable_rules\`, zero scenario evaluations or advisory-only checks does not establish feature or historical incident coverage. Scenario statuses are deterministic evaluations, not browser/provider/deployed execution evidence. Retain relevant uncovered incidents and their concrete regression result or gap; do not promote incomplete delivery acceptance. Zero coverage alone is not a Delivery Loop missing_information blocker: investigate rule applicability and available verification, and continue authorized repair or testing. Keep requested SQM acceptance unmet until it is actually satisfied; alternative tests do not substitute for it. Use an information wait only for a specific question that requires the user's answer.
44
44
 
45
+ Before interpreting a passed proof, reason from the actual change to plausible failure mechanisms, independent of which rules matched. Query \`simy sqm knowledge --query "<mechanism>" --json\` using the same host/session, then expand the search when a principle suggests a different downstream risk. Read nextOffset and limitations. Connect historical mechanisms to current callers, data scale, frequency, concurrency, retries and shared resource limits; test counter-evidence and mark missing runtime evidence unverified. Query or polling changes may increase disk I/O without saying "IO" in the diff; a bounded indexed lookup may be safe. Cross-repository knowledge suggests a hypothesis and never transfers rule scope or evaluation. Report supported defects and concrete verification gaps separately from the deterministic proof; a passed proof is not a claim that these broader risks were checked.
46
+
45
47
  Do not replace SQM with an LLM review, and do not claim the check passed without reading the generated proof.
46
48
  Use existing authorization for minimal related repair and recheck; do not request another continuation after ordinary blockers are resolved. LLM judgment is the base, relevant HC-01–HC-102 lenses are supplemental, and explicit stops, irreversible actions and real permissions remain boundaries.
47
49
  `;
@@ -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"));