@awak-app/simy-cli 0.5.33 → 0.5.35
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/README.md +41 -0
- package/package.json +4 -3
- package/src/agent.js +144 -2
- package/src/backend-executable.js +2 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +4 -1
- package/src/codex-plugins/simy/assets/logo.png +0 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +654 -35
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +20 -2
- package/src/codex-work-pattern-sync.js +940 -0
- package/src/delivery-hooks/command.js +28 -7
- package/src/delivery-hooks/desktop.js +38 -4
- package/src/delivery-hooks/supervisor.js +281 -0
- package/src/desktop-host.js +62 -7
- package/src/index.js +7 -0
- package/src/local-task.js +96 -3
- package/src/orchestrator/budget.js +26 -2
- package/src/runner.js +43 -8
- package/src/session-supervision/claude-sessions.js +131 -0
- package/src/session-supervision/codex-app-server.js +290 -0
- package/src/session-supervision/index.js +140 -0
- package/src/session-supervision/original-session-target.js +20 -0
- package/src/windows-update.js +28 -2
- package/src/workflow-deliverable-upload.js +454 -0
- package/src/workflow-observation-sender.js +348 -86
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { inspectBackendExecutable } from '../backend-executable.js';
|
|
4
|
+
import { assessedCheckpointContract, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
|
|
6
|
+
const REQUEST_TIMEOUT = 15_000;
|
|
7
|
+
const MAX_LINE = 256 * 1024;
|
|
8
|
+
const SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'];
|
|
9
|
+
|
|
10
|
+
export class CodexAppServerError extends Error {
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'CodexAppServerError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class CodexAppServerClient {
|
|
19
|
+
constructor(child) {
|
|
20
|
+
this.child = child;
|
|
21
|
+
this.nextId = 0;
|
|
22
|
+
this.pending = new Map();
|
|
23
|
+
this.listeners = new Set();
|
|
24
|
+
this.closed = false;
|
|
25
|
+
// Provider output can include private text. Hold only one bounded JSON-RPC
|
|
26
|
+
// line at a time, and discard overlong lines before parsing them.
|
|
27
|
+
let pending = Buffer.alloc(0), discard = false;
|
|
28
|
+
child.stdout.on('data', chunk => {
|
|
29
|
+
let offset = 0;
|
|
30
|
+
while (offset < chunk.length) {
|
|
31
|
+
const newline = chunk.indexOf(10, offset);
|
|
32
|
+
const end = newline < 0 ? chunk.length : newline;
|
|
33
|
+
const piece = chunk.subarray(offset, end);
|
|
34
|
+
if (!discard) {
|
|
35
|
+
if (pending.length + piece.length > MAX_LINE) { pending = Buffer.alloc(0); discard = true; }
|
|
36
|
+
else if (piece.length) pending = pending.length ? Buffer.concat([pending, piece]) : Buffer.from(piece);
|
|
37
|
+
}
|
|
38
|
+
if (newline >= 0) {
|
|
39
|
+
if (!discard && pending.length) this.consume(pending.toString('utf8'));
|
|
40
|
+
pending = Buffer.alloc(0); discard = false; offset = newline + 1;
|
|
41
|
+
} else offset = end;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
child.on('error', error => this.fail(error));
|
|
45
|
+
child.on('close', () => this.fail(new CodexAppServerError('closed', 'Codex app-server closed.')));
|
|
46
|
+
// Diagnostics can contain private data. Drain, but never retain or expose them.
|
|
47
|
+
child.stderr?.resume();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
consume(line) {
|
|
51
|
+
if (Buffer.byteLength(line) > MAX_LINE) return;
|
|
52
|
+
let message;
|
|
53
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
54
|
+
if (typeof message.method === 'string') {
|
|
55
|
+
for (const listener of this.listeners) listener(message.method, message.params || {}, message.id);
|
|
56
|
+
} else if (message.id !== undefined) {
|
|
57
|
+
const pending = this.pending.get(message.id);
|
|
58
|
+
if (!pending) return;
|
|
59
|
+
this.pending.delete(message.id);
|
|
60
|
+
clearTimeout(pending.timer);
|
|
61
|
+
if (message.error) pending.reject(new CodexAppServerError(message.error.code, String(message.error.message || 'Codex app-server request failed.').slice(0, 300)));
|
|
62
|
+
else pending.resolve(message.result);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fail(error) {
|
|
67
|
+
if (this.closed) return;
|
|
68
|
+
this.closed = true;
|
|
69
|
+
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
|
|
70
|
+
this.pending.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
request(method, params = {}, timeout = REQUEST_TIMEOUT) {
|
|
74
|
+
if (this.closed) return Promise.reject(new CodexAppServerError('closed', 'Codex app-server closed.'));
|
|
75
|
+
const id = ++this.nextId;
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new CodexAppServerError('timeout', `${method} timed out.`)); }, timeout);
|
|
78
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
79
|
+
this.child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, error => {
|
|
80
|
+
if (!error) return;
|
|
81
|
+
const pending = this.pending.get(id);
|
|
82
|
+
if (pending) { clearTimeout(pending.timer); this.pending.delete(id); reject(error); }
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
notify(method, params = {}) {
|
|
88
|
+
if (!this.closed) this.child.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onNotification(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
92
|
+
|
|
93
|
+
async close() {
|
|
94
|
+
if (this.child.exitCode !== null) return;
|
|
95
|
+
this.child.stdin.end();
|
|
96
|
+
this.child.kill('SIGTERM');
|
|
97
|
+
await new Promise(resolve => {
|
|
98
|
+
const timer = setTimeout(() => { this.child.kill('SIGKILL'); resolve(); }, 2_000);
|
|
99
|
+
this.child.once('close', () => { clearTimeout(timer); resolve(); });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function supportsCodexWriterLock(version) {
|
|
105
|
+
const match = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
106
|
+
if (!match) return false;
|
|
107
|
+
const parts = match.slice(1).map(Number);
|
|
108
|
+
return parts[0] > 0 || parts[1] > 158 || parts[1] === 158 && parts[2] >= 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function openCodexAppServer({ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn, signal } = {}) {
|
|
112
|
+
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.');
|
|
113
|
+
const inspection = await inspectExecutable('codex');
|
|
114
|
+
if (!inspection?.available || !inspection.executable) throw new CodexAppServerError('unavailable', 'Codex executable is unavailable.');
|
|
115
|
+
if (!supportsCodexWriterLock(inspection.installed_version)) throw new CodexAppServerError('unsupported', 'Codex version does not have a verified app-server writer lock.');
|
|
116
|
+
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.');
|
|
117
|
+
// Keep the owned writer alive long enough to interrupt a cancelled turn and
|
|
118
|
+
// restore a temporary read-only override. The caller's signal is checked
|
|
119
|
+
// before spawn and by the turn watcher below.
|
|
120
|
+
const child = spawnImpl(inspection.executable, ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
121
|
+
const client = new CodexAppServerClient(child);
|
|
122
|
+
try {
|
|
123
|
+
await client.request('initialize', { clientInfo: { name: 'simy_session_supervision', title: 'SIMY Session Supervision', version: '1.0.0' } });
|
|
124
|
+
client.notify('initialized');
|
|
125
|
+
return client;
|
|
126
|
+
} catch (error) { await client.close(); throw error; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function codexThreadMetadata(thread) {
|
|
130
|
+
if (!thread || typeof thread.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(thread.id) || typeof thread.cwd !== 'string' || !thread.cwd || thread.ephemeral) return null;
|
|
131
|
+
if (thread.parentThreadId) return null;
|
|
132
|
+
return {
|
|
133
|
+
provider: 'codex', session: thread.id, cwd: thread.cwd,
|
|
134
|
+
title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
|
|
135
|
+
source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
|
|
136
|
+
lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
|
|
137
|
+
model: typeof thread.model === 'string' ? thread.model : null,
|
|
138
|
+
observation: { source: 'codex_app_server', observedAt: Date.now() },
|
|
139
|
+
availability: 'unknown',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function listCodexSessions({ client, maxPages = 40, pageSize = 5 } = {}) {
|
|
144
|
+
const sessions = [];
|
|
145
|
+
let cursor = null;
|
|
146
|
+
for (let page = 0; page < maxPages; page++) {
|
|
147
|
+
const response = await client.request('thread/list', { cursor, limit: pageSize, sourceKinds: SOURCE_KINDS, archived: false });
|
|
148
|
+
for (const thread of response?.data || []) {
|
|
149
|
+
const metadata = codexThreadMetadata(thread);
|
|
150
|
+
if (metadata) sessions.push(metadata);
|
|
151
|
+
}
|
|
152
|
+
cursor = response?.nextCursor || null;
|
|
153
|
+
if (!cursor) break;
|
|
154
|
+
}
|
|
155
|
+
return sessions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function readCodexSession(client, session) {
|
|
159
|
+
const response = await client.request('thread/read', { threadId: session, includeTurns: false });
|
|
160
|
+
const metadata = codexThreadMetadata(response?.thread);
|
|
161
|
+
if (!metadata || metadata.session !== session) throw new CodexAppServerError('identity', 'Exact Codex session metadata is unavailable.');
|
|
162
|
+
return metadata;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isCodexWriterBusy(error) {
|
|
166
|
+
return error instanceof CodexAppServerError && error.code === -32600 && /already has an active writer/i.test(error.message);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {} } = {}) {
|
|
170
|
+
const run = record.run, decision = run.managed?.decision;
|
|
171
|
+
if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
|
|
172
|
+
if (!['read', 'local_edit', 'local_test'].includes(decision.action?.kind)) throw new CodexAppServerError('manual_provider_action', 'This action must be completed in the original provider conversation.');
|
|
173
|
+
const approved = run.managed.approvedAction;
|
|
174
|
+
const authorizedWait = decision.outcome === 'wait' && approved?.id === decision.id && !approved.dispatchedAt &&
|
|
175
|
+
JSON.stringify(approved.action) === JSON.stringify(decision.action);
|
|
176
|
+
if (decision.outcome === 'wait' && !authorizedWait) throw new CodexAppServerError('approval', 'The exact assessed action needs an unused scoped approval.');
|
|
177
|
+
const client = await openClient({ signal });
|
|
178
|
+
let originalSandbox = null, overrideStarted = false, activeTurnId = null, turnCompleted = false, turnStart = null;
|
|
179
|
+
let primaryError = null;
|
|
180
|
+
try {
|
|
181
|
+
const metadata = await readCodexSession(client, record.session);
|
|
182
|
+
if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== run.managed.resumeModel) throw new CodexAppServerError('identity', 'Codex session workspace or model changed.');
|
|
183
|
+
let resumed;
|
|
184
|
+
try { resumed = await client.request('thread/resume', { threadId: record.session, excludeTurns: true }); }
|
|
185
|
+
catch (error) { if (isCodexWriterBusy(error)) return { code: -3, busy: true }; throw error; }
|
|
186
|
+
if (resumed?.thread?.id !== record.session || !resumed.cwd || await realpath(resumed.cwd) !== record.cwd || resumed.model !== run.managed.resumeModel) throw new CodexAppServerError('identity', 'Codex resumed a different session configuration.');
|
|
187
|
+
if (!canRestoreSandboxExactly(resumed.sandbox)) throw new CodexAppServerError('permission_restore', 'This Codex sandbox has custom rights that cannot be restored after a managed turn. Use the original conversation.');
|
|
188
|
+
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex dispatch was cancelled.');
|
|
189
|
+
await beforeDispatch(resumed.sandbox);
|
|
190
|
+
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex dispatch was cancelled.');
|
|
191
|
+
const context = { taskId: run.managed.taskId, objective: run.objective, acceptanceCriteria: run.managed.acceptanceCriteria,
|
|
192
|
+
decision, diagnosis: run.managed.plan?.diagnosis, remainingWork: run.managed.remainingWork, nextStep: run.nextStep,
|
|
193
|
+
enrolledScopes: run.managed.approvalScopes,
|
|
194
|
+
...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
|
|
195
|
+
budgets: { deadline: run.deadline, continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures } };
|
|
196
|
+
const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
|
|
197
|
+
const prompt = run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
|
|
198
|
+
const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
|
|
199
|
+
const currentSandbox = resumed.sandbox;
|
|
200
|
+
originalSandbox = currentSandbox;
|
|
201
|
+
let sandboxPolicy = { type: 'readOnly', networkAccess: false };
|
|
202
|
+
if (decision.action.kind === 'local_edit') {
|
|
203
|
+
if (!canWriteWorkspace(initialSandbox, record.cwd) || !canWriteWorkspace(currentSandbox, record.cwd)) throw new CodexAppServerError('permission_narrowed', 'Current or enrolled Codex sandbox does not allow this local edit.');
|
|
204
|
+
sandboxPolicy = { type: 'workspaceWrite', writableRoots: [record.cwd], networkAccess: false,
|
|
205
|
+
excludeTmpdirEnvVar: true, excludeSlashTmp: true };
|
|
206
|
+
}
|
|
207
|
+
const done = new Promise((resolve, reject) => {
|
|
208
|
+
let turnId = null, completion = null, settled = false;
|
|
209
|
+
const messages = new Map();
|
|
210
|
+
const cleanup = () => { unsubscribe(); signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
|
|
211
|
+
const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
|
|
212
|
+
const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
|
|
213
|
+
const closed = () => finish(new CodexAppServerError('closed', 'Codex app-server closed before turn completion.'));
|
|
214
|
+
const complete = () => {
|
|
215
|
+
if (!completion || !turnId || completion.turn?.id !== turnId) return;
|
|
216
|
+
turnCompleted = true;
|
|
217
|
+
if (completion.turn.status !== 'completed') return finish(new CodexAppServerError('turn_failed', 'Codex turn did not complete.'));
|
|
218
|
+
const message = messages.get(turnId);
|
|
219
|
+
if (!message || Buffer.byteLength(message) > 16 * 1024) return finish(new CodexAppServerError('checkpoint', 'Codex did not return a bounded checkpoint.'));
|
|
220
|
+
try { finish(null, JSON.parse(message)); } catch { finish(new CodexAppServerError('checkpoint', 'Codex final message was not a JSON checkpoint.')); }
|
|
221
|
+
};
|
|
222
|
+
const unsubscribe = client.onNotification((method, params, requestId) => {
|
|
223
|
+
if (requestId !== undefined && /requestApproval|requestPermissions|requestUserInput/i.test(method)) return finish(new CodexAppServerError('provider_approval', 'Codex requires provider approval or user input.'));
|
|
224
|
+
if (params.threadId !== record.session) return;
|
|
225
|
+
if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string' && typeof params.turnId === 'string') messages.set(params.turnId, params.item.text);
|
|
226
|
+
if (method === 'turn/completed') { completion = params; if (params.turn?.id === turnId) turnCompleted = true; complete(); }
|
|
227
|
+
});
|
|
228
|
+
signal?.addEventListener('abort', cancelled, { once: true });
|
|
229
|
+
client.child.once('close', closed);
|
|
230
|
+
overrideStarted = true;
|
|
231
|
+
turnStart = client.request('turn/start', { threadId: record.session, input: [{ type: 'text', text: prompt }], cwd: record.cwd,
|
|
232
|
+
model: run.managed.resumeModel, sandboxPolicy })
|
|
233
|
+
.then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else complete(); })
|
|
234
|
+
.catch(error => finish(error));
|
|
235
|
+
});
|
|
236
|
+
const checkpoint = await done;
|
|
237
|
+
return { code: 0, checkpoint };
|
|
238
|
+
} catch (error) {
|
|
239
|
+
primaryError = error;
|
|
240
|
+
throw error;
|
|
241
|
+
} finally {
|
|
242
|
+
if (overrideStarted && originalSandbox && !client.closed) {
|
|
243
|
+
// Interrupt a cancelled turn before releasing our writer. Codex may
|
|
244
|
+
// report its turn override while this writer is loaded, so verify the
|
|
245
|
+
// durable policy only after the writer is fully closed.
|
|
246
|
+
if (turnStart && !activeTurnId) await Promise.race([turnStart, new Promise(resolve => setTimeout(resolve, 2_000))]).catch(() => {});
|
|
247
|
+
if (!turnCompleted && activeTurnId) await client.request('turn/interrupt', { threadId: record.session, turnId: activeTurnId }, 2_000).catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
await client.close();
|
|
250
|
+
if (overrideStarted && originalSandbox) {
|
|
251
|
+
let verified = false, verifier;
|
|
252
|
+
try {
|
|
253
|
+
verifier = await openClient({});
|
|
254
|
+
const durable = await verifier.request('thread/resume', { threadId: record.session, excludeTurns: true });
|
|
255
|
+
verified = durable?.thread?.id === record.session && durable.model === run.managed.resumeModel &&
|
|
256
|
+
durable.cwd && await realpath(durable.cwd) === record.cwd && sameSandboxRights(durable.sandbox, originalSandbox);
|
|
257
|
+
} catch { /* A busy or unavailable verifier leaves restoration unconfirmed. */ }
|
|
258
|
+
finally { await verifier?.close().catch(() => {}); }
|
|
259
|
+
if (!verified) {
|
|
260
|
+
if (primaryError) primaryError.restorationUnverified = true;
|
|
261
|
+
else throw new CodexAppServerError('permission_restore', 'Codex could not confirm the original provider sandbox after the managed turn. Inspect the original conversation.');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function canWriteWorkspace(sandbox, cwd) {
|
|
268
|
+
if (sandbox?.type === 'dangerFullAccess') return true;
|
|
269
|
+
if (sandbox?.type !== 'workspaceWrite' || !Array.isArray(sandbox.writableRoots)) return false;
|
|
270
|
+
// Codex workspaceWrite always includes its verified thread cwd. The roots
|
|
271
|
+
// array lists *additional* writable directories and is usually empty.
|
|
272
|
+
return Boolean(cwd);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function sameSandboxRights(left, right) {
|
|
276
|
+
if (left?.type !== right?.type) return false;
|
|
277
|
+
if (left.type === 'workspaceWrite') return left.networkAccess === right.networkAccess &&
|
|
278
|
+
JSON.stringify(left.writableRoots || []) === JSON.stringify(right.writableRoots || []) &&
|
|
279
|
+
Boolean(left.excludeTmpdirEnvVar) === Boolean(right.excludeTmpdirEnvVar) &&
|
|
280
|
+
Boolean(left.excludeSlashTmp) === Boolean(right.excludeSlashTmp);
|
|
281
|
+
return left.type === 'readOnly' ? left.networkAccess === right.networkAccess : left.type === 'dangerFullAccess';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function canRestoreSandboxExactly(sandbox) {
|
|
285
|
+
if (sandbox?.type === 'workspaceWrite') return sandbox.networkAccess === false &&
|
|
286
|
+
Array.isArray(sandbox.writableRoots) && sandbox.writableRoots.length === 0 &&
|
|
287
|
+
sandbox.excludeTmpdirEnvVar === false && sandbox.excludeSlashTmp === false;
|
|
288
|
+
if (sandbox?.type === 'readOnly') return sandbox.networkAccess === false;
|
|
289
|
+
return sandbox?.type === 'dangerFullAccess';
|
|
290
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { controlDelivery, listRecords, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { listCodexSessions, openCodexAppServer, readCodexSession } from './codex-app-server.js';
|
|
4
|
+
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
5
|
+
import { originalSessionTarget } from './original-session-target.js';
|
|
6
|
+
|
|
7
|
+
function initialDiagnosisPlan(objective, contextBootstrap = false) {
|
|
8
|
+
const action = { kind: 'read', scope: 'task', summary: contextBootstrap ? 'Understand the original user request and current progress without changing files' : 'Inspect the selected conversation and acceptance evidence without changing files' };
|
|
9
|
+
return {
|
|
10
|
+
diagnosis: contextBootstrap ? 'Read the original conversation context to infer the user goal, constraints, progress, and what remains before choosing any work.' : `Establish the current state of this enrolled objective before choosing work: ${objective.slice(0, 500)}`,
|
|
11
|
+
alternatives: [{ id: 'inspect_current_state', action,
|
|
12
|
+
assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
|
|
13
|
+
expectedVerification: contextBootstrap ? 'Return a grounded structured session understanding and a concrete assessed next action, or one focused question.' : 'Report the actual current state and a concrete next assessed action.', rollback: 'No changes to undo.' }],
|
|
14
|
+
selectedOptionId: 'inspect_current_state', recommendation: { optionId: 'inspect_current_state', reason: 'Read-only diagnosis establishes what remains before any change.' },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function withClient(openClient, work) {
|
|
19
|
+
const client = await openClient();
|
|
20
|
+
try { return await work(client); } finally { await client.close(); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function liveClaudeHook(record, metadata, inspectProcess) {
|
|
24
|
+
if (!record || record.provider !== 'claude' || record.session !== metadata?.session ||
|
|
25
|
+
metadata.observation?.source !== 'claude_local_index' || !record.hookStartedAt ||
|
|
26
|
+
!(['SessionStart', 'Stop'].includes(record.lastEvent) || record.lastEvent === 'Notification' && record.lastNotificationType === 'idle_prompt') ||
|
|
27
|
+
!record.process?.identity) return false;
|
|
28
|
+
try {
|
|
29
|
+
if (record.cwd !== await realpath(metadata.cwd)) return false;
|
|
30
|
+
return await inspectProcess(record.process.pid) === record.process.identity;
|
|
31
|
+
} catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
35
|
+
inspectProcess = processIdentity, now = Date.now() } = {}) {
|
|
36
|
+
const records = await listRecords(root);
|
|
37
|
+
let discovered = [], codexError = null, claude = [], claudeError = null;
|
|
38
|
+
try { discovered = await withClient(openClient, client => listCodexSessions({ client })); }
|
|
39
|
+
catch (error) { codexError = error?.code === 'unavailable' ? 'Codex executable unavailable.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable.'; }
|
|
40
|
+
try { claude = await listClaude(); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
|
|
41
|
+
const byKey = new Map();
|
|
42
|
+
for (const item of [...discovered, ...claude]) byKey.set(`${item.provider}:${item.session}`, item);
|
|
43
|
+
for (const record of records) {
|
|
44
|
+
const key = `${record.provider}:${record.session}`;
|
|
45
|
+
if (!byKey.has(key) && (record.observedAt || record.admission || record.monitor || record.run)) byKey.set(key, {
|
|
46
|
+
provider: record.provider, session: record.session, cwd: record.cwd, title: '', source: record.admission?.source || 'hook',
|
|
47
|
+
lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
|
|
48
|
+
observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
|
|
49
|
+
availability: 'unknown', model: record.admission?.model || null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const projected = (await Promise.all([...byKey.values()].map(async item => {
|
|
53
|
+
const record = records.find(r => r.provider === item.provider && r.session === item.session);
|
|
54
|
+
const run = record?.run;
|
|
55
|
+
const mode = run?.managed ? run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' ? 'pending_takeover' : 'managed' : run ? 'standard_loop' : record?.monitor ? 'monitor' : 'unmanaged';
|
|
56
|
+
const supported = item.provider === 'codex' && item.observation.source === 'codex_app_server' && Boolean(item.model) && !codexError;
|
|
57
|
+
const liveHook = item.provider === 'claude' && !claudeError && await liveClaudeHook(record, item, inspectProcess);
|
|
58
|
+
return { provider: item.provider, session: item.session, cwd: item.cwd, title: item.title, source: item.source,
|
|
59
|
+
lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
|
|
60
|
+
binding: { mode, taskId: run?.managed?.taskId || null },
|
|
61
|
+
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
62
|
+
continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
|
|
63
|
+
capabilities: { canMonitor: item.capabilities?.canMonitor ?? true, canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run, canAutoContinue: supported || liveHook },
|
|
64
|
+
reason: item.provider === 'claude' ? liveHook ? 'Live SIMY hook can continue this Claude session at its next Stop boundary; cold CLI takeover is unavailable.' :
|
|
65
|
+
process.platform === 'win32' ? 'Claude live hook enrollment is unavailable on Windows because provider process identity cannot be verified; monitoring remains available.' :
|
|
66
|
+
'Claude enrollment needs a live matching SIMY SessionStart hook and provider process; cold CLI takeover is unavailable.' :
|
|
67
|
+
codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
|
|
68
|
+
};
|
|
69
|
+
}))).sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
70
|
+
// Keep existing bindings visible during a truncated discovery or provider
|
|
71
|
+
// outage, even when newer unbound conversations fill the candidate window.
|
|
72
|
+
const bound = projected.filter(item => item.binding.mode !== 'unmanaged');
|
|
73
|
+
const sessions = [...bound, ...projected.filter(item => item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
|
|
74
|
+
return { sessions, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function monitorSession({ root, provider, session, monitor = true, openClient = openCodexAppServer, readClaude = readClaudeSession, now = Date.now() }) {
|
|
78
|
+
if (!['codex', 'claude'].includes(provider)) throw new Error('Unsupported provider.');
|
|
79
|
+
if (!monitor) {
|
|
80
|
+
await updateRecord(root, provider, session, previous => {
|
|
81
|
+
if (!previous || previous.run) throw new Error('Stop the managed task before removing its monitor.');
|
|
82
|
+
previous.monitor = false;
|
|
83
|
+
return previous;
|
|
84
|
+
});
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const metadata = provider === 'codex' ? await withClient(openClient, client => readCodexSession(client, session)) : await readClaude(session);
|
|
88
|
+
if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
|
|
89
|
+
const cwd = await realpath(metadata.cwd);
|
|
90
|
+
await updateRecord(root, provider, session, previous => {
|
|
91
|
+
if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
|
|
92
|
+
return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), monitor,
|
|
93
|
+
admission: { source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
|
|
94
|
+
});
|
|
95
|
+
return metadata;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
|
|
99
|
+
maxMinutes = 30, maxContinuations = 6, maxFailures = 3, openClient = openCodexAppServer,
|
|
100
|
+
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
|
|
101
|
+
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
102
|
+
if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
|
|
103
|
+
if (!['autonomous', 'balanced', 'strict'].includes(autonomyPolicy)) throw new Error('Choose an autonomy policy.');
|
|
104
|
+
let metadata;
|
|
105
|
+
if (provider === 'claude') {
|
|
106
|
+
metadata = await readClaude(session);
|
|
107
|
+
const record = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
|
|
108
|
+
if (!await liveClaudeHook(record, metadata, inspectProcess)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
|
|
109
|
+
if (record.run) throw new Error('This session already has a Delivery Loop.');
|
|
110
|
+
await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, now });
|
|
111
|
+
// A second check narrows the time between metadata admission and the
|
|
112
|
+
// original provider's next Stop boundary. Process exit still remains a
|
|
113
|
+
// manual recovery condition; SIMY never starts claude -r.
|
|
114
|
+
if (!await liveClaudeHook((await listRecords(root)).find(item => item.provider === provider && item.session === session), metadata, inspectProcess)) throw new Error('Claude provider process exited before enrollment.');
|
|
115
|
+
} else {
|
|
116
|
+
metadata = await monitorSession({ root, provider, session, monitor: true, openClient, now });
|
|
117
|
+
if (!metadata.model) throw new Error('Codex did not expose this conversation’s model; automatic continuation is unavailable.');
|
|
118
|
+
}
|
|
119
|
+
const cwd = await realpath(metadata.cwd);
|
|
120
|
+
const initialPlan = initialDiagnosisPlan(objective.trim(), contextBootstrap);
|
|
121
|
+
const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
|
|
122
|
+
const liveRecord = provider === 'claude' ? (await listRecords(root)).find(item => item.provider === provider && item.session === session) : null;
|
|
123
|
+
const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
124
|
+
maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess,
|
|
125
|
+
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
126
|
+
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
127
|
+
initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
|
|
128
|
+
autoRecovery: provider === 'codex', resumeChannel: provider === 'codex' ? 'codex_app_server' : null,
|
|
129
|
+
resumeModel: metadata.model } });
|
|
130
|
+
return { provider, session, taskId: run.run.managed.taskId, state: run.run.state, deadline: run.run.deadline };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function continueSession(options) {
|
|
134
|
+
return enrollSession({ ...options, objective: 'Understand and continue the selected conversation’s current user request.',
|
|
135
|
+
acceptanceCriteria: [], contextBootstrap: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function answerSession({ root, provider, session, taskId, questionId, answer, now = Date.now() }) {
|
|
139
|
+
return controlDelivery({ root, provider, session, taskId, questionId, answer, action: 'session_answer', now });
|
|
140
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2
|
+
|
|
3
|
+
// Navigation never records an approval, resumes a provider, or acquires a
|
|
4
|
+
// writer. The installed macOS Codex app registers codex: and itself generates
|
|
5
|
+
// codex://threads/<id> conversation links. Other handlers are not verified.
|
|
6
|
+
export function originalSessionTarget(provider, session, { platform = process.platform } = {}) {
|
|
7
|
+
if (provider === 'codex' && typeof session === 'string' && session.length === 36 && UUID.test(session)) return {
|
|
8
|
+
kind: 'url', url: `codex://threads/${encodeURIComponent(session)}`, label: 'Open in Codex',
|
|
9
|
+
verifiedOnPlatform: platform === 'darwin',
|
|
10
|
+
reason: platform === 'darwin' ? null : 'Codex link handling is not verified on this platform; use the exact session ID if opening fails.',
|
|
11
|
+
};
|
|
12
|
+
if (provider === 'claude') return {
|
|
13
|
+
kind: 'manual', url: null, label: 'Switch to Claude', verifiedOnPlatform: false,
|
|
14
|
+
reason: 'Return to the original Claude window. No verified direct link exists; opening a second CLI process could conflict with the live session.',
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
kind: 'manual', url: null, label: 'Find original conversation', verifiedOnPlatform: false,
|
|
18
|
+
reason: 'No verified exact-conversation link is available for this session.',
|
|
19
|
+
};
|
|
20
|
+
}
|
package/src/windows-update.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
7
|
|
|
7
8
|
const exec = promisify(execFile);
|
|
8
9
|
export const WINDOWS_MANIFEST_URL = "https://simy.one/downloads/simy-cli/windows/latest.json";
|
|
@@ -50,7 +51,32 @@ export async function downloadWindowsArtifact(item, destination, fetchImpl = fet
|
|
|
50
51
|
if (size !== item.size || createHash('sha256').update(bytes).digest('hex') !== item.sha256) throw new Error('Update checksum mismatch');
|
|
51
52
|
await writeFile(destination, bytes);
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
+
async function pathExists(file) {
|
|
55
|
+
try { await stat(file); return true; }
|
|
56
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
57
|
+
}
|
|
58
|
+
// Windows may briefly keep an extracted or health-probed executable open after
|
|
59
|
+
// its process closes. Retry only that promotion rename, while the verified
|
|
60
|
+
// source still exists and no other writer has created the immutable target.
|
|
61
|
+
export async function promoteWindowsCandidate(source, target, {
|
|
62
|
+
renameImpl = rename,
|
|
63
|
+
platform = process.platform,
|
|
64
|
+
retryDelaysMs = [250, 500, 1000, 2000, 4000, 8000, 16000],
|
|
65
|
+
sleep = delay,
|
|
66
|
+
} = {}) {
|
|
67
|
+
for (let attempt = 0; ; attempt++) {
|
|
68
|
+
try { await renameImpl(source, target); return; }
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) ||
|
|
71
|
+
attempt >= retryDelaysMs.length || !(await pathExists(source)) || await pathExists(target)) throw error;
|
|
72
|
+
await sleep(retryDelaysMs[attempt]);
|
|
73
|
+
// A competing publisher may have created the immutable target while we
|
|
74
|
+
// waited. Do not let the next rename replace it.
|
|
75
|
+
if (!(await pathExists(source)) || await pathExists(target)) throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function applyWindowsUpdate({ installation, stateRoot, release, fetchImpl = fetch, verifyCandidate = verifyWindowsCandidate, extract = extractWindowsZip, promote = promoteWindowsCandidate }) {
|
|
54
80
|
validateWindowsRelease(release);
|
|
55
81
|
const current = await selectedWindowsRoot(installation, stateRoot);
|
|
56
82
|
const currentVersion = (await json(path.join(current, 'cli/package.json'))).version;
|
|
@@ -70,7 +96,7 @@ export async function applyWindowsUpdate({ installation, stateRoot, release, fet
|
|
|
70
96
|
await mkdir(path.dirname(target), { recursive: true });
|
|
71
97
|
// Immutable directory: never replace a running or previously staged version.
|
|
72
98
|
try { await stat(target); throw new Error('Release directory already exists'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
73
|
-
await
|
|
99
|
+
await promote(candidate, target);
|
|
74
100
|
await atomicJson(path.join(stateRoot, 'current.json'), { version: release.version, previous: currentVersion });
|
|
75
101
|
return target;
|
|
76
102
|
} finally { await rm(staging, { recursive: true, force: true }); }
|