@awak-app/simy-cli 0.5.80 → 0.5.82

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.80",
3
+ "version": "0.5.82",
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.80";
16
+ export const CLI_VERSION = "0.5.82";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -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.');