@awak-app/simy-cli 0.7.46 → 0.7.48

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.
@@ -0,0 +1,266 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { access, realpath } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
5
+ import { createControllerStore } from './controller-store.js';
6
+ import { createOperationJournal } from './operation-journal.js';
7
+ import { openClaudeStreamTransport } from './claude-stream-transport.js';
8
+ import { snapshotClaudeHumanCursor, validateClaudeStreamCursor } from './claude-sessions.js';
9
+
10
+ const fail = code => Object.assign(new Error(code), { code });
11
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
12
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ // Explicitly isolated native configuration is part of the saved policy. Resume
14
+ // replays it exactly; an observed external transcript cannot manufacture it.
15
+ export const CLAUDE_CONNECTED_ARGS = ['--setting-sources', '', '--settings', '{"disableAllHooks":true}',
16
+ '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands'];
17
+
18
+ export function createClaudeStreamOwner({ root, context, authorize, inspectProcess, executable,
19
+ openTransport = openClaudeStreamTransport, resolvePath = realpath, onChange = () => {}, now = Date.now,
20
+ snapshotCursor = snapshotClaudeHumanCursor, validateCursor = validateClaudeStreamCursor }) {
21
+ const store = createControllerStore(join(root, 'claude-stream-owners'));
22
+ const journal = createOperationJournal(join(root, 'claude-stream-operations'));
23
+ const live = new Map();
24
+ let queue = Promise.resolve(), stopped = false, revoking = false;
25
+ const enqueue = work => { const result = queue.catch(() => {}).then(work); queue = result; return result; };
26
+ const key = (scope, session) => JSON.stringify([scope, session]);
27
+ async function boundary(expected) {
28
+ if (stopped || revoking) throw fail('claude_owner_stopped');
29
+ await authorize();
30
+ if (stopped || revoking) throw fail('claude_owner_stopped');
31
+ const observed = await context();
32
+ if (stopped || revoking) throw fail('claude_owner_stopped');
33
+ const current = { scope: observed?.scope, epoch: observed?.epoch, account: observed?.account };
34
+ if (!current.scope || current.epoch === undefined || !current.account || expected && !same(current, expected)) throw fail('claude_owner_scope_changed');
35
+ return current;
36
+ }
37
+ async function load(identity, session) { return (await store.load(identity.scope)).claudeOwners?.[session]; }
38
+ async function save(identity, session, mutate) {
39
+ return store.change(identity.scope, state => {
40
+ state.claudeOwners ||= {};
41
+ const previous = state.claudeOwners[session];
42
+ const value = mutate(previous);
43
+ state.claudeOwners[session] = value;
44
+ return structuredClone(value);
45
+ });
46
+ }
47
+ const view = (record, transport) => {
48
+ if (!record) return null;
49
+ const native = transport?.snapshot(), owned = Boolean(native && native.state !== 'closed');
50
+ return { scope: record.identity.scope, session: record.session, cwd: record.cwd, model: record.model, pid: record.pid,
51
+ permissionMode: record.permissionMode, revision: record.revision, epoch: record.identity.epoch,
52
+ state: owned ? native.state : record.state, owned,
53
+ pendingRequestId: owned ? native.pendingRequestId ?? null : null,
54
+ receipt: record.receipt || null, result: record.result || null,
55
+ control: owned ? transport.pendingControl() : null };
56
+ };
57
+ async function selected(command) {
58
+ const identity = await boundary();
59
+ const record = await load(identity, command.session);
60
+ if (!record || !same(record.identity, identity) || command.expectedEpoch !== identity.epoch ||
61
+ command.expectedRevision !== record.revision) throw fail('claude_owner_changed');
62
+ const transport = live.get(key(identity.scope, command.session));
63
+ return { identity, record, transport };
64
+ }
65
+ async function open({ identity, record, resume }) {
66
+ // Persist the opening fence before creating a process. A crash in this
67
+ // interval is an unresolved owner, never permission to spawn a replacement.
68
+ record = await save(identity, record.session, () => ({ ...record, state: 'opening', revision: record.revision + 1 }));
69
+ let transport, capturedIdentity;
70
+ try {
71
+ await boundary(identity);
72
+ const command = await executable();
73
+ let nativeExecutable;
74
+ if (isAbsolute(command)) nativeExecutable = await resolvePath(command);
75
+ else {
76
+ for (const directory of (process.env.PATH || '').split(delimiter).filter(isAbsolute)) {
77
+ try { const candidate = join(directory, command); await access(candidate, constants.X_OK); nativeExecutable = await resolvePath(candidate); break; } catch {}
78
+ }
79
+ }
80
+ if (!nativeExecutable) throw fail('claude_stream_unavailable');
81
+ if (record.executable && record.executable !== nativeExecutable) throw fail('claude_owner_policy_changed');
82
+ transport = await openTransport({ executable: nativeExecutable, session: record.session, cwd: record.cwd,
83
+ model: record.model, permissionMode: record.permissionMode, resume, extraArgs: CLAUDE_CONNECTED_ARGS });
84
+ live.set(key(identity.scope, record.session), transport);
85
+ const pid = transport.snapshot().pid;
86
+ capturedIdentity = await inspectProcess(pid);
87
+ if (!pid || !capturedIdentity) throw fail('claude_owner_identity_unavailable');
88
+ await boundary(identity);
89
+ record = await save(identity, record.session, old => ({ ...old, executable: nativeExecutable, state: 'connected', pid, processIdentity: capturedIdentity, connectedAt: now() }));
90
+ const update = () => {
91
+ enqueue(async () => {
92
+ const cursor = lastResult ? await snapshotCursor(record) : null;
93
+ const current = await save(identity, record.session, old => ({ ...old,
94
+ state: transport.snapshot().state === 'closed' ? ['closed', 'stopped', 'revoked', 'unknown'].includes(old.state) ? old.state : 'closed_unexpected' : transport.snapshot().state,
95
+ revision: old.revision + (lastResult && lastResult.requestId !== old.result?.requestId ? 1 : 0),
96
+ cursor: cursor || old.cursor, receipt: lastReceipt || old.receipt, result: lastResult || old.result }));
97
+ onChange(view(current, transport));
98
+ }).catch(() => { /* A failed durable write cannot authorize a new send. */ });
99
+ };
100
+ let lastReceipt, lastResult;
101
+ transport.on('receipt', value => { lastReceipt = value; });
102
+ transport.on('result', value => { lastResult = value; });
103
+ transport.on('change', update);
104
+ onChange(view(record, transport));
105
+ return view(record, transport);
106
+ } catch (error) {
107
+ // Ownership begins at spawn, not at registration. Never drop a live
108
+ // handle merely because a bounded signal wait resolved. Unknown identity
109
+ // permits EOF only; escalation rechecks the captured identity per signal.
110
+ if (transport) {
111
+ transport.freeze?.('claude_stream_registration_failed');
112
+ transport.closeInput?.();
113
+ if (capturedIdentity) await terminateOwned(transport, { pid: transport.snapshot().pid, processIdentity: capturedIdentity }).catch(() => {});
114
+ else await transport.waitForExit?.(1000);
115
+ if (transport.snapshot().state === 'closed') live.delete(key(identity.scope, record.session));
116
+ }
117
+ const unknown = await save(identity, record.session, old => ({ ...old, state: 'unknown',
118
+ ...(transport ? { pid: transport.snapshot().pid, processIdentity: capturedIdentity || null } : {}) }));
119
+ onChange(view(unknown, live.get(key(identity.scope, record.session))));
120
+ throw error;
121
+ }
122
+ }
123
+ async function terminateOwned(transport, record, authorizeStop = async () => {}) {
124
+ const guard = async () => { await authorizeStop();
125
+ if (!record.processIdentity || await inspectProcess(record.pid) !== record.processIdentity) throw fail('claude_owner_changed'); };
126
+ if (transport.terminate) return transport.terminate({ beforeSignal: guard });
127
+ return transport.stop({ beforeStop: guard });
128
+ }
129
+ return {
130
+ async create({ cwd, model, permissionMode = 'manual', confirmedNew = false }) {
131
+ return enqueue(async () => {
132
+ if (confirmedNew !== true || !['manual', 'plan'].includes(permissionMode) || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{1,150}$/.test(model || '')) throw fail('claude_owner_invalid_policy');
133
+ const identity = await boundary();
134
+ const record = { session: randomUUID(), cwd: await resolvePath(cwd), model, permissionMode,
135
+ identity, revision: 0, state: 'new', createdAt: now(), policyVersion: 1, nativeArgs: CLAUDE_CONNECTED_ARGS };
136
+ return open({ identity, record, resume: false });
137
+ });
138
+ },
139
+ async connect(command) {
140
+ return enqueue(async () => {
141
+ const { identity, record, transport } = await selected(command);
142
+ if (transport && transport.snapshot().state !== 'closed') return view(record, transport);
143
+ if (transport) live.delete(key(identity.scope, record.session));
144
+ if (record.state !== 'closed' || record.policyVersion !== 1 || !same(record.nativeArgs, CLAUDE_CONNECTED_ARGS) || !record.pid || !record.processIdentity ||
145
+ await inspectProcess(record.pid) !== '' || await resolvePath(record.cwd) !== record.cwd ||
146
+ record.result && (!record.cursor || !await validateCursor(record.cursor))) throw fail('claude_owner_not_proven_closed');
147
+ return open({ identity, record, resume: true });
148
+ });
149
+ },
150
+ async status(session) {
151
+ const identity = await boundary();
152
+ const record = await load(identity, session);
153
+ if (!record || !same(record.identity, identity)) return null;
154
+ return view(record, live.get(key(identity.scope, session)));
155
+ },
156
+ async list() {
157
+ const identity = await boundary();
158
+ return Object.values((await store.load(identity.scope)).claudeOwners || {}).filter(record => same(record.identity, identity))
159
+ .map(record => view(record, live.get(key(identity.scope, record.session))));
160
+ },
161
+ async send(command) {
162
+ if (!UUID.test(command.requestId || '') || typeof command.text !== 'string' || !command.text.trim() || command.text.length > 4000) throw fail('claude_guidance_invalid');
163
+ const { identity, record, transport } = await selected(command);
164
+ const operation = { ...command, provider: 'claude', action: 'session_guidance' };
165
+ const previous = await journal.begin(identity.scope, operation);
166
+ if (previous) { if (previous.ok) return previous.data; throw fail(previous.code); }
167
+ if (!transport) {
168
+ await journal.finish(identity.scope, operation, { ok: false, code: 'claude_stream_unavailable' });
169
+ throw fail('claude_stream_unavailable');
170
+ }
171
+ try {
172
+ const data = await transport.send({ requestId: command.requestId, text: `[SIMY user guidance ${command.requestId}]\n${command.text}`,
173
+ beforeWrite: async () => {
174
+ await boundary(identity);
175
+ const current = await load(identity, record.session);
176
+ if (current.revision !== record.revision || live.get(key(identity.scope, record.session)) !== transport ||
177
+ await inspectProcess(record.pid) !== record.processIdentity) throw fail('claude_owner_changed');
178
+ if (current.result && (!current.cursor || !await validateCursor(current.cursor))) {
179
+ transport.freeze?.('claude_owner_transcript_changed'); throw fail('claude_owner_transcript_changed');
180
+ }
181
+ } });
182
+ await journal.finish(identity.scope, operation, { ok: true, data });
183
+ return data;
184
+ } catch (error) {
185
+ await journal.finish(identity.scope, operation, { ok: false, code: error.code === 'claude_guidance_unknown' ? 'remote_operation_unknown' : error.code || 'claude_guidance_unknown' });
186
+ throw error;
187
+ }
188
+ },
189
+ async decide(command) {
190
+ const { identity, record, transport } = await selected(command);
191
+ if (!transport) throw fail('claude_stream_unavailable');
192
+ await transport.decide({ controlRequestId: command.controlRequestId, allow: command.allow,
193
+ beforeWrite: async () => {
194
+ await boundary(identity);
195
+ if ((await load(identity, record.session)).revision !== record.revision || await inspectProcess(record.pid) !== record.processIdentity) throw fail('claude_owner_changed');
196
+ if (record.cursor && !await validateCursor(record.cursor, transport.snapshot().pendingRequestId)) throw fail('claude_owner_transcript_changed');
197
+ } });
198
+ return view(record, transport);
199
+ },
200
+ async disconnect(command) {
201
+ return enqueue(async () => {
202
+ const { identity, record, transport } = await selected(command);
203
+ if (!transport || transport.snapshot().state !== 'idle') throw fail('claude_stream_busy');
204
+ await boundary(identity);
205
+ await transport.close();
206
+ if (await inspectProcess(record.pid) !== '') throw fail('claude_owner_not_proven_closed');
207
+ live.delete(key(identity.scope, record.session));
208
+ return view(await save(identity, record.session, old => ({ ...old, state: 'closed', revision: old.revision + 1, closedAt: now() })));
209
+ });
210
+ },
211
+ async stop(command, { authorize: authorizeStop = async () => {} } = {}) {
212
+ return enqueue(async () => {
213
+ if (command.confirmed !== true) throw fail('claude_owner_stop_confirmation_required');
214
+ // Local teardown cannot depend on a native credential that may have
215
+ // just been revoked. It grants no input or permission; the host still
216
+ // fences its controller scope and every signal verifies the exact PID.
217
+ const matches = [...live].filter(([entry]) => JSON.parse(entry)[1] === command.session);
218
+ if (matches.length !== 1) throw fail('claude_stream_unavailable');
219
+ const [entry, transport] = matches[0], [scope] = JSON.parse(entry);
220
+ const record = (await store.load(scope)).claudeOwners?.[command.session], identity = record?.identity;
221
+ if (!record || record.revision !== command.expectedRevision || identity.epoch !== command.expectedEpoch) throw fail('claude_owner_changed');
222
+ await terminateOwned(transport, record, authorizeStop);
223
+ if (await inspectProcess(record.pid) !== '') throw fail('claude_owner_not_proven_closed');
224
+ live.delete(key(identity.scope, record.session));
225
+ return view(await save(identity, record.session, old => ({ ...old, state: 'stopped', revision: old.revision + 1, closedAt: now() })));
226
+ });
227
+ },
228
+ busy: () => [...live.values()].some(transport => !['idle', 'closed'].includes(transport.snapshot().state)),
229
+ revoke() {
230
+ // Revocation grants no provider input or permission response. It only
231
+ // tears down children already registered to this exact execution owner,
232
+ // without requiring the credentials that have just been revoked.
233
+ revoking = true;
234
+ return enqueue(async () => {
235
+ for (const [entry, transport] of live) {
236
+ const [scope, session] = JSON.parse(entry);
237
+ const record = (await store.load(scope)).claudeOwners?.[session];
238
+ const observed = record && await inspectProcess(record.pid);
239
+ if (observed !== '' && (!record?.processIdentity || observed !== record.processIdentity)) throw fail('claude_owner_identity_unavailable');
240
+ if (observed !== '') await terminateOwned(transport, record);
241
+ if (await inspectProcess(record.pid) !== '') throw fail('claude_owner_not_proven_closed');
242
+ live.delete(entry);
243
+ const ended = await store.change(scope, state => {
244
+ const current = state.claudeOwners[session]; current.state = 'revoked'; current.revision++;
245
+ current.closedAt = now(); return structuredClone(current);
246
+ });
247
+ onChange(view(ended));
248
+ }
249
+ }).finally(() => { revoking = false; });
250
+ },
251
+ async close() {
252
+ if ([...live.values()].some(transport => !['idle', 'closed'].includes(transport.snapshot().state))) throw fail('claude_stream_busy');
253
+ // Orderly host shutdown records closure; unknown/crashed owners remain
254
+ // blocked and are never silently resumed by the next host.
255
+ for (const [entry, transport] of live) {
256
+ const [scope, session] = JSON.parse(entry);
257
+ await transport.close();
258
+ const record = (await store.load(scope)).claudeOwners?.[session];
259
+ const dead = record && await inspectProcess(record.pid) === '';
260
+ await store.change(scope, state => { const current = state.claudeOwners?.[session];
261
+ if (current && dead) { current.state = 'closed'; current.revision++; current.closedAt = now(); } });
262
+ }
263
+ stopped = true; await queue.catch(() => {}); await store.close(); await journal.close();
264
+ },
265
+ };
266
+ }
@@ -0,0 +1,27 @@
1
+ export function claudeStreamPresentation(candidate, owner) {
2
+ if (candidate.provider !== 'claude' || candidate.hostId && candidate.hostId !== 'local' || candidate.remoteHost || candidate.teamRemote ||
3
+ !owner || owner.session !== candidate.session || owner.cwd !== candidate.cwd) return candidate;
4
+ const { scope, ...connection } = owner;
5
+ const available = owner.owned === true && owner.state === 'idle';
6
+ const active = owner.owned === true && ['authorizing', 'working', 'awaiting_permission'].includes(owner.state);
7
+ const requestId = owner.pendingRequestId || (owner.state !== 'authorizing' ? owner.receipt?.requestId : null);
8
+ const currentReceipt = Boolean(requestId && owner.receipt?.requestId === requestId);
9
+ const currentResult = Boolean(requestId && owner.result?.requestId === requestId);
10
+ const pending = active || currentReceipt && !currentResult;
11
+ let state = currentResult ? 'observed' : 'not_sent';
12
+ if (pending) {
13
+ state = 'submitting';
14
+ if (currentReceipt && owner.receipt.state === 'unknown') state = 'unknown';
15
+ else if (currentReceipt && owner.receipt.providerReceiptConfirmed) state = 'submitted';
16
+ }
17
+ return { ...candidate, claudeConnection: connection,
18
+ originalSessionTarget: { kind: 'simy_claude_stream', session: owner.session, label: 'Open connected Claude conversation in SIMY' },
19
+ conversationPresentation: { ...candidate.conversationPresentation,
20
+ guidance: { available, ...(requestId ? { requestId } : {}),
21
+ state,
22
+ providerReceiptConfirmed: currentReceipt && owner.receipt.providerReceiptConfirmed === true,
23
+ providerExecutionConfirmed: currentResult && owner.result.providerExecutionConfirmed === true,
24
+ reason: available ? null : owner.owned ? owner.state : owner.state === 'closed' ? 'claude_connection_closed' : 'claude_owner_unproven',
25
+ ...(available ? { target: { provider: 'claude', session: owner.session, cwd: owner.cwd, runId: `claude-stream:${owner.session}`,
26
+ taskId: owner.session, revision: owner.revision, epoch: owner.epoch } } : {}) } } };
27
+ }
@@ -0,0 +1,188 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ const MODES = new Set(['manual', 'plan', 'acceptEdits']);
7
+ const failure = code => Object.assign(new Error(code), { code });
8
+ const digest = text => createHash('sha256').update(text).digest('hex');
9
+ const userText = content => typeof content === 'string' ? content : Array.isArray(content) && content.every(v => v?.type === 'text' && typeof v.text === 'string')
10
+ ? content.map(v => v.text).join('') : null;
11
+
12
+ // This object owns the provider's original stdin for its entire lifetime. It
13
+ // never attaches to a terminal, forks a transcript, or resumes another writer.
14
+ // The controller supplies an already-authorized immutable launch policy.
15
+ export async function openClaudeStreamTransport({ executable, session, cwd, model, permissionMode,
16
+ resume = false, environment = process.env, spawnImpl = spawn, receiptTimeoutMs = 30_000,
17
+ maxLineBytes = 8 * 1024 * 1024, extraArgs = [] } = {}) {
18
+ if (typeof executable !== 'string' || !executable || !UUID.test(session || '') || typeof cwd !== 'string' || !cwd ||
19
+ !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{1,150}$/.test(model || '') || !MODES.has(permissionMode)) throw failure('claude_stream_invalid_policy');
20
+ const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--replay-user-messages',
21
+ '--permission-prompts', 'host', '--permission-prompt-tool=stdio', '--permission-mode', permissionMode, '--model', model,
22
+ resume ? '--resume' : '--session-id', session, ...extraArgs];
23
+ const child = spawnImpl(executable, args, { cwd, env: { ...environment }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
24
+ const events = new EventEmitter(), requests = new Map(), toolUses = new Set();
25
+ let state = 'starting', buffer = Buffer.alloc(0), pending = null, control = null, closed = false, faulted = false, stderrSeen = false;
26
+ const snapshot = () => ({ session, pid: child.pid, state, model, permissionMode,
27
+ pendingRequestId: pending?.id ?? null, controlRequestId: control?.id ?? null, stderrSeen });
28
+ const publish = () => events.emit('change', snapshot());
29
+ const waitForExit = timeoutMs => closed ? Promise.resolve(true) : new Promise(resolve => {
30
+ const exited = () => { clearTimeout(timer); resolve(true); };
31
+ const timer = setTimeout(() => { child.removeListener('exit', exited); resolve(false); }, timeoutMs);
32
+ child.once('exit', exited);
33
+ });
34
+ function settleUnknown(code) {
35
+ if (closed) return;
36
+ faulted = true;
37
+ state = 'unavailable';
38
+ if (pending) {
39
+ pending.state = pending.attempted ? 'unknown' : 'cancelled';
40
+ clearTimeout(pending.timer); pending.reject(failure(pending.attempted ? 'claude_guidance_unknown' : code));
41
+ events.emit('receipt', { requestId: pending.id, session, state: pending.state, code });
42
+ }
43
+ publish();
44
+ }
45
+ function consume(row) {
46
+ if (closed || faulted) return;
47
+ if (row.session_id !== undefined && row.session_id !== session) { settleUnknown('claude_stream_session_mismatch'); return; }
48
+ if (row.type === 'control_request') {
49
+ if (typeof row.request_id !== 'string' || !row.request_id || row.request_id.length > 200 || control) { settleUnknown('claude_stream_control_invalid'); return; }
50
+ // No response is synthesized. Only an explicit controller/UI decision may
51
+ // answer this exact request; unsupported controls remain visibly blocked.
52
+ control = { id: row.request_id, request: row.request };
53
+ state = 'awaiting_permission'; events.emit('control', structuredClone(control)); publish(); return;
54
+ }
55
+ if (row.type === 'system' && row.subtype === 'init' && row.model && row.model !== model) {
56
+ settleUnknown('claude_stream_model_mismatch'); return;
57
+ }
58
+ if (row.type === 'user') {
59
+ const parts = row.message?.content;
60
+ // Native tool results share the user envelope but are not human-input
61
+ // acknowledgements. Accept only results for this turn's observed calls.
62
+ if (pending && row.session_id === session && Array.isArray(parts) && parts.length &&
63
+ parts.every(part => part?.type === 'tool_result' && toolUses.has(part.tool_use_id))) {
64
+ events.emit('toolResult', { requestId: pending.id, session }); return;
65
+ }
66
+ const request = requests.get(row.uuid);
67
+ if (!request || request !== pending || row.session_id !== session || row.parent_tool_use_id != null || userText(row.message?.content) !== request.text) {
68
+ settleUnknown('claude_stream_receipt_mismatch'); return;
69
+ }
70
+ request.state = 'received'; clearTimeout(request.timer);
71
+ const receipt = { state: 'received', requestId: request.id, session, pid: child.pid, providerReceiptConfirmed: true, providerExecutionConfirmed: false };
72
+ request.receipt = receipt; request.resolve(receipt); events.emit('receipt', receipt); publish(); return;
73
+ }
74
+ if (row.type === 'assistant' && pending) {
75
+ if (row.session_id !== session) { settleUnknown('claude_stream_session_mismatch'); return; }
76
+ pending.executionObserved = true;
77
+ for (const part of row.message?.content || []) if (part?.type === 'tool_use' && typeof part.id === 'string') toolUses.add(part.id);
78
+ events.emit('assistant', { requestId: pending.id, session, message: row.message }); return;
79
+ }
80
+ if (row.type === 'result' && pending) {
81
+ if (row.session_id !== session || pending.state !== 'received') { settleUnknown('claude_stream_receipt_mismatch'); return; }
82
+ const request = pending;
83
+ request.state = row.is_error ? 'failed' : 'completed';
84
+ request.result = { state: request.state, session, requestId: request.id,
85
+ providerReceiptConfirmed: true, providerExecutionConfirmed: request.executionObserved === true,
86
+ result: typeof row.result === 'string' ? row.result.slice(0, 131072) : null, isError: row.is_error === true };
87
+ pending = null; control = null; state = 'idle'; events.emit('result', request.result); publish();
88
+ toolUses.clear();
89
+ }
90
+ }
91
+ child.stdout.on('data', chunk => {
92
+ if (faulted || closed) return;
93
+ buffer = Buffer.concat([buffer, chunk]);
94
+ if (buffer.length > maxLineBytes && buffer.indexOf(10) < 0) { settleUnknown('claude_stream_output_limit'); return; }
95
+ let at;
96
+ while ((at = buffer.indexOf(10)) >= 0) {
97
+ const line = buffer.subarray(0, at); buffer = buffer.subarray(at + 1);
98
+ if (line.length > maxLineBytes) { settleUnknown('claude_stream_output_limit'); return; }
99
+ if (!line.length) continue;
100
+ try { consume(JSON.parse(line.toString('utf8'))); }
101
+ catch { settleUnknown('claude_stream_protocol_invalid'); return; }
102
+ }
103
+ });
104
+ child.stderr.on('data', () => { stderrSeen = true; });
105
+ child.stdin.on('error', () => settleUnknown('claude_stream_input_closed'));
106
+ child.on('error', () => settleUnknown('claude_stream_spawn_failed'));
107
+ child.on('exit', () => { settleUnknown('claude_stream_exited'); closed = true; state = 'closed'; publish(); });
108
+ await new Promise((resolve, reject) => {
109
+ const failed = () => { child.removeListener('spawn', spawned); reject(failure('claude_stream_spawn_failed')); };
110
+ const spawned = () => { child.removeListener('error', failed); if (closed) reject(failure('claude_stream_exited')); else resolve(); };
111
+ child.once('spawn', spawned); child.once('error', failed);
112
+ });
113
+ state = 'idle'; publish();
114
+ return Object.assign(events, {
115
+ snapshot,
116
+ async send({ requestId, text, beforeWrite = async () => {} }) {
117
+ if (!UUID.test(requestId || '') || typeof text !== 'string' || !text.trim() || text.length > 8192) throw failure('claude_guidance_invalid');
118
+ const previous = requests.get(requestId);
119
+ if (previous) {
120
+ if (previous.digest !== digest(text)) throw failure('claude_guidance_conflict');
121
+ if (previous.receipt) return previous.receipt;
122
+ throw failure(previous.state === 'unknown' ? 'claude_guidance_unknown' : 'claude_guidance_pending');
123
+ }
124
+ if (closed || state === 'unavailable' || !child.stdin.writable) throw failure('claude_stream_unavailable');
125
+ if (pending || control || state !== 'idle') throw failure('claude_stream_busy');
126
+ // Reserve the writer before awaiting the caller's final authorization.
127
+ state = 'authorizing'; publish();
128
+ try { await beforeWrite(); } catch (error) { if (state === 'authorizing') state = 'idle'; publish(); throw error; }
129
+ if (closed || state !== 'authorizing' || !child.stdin.writable) throw failure('claude_stream_unavailable');
130
+ const request = { id: requestId, text, digest: digest(text), state: 'submitting', attempted: false };
131
+ const received = new Promise((resolve, reject) => { request.resolve = resolve; request.reject = reject; });
132
+ requests.set(requestId, request); pending = request; state = 'working';
133
+ request.timer = setTimeout(() => settleUnknown('claude_stream_receipt_timeout'), receiptTimeoutMs);
134
+ request.timer.unref?.();
135
+ request.attempted = true;
136
+ try { child.stdin.write(JSON.stringify({ type: 'user', uuid: requestId, session_id: session, parent_tool_use_id: null,
137
+ message: { role: 'user', content: text } }) + '\n'); }
138
+ catch { settleUnknown('claude_stream_input_closed'); }
139
+ publish(); return received;
140
+ },
141
+ result: requestId => requests.get(requestId)?.result ?? null,
142
+ pendingControl: () => control ? structuredClone(control) : null,
143
+ freeze: code => settleUnknown(code || 'claude_stream_cleanup_unknown'),
144
+ closeInput: () => { child.stdin.end(); },
145
+ waitForExit: (timeoutMs = 1000) => waitForExit(timeoutMs),
146
+ async terminate({ beforeSignal, timeoutMs = 1000 }) {
147
+ settleUnknown('claude_stream_stopped'); child.stdin.end();
148
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGKILL']) {
149
+ if (closed) return;
150
+ await beforeSignal();
151
+ if (closed) return;
152
+ child.kill(signal);
153
+ if (await waitForExit(timeoutMs)) return;
154
+ }
155
+ throw failure('claude_stream_cleanup_unknown');
156
+ },
157
+ async stop({ beforeStop = async () => {} } = {}) {
158
+ await beforeStop();
159
+ if (closed) return;
160
+ // This signals only the process whose stdin this transport created.
161
+ // Any attempted message remains unknown; stopping never authorizes retry.
162
+ settleUnknown('claude_stream_stopped');
163
+ child.kill('SIGINT');
164
+ await new Promise(resolve => { const timer = setTimeout(resolve, 5000); timer.unref?.();
165
+ child.once('exit', () => { clearTimeout(timer); resolve(); }); });
166
+ },
167
+ async decide({ controlRequestId, allow, beforeWrite = async () => {} }) {
168
+ if (typeof allow !== 'boolean' || !control || control.id !== controlRequestId || state !== 'awaiting_permission' ||
169
+ control.request?.subtype !== 'can_use_tool') throw failure('claude_stream_control_changed');
170
+ if (control.request.requires_user_interaction === true || control.request.tool_name === 'AskUserQuestion') throw failure('claude_stream_control_interaction_required');
171
+ const current = control;
172
+ await beforeWrite();
173
+ if (control !== current || closed || state !== 'awaiting_permission') throw failure('claude_stream_control_changed');
174
+ child.stdin.write(JSON.stringify({ type: 'control_response', response: { subtype: 'success', request_id: current.id,
175
+ response: allow ? { behavior: 'allow', updatedInput: current.request.input } : { behavior: 'deny', message: 'The user declined this tool request.' } } }) + '\n');
176
+ control = null; state = pending ? 'working' : 'idle'; publish();
177
+ },
178
+ async close() {
179
+ if (closed) return;
180
+ settleUnknown('claude_stream_closing');
181
+ child.stdin.end();
182
+ // EOF is an orderly disconnect, not proof of exit. Only the owner can
183
+ // escalate with a fresh process-identity guard; keep unknown/busy if EOF
184
+ // does not reap this child.
185
+ if (!await waitForExit(5000)) throw failure('claude_stream_cleanup_unknown');
186
+ },
187
+ });
188
+ }
@@ -1,3 +1,4 @@
1
+ import {collectNativeTurnObservation} from './native-turn-observer.js';
1
2
  import { agentRole } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
2
3
  import { captureAcceptanceReads } from './acceptance-reads.js';
3
4
  import { providerFailureDiagnostic } from './provider-failure.js';
@@ -40,6 +41,11 @@ const ANALYSIS_MAX_WIRE_LINE = 8 * 1024 * 1024;
40
41
  // conversation. Explicit bindings remain visible through discoverSessions.
41
42
  const SOURCE_KINDS = ['cli', 'vscode', 'appServer'];
42
43
 
44
+ // A cold app-server can expose another process's live turn as interrupted
45
+ // before it has a terminal timestamp. Such a summary is not a settled boundary.
46
+ const runningTurn = turn => ['inProgress', 'in_progress'].includes(turn?.status) ||
47
+ turn?.status === 'interrupted' && turn.completedAt === null;
48
+
43
49
  export class CodexAppServerError extends Error {
44
50
  constructor(code, message, diagnostic) {
45
51
  super(message);
@@ -427,7 +433,7 @@ export async function readCodexObservation(client, session, { deadline = Date.no
427
433
  try {
428
434
  const page = await client.request('thread/turns/list', { threadId: session, cursor: null, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' }, timeout());
429
435
  if (Array.isArray(page?.data) && page.data.length === 1 && ['inProgress', 'completed', 'interrupted', 'failed'].includes(page.data[0]?.status)) {
430
- observation.isRunning = page.data[0].status === 'inProgress';
436
+ observation.isRunning = runningTurn(page.data[0]);
431
437
  if (typeof page.data[0].id === 'string' && page.data[0].id && page.data[0].id.length <= 160) observation.latestTurnId = page.data[0].id;
432
438
  }
433
439
  } catch (error) {
@@ -549,7 +555,7 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
549
555
  if (metadata.cwd !== candidate.cwd) throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
550
556
  const providerGoal = await readCodexGoal(client, candidate.session);
551
557
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
552
- const terminal = turn => ['completed', 'interrupted', 'failed'].includes(turn?.status);
558
+ const terminal = turn => ['completed', 'interrupted', 'failed'].includes(turn?.status) && !runningTurn(turn);
553
559
  const turns = [];
554
560
  const turnIds = new Set(), cursors = new Set();
555
561
  let pageSize = ANALYSIS_PAGE_SIZE;
@@ -625,8 +631,8 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
625
631
  // A display summary omits intermediate reasoning/tool context even when
626
632
  // every turn fits. Never represent it as the complete conversation; the
627
633
  // classifier must establish a self-contained objective from retained text.
628
- const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verificationTurns].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
629
- let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !['inProgress', 'in_progress'].includes(turn?.status)), truncated = true;
634
+ const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verificationTurns].some(runningTurn);
635
+ let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !runningTurn(turn)), truncated = true;
630
636
  for (const [index, turn] of settled.toReversed().entries()) {
631
637
  if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
632
638
  if (turn.itemsView !== 'summary') throw new CodexAppServerError('context_unavailable', 'Codex did not return the requested conversation summaries.');
@@ -931,7 +937,12 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
931
937
  const observations = run.managed.purpose?.enabled ? await observeExternal(run.managed, { cwd: executionCwd, signal,
932
938
  authorize: authorizeObservation,
933
939
  networkAllowed: observationNetworkAllowed }) : [];
934
- reviewObservations = [...(run.managed.purpose?.evidence?.observations || []), ...observations].filter(item => Date.now() - item.at < 60_000);
940
+ const nativeObservations = reviewing ? await collectNativeTurnObservation(record, {root, client, signal, authorize: authorizePreparation}) : [];
941
+ reviewObservations = [...(run.managed.purpose?.evidence?.observations || []).filter(item => item.kind !== 'codex_native_history'), ...observations, ...nativeObservations].filter(item => Date.now() - item.at < 60_000);
942
+ if (nativeObservations.length) {
943
+ const evidence = {source: 'adapter_capture', actionId: decision.id, observations: nativeObservations};
944
+ await onEvidence(evidence); retainControllerEvidence(run.managed, evidence, Date.now());
945
+ }
935
946
  if (observations.length) {
936
947
  const evidence = { source: 'adapter_capture', actionId: decision.id, observations };
937
948
  await onEvidence(evidence);
@@ -1091,11 +1102,11 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
1091
1102
  if (operationContext) {
1092
1103
  const currentManaged = root ? (await readRecord(root, record.provider, record.session))?.run?.managed : run.managed;
1093
1104
  if (reviewOperationContext(currentManaged || {})?.digest !== operationContext.digest)
1094
- throw new CodexAppServerError('cancelled', 'Captured operation history changed during review.');
1105
+ throw new CodexAppServerError('review_history_changed', 'Captured operation history changed during review.');
1095
1106
  }
1096
1107
  const freshContext = await readCodexConversation(record, { openClient, signal });
1097
1108
  if (JSON.stringify(freshContext) !== JSON.stringify(sourceContext))
1098
- throw new CodexAppServerError('cancelled', 'Original conversation changed during answer review.');
1109
+ throw new CodexAppServerError('review_context_changed', 'Original conversation changed during answer review.');
1099
1110
  const native = await readCodexSession(client, record.session, { signal });
1100
1111
  if (native.model !== run.managed.resumeModel || !native.nativeSandbox || !sameSandboxRights(native.nativeSandbox, dispatchMetadata.nativeSandbox))
1101
1112
  throw new CodexAppServerError('permission_narrowed', 'Original provider context changed during answer review.');
@@ -1165,7 +1176,18 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
1165
1176
  if (localReads.some(prior => !fresh.some(item => item.id === prior.id && item.digest === prior.digest))) { review.passed = false; review.needsHuman = false; review.findings.push('An original file predicate changed during independent review; inspect its current result.'); localReads = []; }
1166
1177
  else localReads = fresh.filter(item => localReads.some(prior => prior.id === item.id && prior.digest === item.digest));
1167
1178
  }
1168
- review = assessIndependentInspection(review, { toolEvidence, observations: [...reviewObservations, ...localReads], criteria: effectiveAcceptanceCriteria(run.managed), flexible: flexibleReasoning(run.managed) });
1179
+ if (reviewObservations.some(item => item.kind === 'codex_native_history')) {
1180
+ const before = reviewObservations.filter(item => item.kind === 'codex_native_history');
1181
+ const fresh = await collectNativeTurnObservation(record, {root, client, signal, authorize: authorizePreparation});
1182
+ if (before.some(prior => prior.status !== 'observed' || !fresh.some(item => item.id === prior.id && item.status === 'observed' && item.digest === prior.digest))) {
1183
+ review.passed = false; review.needsHuman = false;
1184
+ review.findings.push('The complete original native turn history changed or became unavailable during review; inspect its actual current result without replaying the operation.');
1185
+ }
1186
+ reviewObservations = [...reviewObservations.filter(item => item.kind !== 'codex_native_history'), ...fresh];
1187
+ const evidence = {source: 'adapter_capture', actionId: decision.id, reviewBatch: true, observations: reviewObservations};
1188
+ await onEvidence(evidence); retainControllerEvidence(run.managed, evidence, Date.now());
1189
+ }
1190
+ review = assessIndependentInspection(review, { toolEvidence, observations: [...reviewObservations, ...localReads], criteria: effectiveAcceptanceCriteria(run.managed), flexible: flexibleReasoning(run.managed), record });
1169
1191
  review = await bindCompletionArtifacts(record, { ...review, workspaceDigest: boundAfter }, { authorize: authorizePreparation, signal });
1170
1192
  await checkAnswerContext?.();
1171
1193
  return { code: 0, dispatchAttemptId, verification: { ...review, contractDigest, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
@@ -0,0 +1,30 @@
1
+ import {openAppTools} from '../codex-plugins/simy/native/app-tools.mjs';
2
+ import {createHash,randomUUID} from 'node:crypto';
3
+ import {stat,readFile,writeFile,mkdir,rename,realpath} from 'node:fs/promises';
4
+ import {join,isAbsolute,dirname} from 'node:path';
5
+ import {readRecord,automaticContinuationAllowed} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
+ const NODE='/Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node';
7
+ const SERVER='/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled/plugins/codex-app-tools/server.mjs';
8
+ const fail=code=>Object.assign(new Error(code),{code});
9
+ const file=(root,session)=>join(root,'native-app-connections',createHash('sha256').update(session).digest('hex')+'.json');
10
+ const identity=async path=>{const s=await stat(path);return [s.dev,s.ino,s.size,s.mtimeMs];};
11
+ async function ownedPipe(path){if(!isAbsolute(path||''))throw fail('native_tools_attachment_missing');const s=await stat(path);if(!s.isSocket()||s.uid!==process.getuid())throw fail('native_tools_owner_mismatch');}
12
+ export async function registerCodexAppToolsConnection({root,session,env=process.env}){
13
+ if(env.CODEX_THREAD_ID!==session)throw fail('native_tools_caller_mismatch');
14
+ await ownedPipe(env.CODEX_APP_TOOLS_PIPE_PATH);
15
+ const record=await readRecord(join(root,'delivery-hooks'),'codex',session);
16
+ if(!record?.run?.managed||!automaticContinuationAllowed(record))throw fail('native_tools_consent_required');
17
+ const attachment={schema:1,session,runId:record.run.id,taskId:record.run.managed.taskId,epoch:record.autoContinueEpoch,
18
+ pipe:env.CODEX_APP_TOOLS_PIPE_PATH,nodeIdentity:await identity(NODE),serverIdentity:await identity(SERVER),registeredAt:Date.now()};
19
+ const target=file(root,session);await mkdir(dirname(target),{recursive:true,mode:0o700});const temp=target+'.'+randomUUID();
20
+ await writeFile(temp,JSON.stringify(attachment),{mode:0o600});await rename(temp,target);
21
+ return {registered:true,session,runId:attachment.runId,taskId:attachment.taskId};
22
+ }
23
+ export async function openRegisteredCodexAppTools({root,record,signal}){
24
+ let a;try{a=JSON.parse(await readFile(file(dirname(root),record.session),'utf8'));}catch(e){if(e.code==='ENOENT')return null;throw e;}
25
+ if(a.schema!==1||a.session!==record.session||a.runId!==record.run.id||a.taskId!==record.run.managed.taskId||a.epoch!==record.autoContinueEpoch)throw fail('native_tools_attachment_changed');
26
+ if(await realpath(process.execPath)!==await realpath(NODE))throw fail('native_tools_runtime_required');
27
+ await ownedPipe(a.pipe);
28
+ if(JSON.stringify(await identity(NODE))!==JSON.stringify(a.nodeIdentity)||JSON.stringify(await identity(SERVER))!==JSON.stringify(a.serverIdentity))throw fail('native_tools_app_changed');
29
+ return openAppTools({record,signal,node:NODE,server:SERVER,pipe:a.pipe});
30
+ }
@@ -1,3 +1,4 @@
1
+ import {sessionGuidancePresentation} from './session-guidance.js';
1
2
  import { sessionWorkState, sessionWorkOwnership } from './work-state.js';
2
3
  import { deliveryDecisionRevision } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
4
  import { pauseOrigin } from './autonomous-upgrade.js';
@@ -200,6 +201,7 @@ export function conversationPresentation(candidate = {}, projectedRun, { rawReco
200
201
  source(lastRaw?.result?.summary || lastRaw?.summary, 'record.run.managed.workHistory');
201
202
  return { schemaVersion: 1, target,
202
203
  readiness: ready, recovery, plan,
204
+ ...(managed.nativeConnectionObservation || managed.userGuidance ? {guidance: sessionGuidancePresentation(candidate, sameRaw ? rawRecord : null, now)} : {}),
203
205
  now: { state: text(work.state, 64), ownership, label: text(work.label, 200),
204
206
  reason: source(work.reason, 'candidate.workState.reason'), stale: work.stale === true, checkedAt: time(work.checkedAt), ownershipObservedAt: time(work.ownershipObservedAt), providerExecutionConfirmed: executionObserved },
205
207
  next: { ...(next || { text: null, source: null, truncated: false }), ...(nextCopy ? { copy: nextCopy } : {}), owner: nextOwner,