@awak-app/simy-cli 0.5.81 → 0.5.83

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.81",
3
+ "version": "0.5.83",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.81";
16
+ export const CLI_VERSION = "0.5.83";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -294,7 +294,7 @@ export function assessedCheckpointContract(decision) {
294
294
  return `Return ONLY one JSON object with top-level status:"in_progress|completed|blocked|error" and summary:"what happened". Use the key status, NEVER kind, for an ordinary work checkpoint. Record actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"what actually happened",evidence:["concrete observation"]} for THIS selected action. This is provider-reported, not independent verification. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. Risk is low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown. Missing facts are unknown. Completed requires evidence, and SIMY independently checks frozen acceptance. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. missing_information requires a specific question. external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
295
295
  }
296
296
  export function understandingCheckpointContract(decision) {
297
- return `This is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; do not edit files, call external services, or start the proposed next action. Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require review. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. For semantic conditions use manual. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. If material context is missing, set exactly one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification. Optionally include sessionUnderstanding.matchingContext:{deliverables:["concrete requested output"],projectHints:["project name"],repositoryUrls:[],relatedUrls:[],actionIds:[],sourceRefs:[{platform:"slack",externalThreadId:"exact thread",channelExternalId:"exact channel"}]}. Extract only explicit conversation evidence; IDs and sources are retrieval signals, never authenticated identity. Do not infer URLs or identifiers.`;
297
+ return `This is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; do not edit files, call external services, or start the proposed next action. Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require review. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. For semantic conditions use manual. If the complete expected file contents are not yet known from the original user request or observed evidence in this understanding turn, use manual for that criterion and choose a read as the next action to learn them. Never emit file_equals with null, omitted, guessed, or placeholder text; an exact file check requires a bounded concrete UTF-8 string now. Do not perform the proposed next action merely to fill the check, and do not claim that a later read or model report has independently verified a manual criterion. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. If material context is missing, set exactly one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification. Optionally include sessionUnderstanding.matchingContext:{deliverables:["concrete requested output"],projectHints:["project name"],repositoryUrls:[],relatedUrls:[],actionIds:[],sourceRefs:[{platform:"slack",externalThreadId:"exact thread",channelExternalId:"exact channel"}]}. Extract only explicit conversation evidence; IDs and sources are retrieval signals, never authenticated identity. Do not infer URLs or identifiers.`;
298
298
  }
299
299
  function boundedContextList(value, maxItems = 8, maxLength = 300) {
300
300
  if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
@@ -444,7 +444,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
444
444
  if (liveHookAdmission) {
445
445
  const process = previous?.process;
446
446
  if (provider !== 'claude' || previous?.hookStartedAt !== liveHookAdmission.hookStartedAt ||
447
- !(['SessionStart', 'Stop'].includes(previous.lastEvent) || previous.lastEvent === 'Notification' && previous.lastNotificationType === 'idle_prompt') || process?.pid !== liveHookAdmission.pid ||
447
+ !(await claudeHookChannel(previous, { session, cwd, inspectProcess })).ready || process?.pid !== liveHookAdmission.pid ||
448
448
  process?.identity !== liveHookAdmission.identity || !process.identity ||
449
449
  await inspectProcess(process.pid) !== process.identity) throw new Error('Claude live hook changed before enrollment; refresh the session.');
450
450
  }
@@ -928,6 +928,29 @@ export async function processIdentity(pid) {
928
928
  try { const { stdout } = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'lstart=,comm='], { timeout: 500, maxBuffer: 2048 }); return text(stdout, 1000) || null; }
929
929
  catch (e) { return e.code === 1 ? '' : null; }
930
930
  }
931
+ // Channel availability lasts for the verified provider session, independently
932
+ // of turn/permission events. Permission and dispatch decisions remain in the loop.
933
+ export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
934
+ const unavailable = (code, reason) => ({ ready: false, code, reason });
935
+ if (platform === 'win32') return unavailable('platform_unsupported', 'Claude process verification is unavailable on Windows; monitoring remains available.');
936
+ 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.');
937
+ if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
938
+ try { if (record.cwd !== await realpath(cwd)) return unavailable('workspace_mismatch', 'The observed Claude hook belongs to a different folder. Refresh this conversation.'); }
939
+ catch { return unavailable('workspace_unavailable', 'The Claude conversation folder could not be verified. Check the folder and refresh.'); }
940
+ if (record.hookEndedAt || record.lastEvent === 'SessionEnd') return unavailable('session_ended', 'This Claude session has ended. Return to the original conversation to continue.');
941
+ if (!record.hookStartedAt) return unavailable(record.hookObservedAt ? 'process_unverified' : 'hook_missing', record.hookObservedAt
942
+ ? 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.'
943
+ : 'SIMY has not observed a session-start event for this conversation. Check the SIMY plugin and hook execution in the original Claude window.');
944
+ const owner = record.hookSessionProcess || record.process; // Existing verified records remain compatible.
945
+ 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.');
946
+ 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.');
947
+ let identity;
948
+ try { identity = await inspectProcess(owner.pid); } catch { identity = null; }
949
+ if (identity == null) return unavailable('process_check_unavailable', 'SIMY could not check whether the original Claude process is still running. Refresh and check the local process diagnostics.');
950
+ if (!identity) return unavailable('process_exited', 'The original Claude process has exited. Return to the original conversation to continue.');
951
+ if (identity !== owner.identity) return unavailable('process_changed', 'The original Claude process identity changed. Refresh the original conversation.');
952
+ return { ready: true, code: 'ready', reason: 'The SIMY hook channel is available. Continuation waits for this turn to finish and respects the original permissions.' };
953
+ }
931
954
  async function providerProcess(provider) {
932
955
  if (process.platform === 'win32') return null;
933
956
  let pid = process.ppid;
@@ -960,12 +983,22 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
960
983
  }
961
984
  record.observedAt = now; record.lastEvent = event;
962
985
  record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
963
- if (identity) record.process = identity;
986
+ if (identity) {
987
+ // Legacy records have no frozen hook owner; a new process must not inherit
988
+ // an old SessionStart admission through a later prompt or stop event.
989
+ if (event !== 'SessionStart' && record.process && !record.hookSessionProcess &&
990
+ (identity.pid !== record.process.pid || identity.identity !== record.process.identity)) delete record.hookStartedAt;
991
+ record.process = identity;
992
+ }
964
993
  if (event === 'SessionStart') {
965
- if (identity) record.hookStartedAt = now;
994
+ record.hookObservedAt = now;
995
+ delete record.hookEndedAt;
996
+ if (identity) { record.hookStartedAt = now; record.hookSessionProcess = { ...identity }; }
997
+ else { delete record.hookStartedAt; delete record.hookSessionProcess; delete record.process; }
966
998
  const approved = record.run?.managed?.approvedAction;
967
999
  output = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `SIMY hooks observed this ${provider} session: ${input.session_id}. Only if the user requests SIMY Delivery Loop, use simy delivery start --provider ${provider} --session ${input.session_id} with the agreed objective and next step. Existing loop: ${record.run?.state || 'not started'}.${approved ? ` User approved exactly one ${approved.action.kind} action in scope ${approved.action.scope}: ${approved.action.summary}. Next step: ${approved.nextStep}. ${approved.dispatchedAt ? 'That approval was already dispatched; any repeated action needs a new approval.' : 'Resume the original conversation to dispatch this one action.'}` : ''} Never enable, trust, resume, or reset a loop on the user's behalf without their request.` } };
968
1000
  }
1001
+ if (event === 'SessionEnd') record.hookEndedAt = now;
969
1002
  const run = record.run;
970
1003
  if (run && event === 'Stop' && input.turn_id && run.lastStopKey === `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}`) return record;
971
1004
  // Observation only: human prompts mark work in progress, and real provider
@@ -30,6 +30,10 @@ function candidateMetadata(id, metadata, lastActivityAt, observedAt) {
30
30
  };
31
31
  }
32
32
 
33
+ // Desktop writes this bookkeeping outside the session envelope. It is neither
34
+ // conversation identity nor message content in either bounded reader.
35
+ const unscopedFileHistory = item => item.type === 'file-history-snapshot' && item.sessionId === undefined;
36
+
33
37
  function within(parent, child) {
34
38
  const path = relative(parent, child);
35
39
  return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
@@ -60,6 +64,7 @@ function metadataFromPrefix(bytes, id) {
60
64
  if (!line) continue;
61
65
  let item;
62
66
  try { item = JSON.parse(line); } catch { return null; }
67
+ if (unscopedFileHistory(item)) continue;
63
68
  if (item.sessionId !== id) return null;
64
69
  if (item.type === 'custom-title' && item.isSidechain !== true) { customTitle = sessionTitle(item.customTitle); continue; }
65
70
  if (item.type !== 'user' && item.type !== 'assistant') continue;
@@ -199,6 +204,7 @@ async function conversationFromHandle(handle, opened, session, cwd) {
199
204
  if (!line.trim()) continue;
200
205
  let item;
201
206
  try { item = JSON.parse(line); } catch { incomplete = true; continue; }
207
+ if (unscopedFileHistory(item)) continue;
202
208
  if (item.sessionId !== session || item.isSidechain === true) { incomplete = true; continue; }
203
209
  if (!['user', 'assistant'].includes(item.type) || item.isMeta === true) continue;
204
210
  if (typeof item.cwd !== 'string' || resolve(item.cwd) !== cwd) { incomplete = true; continue; }
@@ -1,6 +1,6 @@
1
1
  import { realpath } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { TERMINAL as TERMINAL_RUNS, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServerError } from './codex-app-server.js';
5
5
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
6
6
  import { originalSessionTarget } from './original-session-target.js';
@@ -44,15 +44,13 @@ async function withClient(openClient, work, signal) {
44
44
  } finally { signal?.removeEventListener('abort', cancelled); await client.close(); }
45
45
  }
46
46
 
47
- async function liveClaudeHook(record, metadata, inspectProcess) {
48
- if (!record || record.provider !== 'claude' || record.session !== metadata?.session ||
49
- metadata.observation?.source !== 'claude_local_index' || !record.hookStartedAt ||
50
- !(['SessionStart', 'Stop'].includes(record.lastEvent) || record.lastEvent === 'Notification' && record.lastNotificationType === 'idle_prompt') ||
51
- !record.process?.identity) return false;
52
- try {
53
- if (record.cwd !== await realpath(metadata.cwd)) return false;
54
- return await inspectProcess(record.process.pid) === record.process.identity;
55
- } catch { return false; }
47
+ async function liveClaudeChannel(record, metadata, inspectProcess) {
48
+ if (metadata?.observation?.source !== 'claude_local_index') return { ready: false, code: 'session_metadata_unavailable', reason: 'SIMY could not match this hook to the Claude conversation index. Refresh the conversation list.' };
49
+ return claudeHookChannel(record, { session: metadata.session, cwd: metadata.cwd, inspectProcess });
50
+ }
51
+ async function requireClaudeChannel(record, metadata, inspectProcess) {
52
+ const channel = await liveClaudeChannel(record, metadata, inspectProcess);
53
+ if (!channel.ready) throw Object.assign(new Error(channel.reason), { code: channel.code });
56
54
  }
57
55
 
58
56
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
@@ -90,7 +88,13 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
90
88
  const run = record?.run;
91
89
  const mode = run?.managed ? run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' ? 'pending_takeover' : 'managed' : run ? 'standard_loop' : record?.monitor ? 'monitor' : 'unmanaged';
92
90
  const supported = item.provider === 'codex' && item.observation.source === 'codex_app_server' && Boolean(item.model) && !codexError;
93
- const liveHook = item.provider === 'claude' && !claudeError && await liveClaudeHook(record, item, inspectProcess);
91
+ const claudeChannel = item.provider === 'claude' ? claudeError
92
+ ? { ready: false, code: 'session_metadata_unavailable', reason: claudeError }
93
+ : await liveClaudeChannel(record, item, inspectProcess) : null;
94
+ const liveHook = claudeChannel?.ready === true;
95
+ const continuationIssue = liveHook && run ? !run.managed
96
+ ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
97
+ : TERMINAL_RUNS.has(run.state) ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null : null;
94
98
  let isRunning = item.isRunning;
95
99
  if (record?.runtime?.process?.identity && record.cwd === item.cwd) {
96
100
  try {
@@ -114,13 +118,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
114
118
  binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
115
119
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
116
120
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
121
+ ...(claudeChannel ? { continuationReasonCode: continuationIssue?.code || claudeChannel.code } : {}),
117
122
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
118
123
  canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
119
124
  canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
120
125
  canAutoContinue: (supported || liveHook) && (!run || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
121
- reason: item.provider === 'claude' ? liveHook ? 'Live SIMY hook can continue this Claude session at its next Stop boundary; cold CLI takeover is unavailable.' :
122
- process.platform === 'win32' ? 'Claude live hook enrollment is unavailable on Windows because provider process identity cannot be verified; monitoring remains available.' :
123
- 'Claude enrollment needs a live matching SIMY SessionStart hook and provider process; cold CLI takeover is unavailable.' :
126
+ reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
124
127
  codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
125
128
  };
126
129
  };
@@ -149,8 +152,8 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
149
152
  if (enabled && previous.run && !previous.run.managed) throw new Error('Automatic continuation is unavailable for this conversation.');
150
153
  if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('This conversation is settled. Resume an eligible existing task after refreshing its status.');
151
154
  if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
152
- if (enabled && provider === 'claude' && !await liveClaudeHook(previous,
153
- { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
155
+ if (enabled && provider === 'claude') await requireClaudeChannel(previous,
156
+ { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity);
154
157
  const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
155
158
  const settings = { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3,
156
159
  ...previous.autoContinueSettings,
@@ -230,13 +233,13 @@ export async function enrollSession({ root, provider, session, objective, accept
230
233
  if (provider === 'claude') {
231
234
  metadata = await readClaude(session);
232
235
  const record = await readRecord(root, provider, session);
233
- if (!await liveClaudeHook(record, metadata, inspectProcess)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
236
+ await requireClaudeChannel(record, metadata, inspectProcess);
234
237
  if (record.run) throw new Error('This session already has a Delivery Loop.');
235
238
  await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, now });
236
239
  // A second check narrows the time between metadata admission and the
237
240
  // original provider's next Stop boundary. Process exit still remains a
238
241
  // manual recovery condition; SIMY never starts claude -r.
239
- if (!await liveClaudeHook(await readRecord(root, provider, session), metadata, inspectProcess)) throw new Error('Claude provider process exited before enrollment.');
242
+ await requireClaudeChannel(await readRecord(root, provider, session), metadata, inspectProcess);
240
243
  } else {
241
244
  metadata = await monitorSession({ root, provider, session, monitor: true, openClient, now });
242
245
  if (!metadata.model) throw new Error('Codex did not expose this conversation’s model; automatic continuation is unavailable.');