@awak-app/simy-cli 0.7.46 → 0.7.47
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 +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.mcp.json +11 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +5 -3
- package/src/codex-plugins/simy/native/app-tools.mjs +45 -0
- package/src/codex-plugins/simy/native/bridge.mjs +156 -0
- package/src/codex-plugins/simy/native/guidance-intent.mjs +30 -0
- package/src/codex-plugins/simy/native/launch +36 -0
- package/src/codex-plugins/simy/native/server.mjs +26 -0
- package/src/codex-plugins/simy/native/submission-receipt.mjs +9 -0
- package/src/delivery-hooks/supervisor.js +10 -2
- package/src/desktop-host.js +179 -11
- package/src/session-supervision/claude-sessions.js +25 -14
- package/src/session-supervision/claude-stream-owner.js +266 -0
- package/src/session-supervision/claude-stream-presentation.js +27 -0
- package/src/session-supervision/claude-stream-transport.js +188 -0
- package/src/session-supervision/codex-app-server.js +30 -8
- package/src/session-supervision/codex-app-tools-connection.js +30 -0
- package/src/session-supervision/conversation-presentation.js +2 -0
- package/src/session-supervision/index.js +5 -1
- package/src/session-supervision/native-handoff.js +69 -13
- package/src/session-supervision/native-turn-observer.js +196 -0
- package/src/session-supervision/purpose-verification.js +18 -7
- package/src/session-supervision/session-guidance.js +160 -0
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/review-worker.js +5 -1
|
@@ -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]
|
|
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(
|
|
629
|
-
let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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,
|