@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
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
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
|
-
!(
|
|
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)
|
|
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
|
-
|
|
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
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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' ?
|
|
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'
|
|
153
|
-
{ session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)
|
|
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
|
-
|
|
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
|
-
|
|
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.');
|