@awak-app/simy-cli 0.7.10 → 0.7.12

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.
Files changed (42) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/agent.js +47 -7
  4. package/src/auto-update.js +32 -9
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  8. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  9. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  10. package/src/delivery-hooks/desktop.js +1 -1
  11. package/src/delivery-hooks/supervisor.js +39 -10
  12. package/src/desktop-host.js +90 -15
  13. package/src/desktop-status.js +1 -1
  14. package/src/direct-executor.js +104 -4
  15. package/src/local-task.js +14 -3
  16. package/src/provider-stream.js +3 -0
  17. package/src/runner.js +19 -4
  18. package/src/session-supervision/claude-desktop-lineage.js +19 -5
  19. package/src/session-supervision/claude-sessions.js +73 -15
  20. package/src/session-supervision/codex-app-server.js +28 -9
  21. package/src/session-supervision/index.js +25 -14
  22. package/src/session-supervision/pull-requests.js +52 -0
  23. package/src/session-supervision/work-state.js +84 -0
  24. package/src/sqm/claude-plugin.js +5 -0
  25. package/src/sqm/review-worker.js +15 -6
  26. package/src/update-detachment/claude-observation.js +33 -0
  27. package/src/update-detachment/codex-context.js +27 -0
  28. package/src/update-detachment/coordinator.js +117 -0
  29. package/src/update-detachment/dispatch-gate.js +1 -0
  30. package/src/update-detachment/ledger-adapter.js +62 -0
  31. package/src/update-detachment/ledger-reader.js +42 -0
  32. package/src/update-detachment/provenance.js +1 -0
  33. package/src/update-detachment/provider-adapters.js +33 -0
  34. package/src/update-detachment/reconcile.js +105 -0
  35. package/src/update-detachment/reconciliation-adapter.js +13 -0
  36. package/src/update-detachment/runtime.js +319 -0
  37. package/src/update-detachment/state-store.js +1 -0
  38. package/src/update-detachment/worker-entry.js +145 -0
  39. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  40. package/src/update-detachment/worker-ledger.js +138 -0
  41. package/src/update-detachment/worker-storage.js +29 -0
  42. package/src/update-detachment/worker.js +167 -0
@@ -0,0 +1,326 @@
1
+ import { mkdirSync, realpathSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
2
+ import { registerDispatchProvenance, normalizeDispatchIdentity } from './update-dispatch-identity.mjs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { readState, createStateStore, atomicSave, withLock, acquireLock, fail, digest, isMap, CONNECTION_STATES } from './update-state-store.mjs';
7
+
8
+ export const DISPATCH_PROTOCOL = 3;
9
+ export const DISPATCH_MODULE_DIGEST = createHash('sha256').update(readFileSync(new URL(import.meta.url))).digest('hex');
10
+ export const detachmentRoot = () => resolve(process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'));
11
+ export const rootFromDelivery = root => basename(resolve(root)) === 'delivery-hooks' ? dirname(resolve(root)) : resolve(root);
12
+ const path = root => join(resolve(root), 'update-detachment', 'gate.json');
13
+ function read(root) {
14
+ const state = readState(path(root), { versions: [1, 2] }) || { schemaVersion: 1, revision: 0, generation: 0, connection: 'connected', attempts: {}, receipts: {}, events: {} };
15
+ if (!Number.isSafeInteger(state.generation) || !Number.isSafeInteger(state.revision) || !CONNECTION_STATES.includes(state.connection) || !['attempts', 'receipts', 'events'].every(key => isMap(state[key])) || (state.hookOwners !== undefined && !isMap(state.hookOwners)) || !Object.values(state.attempts).every(attempt => isMap(attempt) && typeof attempt.id === 'string' && Number.isSafeInteger(attempt.generation) && ['intent','sending','sent','provider_identified','result_saved','confirmed'].includes(attempt.phase))) fail('invalid_state');
16
+ if ((state.schemaVersion === 2 && ![2, 3].includes(state.minimumDispatchProtocol)) || (state.minimumDispatchProtocol !== undefined && ![2, 3].includes(state.minimumDispatchProtocol))) fail('writer_protocol_incompatible');
17
+ validateTaskFences(state, root);
18
+ return state;
19
+ }
20
+ const owners = new Map();
21
+ export function captureDispatchOwner(root = detachmentRoot()) {
22
+ mkdirSync(root, { recursive: true, mode: 0o700 }); root = realpathSync(root); const state = read(root);
23
+ if (!owners.has(root)) owners.set(root, Object.freeze({ root, generation: state.generation, protocol: DISPATCH_PROTOCOL }));
24
+ return owners.get(root);
25
+ }
26
+ export function readDispatchGate(root = detachmentRoot()) { return read(root); }
27
+ export function transitionConnection({ root = detachmentRoot(), expectedRevision, connection, eventId }) {
28
+ return withLock(path(root), () => {
29
+ const state = read(root);
30
+ if (eventId && state.events[digest(eventId)]) return state;
31
+ if (state.revision !== expectedRevision) fail('revision_conflict');
32
+ const allowed = { connected: ['detaching'], ready: ['connected', 'detaching'], detaching: ['detached', 'recovery_required'], detached: ['reconciling'], reconciling: ['ready', 'recovery_required'], recovery_required: ['reconciling', 'detaching'] };
33
+ if (!allowed[state.connection]?.includes(connection)) fail('invalid_transition');
34
+ if (connection === 'detaching') { state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL; }
35
+ state.connection = connection; state.revision++;
36
+ if (eventId) state.events[digest(eventId)] = { revision: state.revision };
37
+ atomicSave(path(root), state); return state;
38
+ });
39
+ }
40
+ function applyReceipt(state, { attemptId, receiptId, providerExecutionId = null, resultRef = null }) {
41
+ if (![attemptId, receiptId].every(value => typeof value === 'string' && value.length > 0 && value.length <= 1024) || [providerExecutionId, resultRef].some(value => value !== null && (typeof value !== 'string' || !value || value.length > 2048))) fail('receipt_invalid');
42
+ const attempt = state.attempts[digest(attemptId)]; if (!attempt) fail('attempt_unknown');
43
+ const key = digest(receiptId), prior = state.receipts[key];
44
+ if (prior) { if (prior.attemptId !== attemptId || prior.providerExecutionId !== providerExecutionId || prior.resultRef !== resultRef) fail('receipt_conflict'); return false; }
45
+ state.receipts[key] = { attemptId, providerExecutionId, resultRef, generation: attempt.generation };
46
+ if (attempt.phase !== 'confirmed' && (resultRef || attempt.phase !== 'result_saved')) attempt.phase = resultRef ? 'result_saved' : 'provider_identified';
47
+ return true;
48
+ }
49
+ export function recordDispatchReceipt({ root = detachmentRoot(), ...receipt }) {
50
+ return withLock(path(root), () => {
51
+ const state = read(root);
52
+ if (applyReceipt(state, receipt)) { state.revision++; atomicSave(path(root), state); }
53
+ return state;
54
+ });
55
+ }
56
+ export function queueDispatchReceipt({ root = detachmentRoot(), ...receipt }) {
57
+ // Validate without mutating the live ledger before persisting intake.
58
+ applyReceipt(structuredClone(read(root)), receipt);
59
+ const item = { schemaVersion: 1, kind: 'receipt', ...receipt };
60
+ atomicSave(join(root, 'update-detachment', 'intake', `${digest(item)}.json`), item);
61
+ try { return flushDispatchIntake(root); } catch (error) { if (error.code !== 'state_busy') throw error; return null; }
62
+ }
63
+ export function confirmDispatch({ root = detachmentRoot(), attemptId }) {
64
+ return withLock(path(root), () => { const state = read(root), attempt = state.attempts[digest(attemptId)];
65
+ if (!attempt || !Object.values(state.receipts).some(item => item.attemptId === attemptId && item.resultRef)) fail('result_required');
66
+ attempt.phase = 'confirmed'; state.revision++; atomicSave(path(root), state); return state; });
67
+ }
68
+ export function dispatchWithFence(owner, { taskId, attemptId = randomUUID(), identity = null, provenance = null }, send) {
69
+ if (!owner || typeof send !== 'function' || typeof taskId !== 'string' || !taskId || typeof attemptId !== 'string' || !attemptId) fail('dispatch_identity_required');
70
+ return withLock(path(owner.root), () => {
71
+ const state = read(owner.root);
72
+ if (!['connected', 'ready'].includes(state.connection)) fail('dispatch_detached');
73
+ if (state.minimumDispatchProtocol && (owner.protocol !== DISPATCH_PROTOCOL || owner.protocol < state.minimumDispatchProtocol)) fail('writer_protocol_incompatible');
74
+ if (owner.generation !== state.generation) fail('owner_fenced');
75
+ if (provenance?.identity) identity = registerDispatchProvenance(provenance);
76
+ else if (identity) identity = normalizeDispatchIdentity(identity);
77
+ if (identity && identity.root !== resolve(owner.root)) fail('identity_root_mismatch');
78
+ assertTaskAdmissionState(state, { taskId, identity });
79
+ const key = digest(attemptId); if (Object.hasOwn(state.attempts, key)) fail('attempt_already_recorded');
80
+ state.attempts[key] = { id: attemptId, taskHash: digest(taskId), identityHash: identity ? digest(identity) : null, ...(provenance ? { provenance: { taskId: provenance.taskId, recordRef: provenance.recordRef, context: provenance.context, providerSession: provenance.providerSession }, identityStatus: identity ? 'bound' : provenance.context.account && provenance.context.device ? 'awaiting_provider_session' : 'unavailable' } : {}), generation: state.generation, phase: 'intent' };
81
+ state.revision++; atomicSave(path(owner.root), state);
82
+ state.attempts[key].phase = 'sending'; state.revision++; atomicSave(path(owner.root), state);
83
+ // Initiation must be synchronous: no await may separate this check and the
84
+ // transport write/spawn. Promise completion belongs to the result intake.
85
+ const value = send(attemptId);
86
+ return value;
87
+ }, { preserveResult: true });
88
+ }
89
+
90
+ // Hook state evaluation and stdout emission share the update barrier. Lifecycle
91
+ // intake continues while detached, but it must not consume continuation budgets.
92
+ export async function fenceHook(owner, taskId, evaluate, emit = null, beforeEmit = () => true, dispatchContext = () => null) {
93
+ const release = acquireLock(path(owner.root));
94
+ try {
95
+ const state = read(owner.root);
96
+ const initialContext = dispatchContext();
97
+ let admitted = true;
98
+ try { assertTaskAdmissionState(state, { taskId: initialContext?.taskId || taskId, identity: initialContext?.identity || initialContext?.provenance?.identity }); }
99
+ catch (error) { if (!['task_admission_blocked', 'task_admission_identity_required'].includes(error.code)) throw error; admitted = false; }
100
+ const allowed = admitted && ['connected', 'ready'].includes(state.connection) && owner.generation === state.generation && (!state.minimumDispatchProtocol || owner.protocol === DISPATCH_PROTOCOL);
101
+ const output = await evaluate(!allowed);
102
+ if (!allowed) { if (emit) emit({}); return {}; }
103
+ if (output?.decision === 'block') {
104
+ const context = dispatchContext(), attemptId = context?.attemptId || randomUUID();
105
+ const identity = context?.provenance?.identity ? registerDispatchProvenance(context.provenance) : null;
106
+ assertTaskAdmissionState(state, { taskId: context?.taskId || taskId, identity });
107
+ if (state.attempts[digest(attemptId)]) fail('attempt_already_recorded');
108
+ state.attempts[digest(attemptId)] = { id: attemptId, taskHash: digest(context?.taskId || taskId), identityHash: identity ? digest(identity) : null, generation: state.generation, phase: 'sending' };
109
+ state.revision++; atomicSave(path(owner.root), state);
110
+ }
111
+ if (!beforeEmit()) { if (emit) emit({}); return {}; }
112
+ if (emit) emit(output);
113
+ return output;
114
+ } finally { release(); }
115
+ }
116
+
117
+ // The provider remains live while hook invocations launch new Node processes.
118
+ // Bind its session to the first observed generation durably, not to hook PID.
119
+ export function captureHookOwner(root, provider, session, writer = null) {
120
+ return withLock(path(root), () => {
121
+ const state = read(root), key = digest([provider, session]);
122
+ state.hookOwners ||= {};
123
+ if (!Object.hasOwn(state.hookOwners, key)) {
124
+ state.hookOwners[key] = state.generation; state.revision++; atomicSave(path(root), state);
125
+ }
126
+ if (writer?.pid > 1 && writer.identity) {
127
+ state.writerCapabilities ||= {};
128
+ state.writerCapabilities[key] = { protocol: DISPATCH_PROTOCOL, pid: writer.pid, processIdentity: writer.identity, moduleDigest: DISPATCH_MODULE_DIGEST, generation: state.hookOwners[key] };
129
+ state.revision++; atomicSave(path(root), state);
130
+ }
131
+ return { root: realpathSync(root), generation: state.hookOwners[key], protocol: DISPATCH_PROTOCOL };
132
+ });
133
+ }
134
+ export function adoptHookOwner({ root = detachmentRoot(), provider, session, expectedRevision }) {
135
+ return withLock(path(root), () => {
136
+ const state = read(root);
137
+ if (state.revision !== expectedRevision) fail('revision_conflict');
138
+ if (state.connection !== 'reconciling') fail('reconciliation_required');
139
+ state.hookOwners ||= {}; state.hookOwners[digest([provider, session])] = state.generation;
140
+ state.revision++; atomicSave(path(root), state); return state;
141
+ });
142
+ }
143
+
144
+ // Bind only an actual Provider-reported session; never substitute a task ID.
145
+ function applyIdentityIntake(state, item) {
146
+ const attempt = state.attempts[digest(item.attemptId)];
147
+ if (!attempt?.provenance) fail('provenance_unavailable');
148
+ if (attempt.provenance.providerSession && attempt.provenance.providerSession !== item.providerSession) fail('provider_session_mismatch');
149
+ const identity = normalizeDispatchIdentity({ ...attempt.provenance.context, session: item.providerSession });
150
+ if (attempt.identityHash && attempt.identityHash !== digest(identity)) fail('provider_session_mismatch');
151
+ registerDispatchProvenance(attempt.provenance, item.providerSession);
152
+ attempt.identityHash = digest(identity); attempt.identityStatus = 'bound';
153
+ const key = digest(`identity:${item.attemptId}:${item.providerExecutionId}`);
154
+ state.receipts[key] = { attemptId: item.attemptId, providerExecutionId: item.providerExecutionId, resultRef: null, generation: attempt.generation };
155
+ if (!['result_saved', 'confirmed'].includes(attempt.phase)) attempt.phase = 'provider_identified';
156
+ return identity;
157
+ }
158
+ export function flushDispatchIntake(root = detachmentRoot()) {
159
+ const directory = join(root, 'update-detachment', 'intake');
160
+ return withLock(path(root), () => {
161
+ const state = read(root); let names;
162
+ try { names = readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') return state; throw error; }
163
+ const applied = [];
164
+ for (const name of names) {
165
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
166
+ const item = readState(join(directory, name));
167
+ if (item?.kind === 'receipt') applyReceipt(state, item); else applyIdentityIntake(state, item);
168
+ applied.push(name);
169
+ }
170
+ if (applied.length) { state.revision++; atomicSave(path(root), state); for (const name of applied) unlinkSync(join(directory, name)); }
171
+ return state;
172
+ });
173
+ }
174
+ export function bindDispatchIdentity({ root = detachmentRoot(), attemptId, providerSession, providerExecutionId = providerSession }) {
175
+ const state = read(root), attempt = state.attempts[digest(attemptId)];
176
+ if (!attempt?.provenance) fail('provenance_unavailable');
177
+ if (attempt.provenance.providerSession && attempt.provenance.providerSession !== providerSession) fail('provider_session_mismatch');
178
+ const identity = normalizeDispatchIdentity({ ...attempt.provenance.context, session: providerSession });
179
+ if (attempt.identityHash && attempt.identityHash !== digest(identity)) fail('provider_session_mismatch');
180
+ const item = { schemaVersion: 1, attemptId, providerSession, providerExecutionId };
181
+ // Intake has its own durable file; an async hook holding the send lock must
182
+ // not lose already observed provider evidence or the running child handle.
183
+ atomicSave(join(root, 'update-detachment', 'intake', `${digest(item)}.json`), item);
184
+ try { flushDispatchIntake(root); } catch (error) { if (error.code !== 'state_busy') throw error; }
185
+ return identity;
186
+ }
187
+ export function observeDispatchEvent({ root = detachmentRoot(), attemptId, provider, event, onError = () => {} }) {
188
+ try {
189
+ const providerSession = provider === 'codex' && event?.type === 'thread.started' ? event.thread_id :
190
+ provider === 'claude' && ['system', 'result'].includes(event?.type) ? event.session_id : null;
191
+ if (!providerSession) return null;
192
+ const attempt = read(root).attempts[digest(attemptId)];
193
+ if (!attempt?.provenance?.context.account || !attempt.provenance.context.device) return null;
194
+ return bindDispatchIdentity({ root, attemptId, providerSession });
195
+ } catch (error) { onError(error); return null; }
196
+ }
197
+
198
+ // Callers must enumerate every live writer from actual discovery; absence of a
199
+ // writer report is not proof that old hooks or a rollback binary are safe.
200
+ function validateWriters(state, writers) {
201
+ if (!Array.isArray(writers)) fail('writer_inventory_required');
202
+ for (const writer of writers) {
203
+ if (!writer || !['codex', 'claude'].includes(writer.provider) || !writer.session) fail('writer_identity_required');
204
+ const key = digest([writer.provider, writer.session]);
205
+ const report = state.writerCapabilities?.[key];
206
+ if (!writer?.processIdentity || !Number.isSafeInteger(writer.pid) || !report || report.protocol !== DISPATCH_PROTOCOL || report.pid !== writer.pid || report.processIdentity !== writer.processIdentity || report.moduleDigest !== writer.moduleDigest) fail('writer_protocol_unverified');
207
+ }
208
+ return true;
209
+ }
210
+ export function assertWriterCompatibility({ root = detachmentRoot(), writers }) { return validateWriters(read(root), writers); }
211
+ export function recordWriterCapability({ root = detachmentRoot(), provider, session, pid, processIdentity, moduleDigest }) {
212
+ if (!['codex', 'claude'].includes(provider) || !session || !Number.isSafeInteger(pid) || pid < 2 || !processIdentity || !moduleDigest) fail('writer_identity_required');
213
+ return withLock(path(root), () => {
214
+ const state = read(root); state.writerCapabilities ||= {};
215
+ state.writerCapabilities[digest([provider, session])] = { protocol: DISPATCH_PROTOCOL, pid, processIdentity, moduleDigest, generation: state.generation };
216
+ state.revision++; atomicSave(path(root), state); return state;
217
+ });
218
+ }
219
+ export function prepareUpgradeFence({ root = detachmentRoot(), expectedRevision, writers }) {
220
+ return withLock(path(root), () => {
221
+ const state = read(root); validateWriters(state, writers);
222
+ if (state.revision !== expectedRevision) fail('revision_conflict');
223
+ if (!['connected', 'ready', 'recovery_required'].includes(state.connection)) fail('invalid_transition');
224
+ state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL;
225
+ state.connection = 'detaching'; state.revision++; atomicSave(path(root), state); return state;
226
+ });
227
+ }
228
+
229
+ const boundedText = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 2048;
230
+ const exactKeys = (value, keys) => isMap(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
231
+ const fenceKey = (identity, taskId) => digest([digest(identity), digest(taskId)]);
232
+ const REASONS = ['provider_policy_unknown', 'provider_model_unknown', 'provider_identity_unverified'];
233
+ function storedFenceIdentity(value, root) {
234
+ if (!exactKeys(value, ['root', 'account', 'origin', 'device', 'provider', 'session', 'worktree']) || !['root', 'account', 'origin', 'device', 'session', 'worktree'].every(key => boundedText(value[key])) || !['codex', 'claude'].includes(value.provider) || value.root !== realpathSync(root) || resolve(value.worktree) !== value.worktree) fail('task_fence_invalid');
235
+ let url; try { url = new URL(value.origin); } catch { fail('task_fence_invalid'); }
236
+ if (!['https:', 'http:'].includes(url.protocol) || url.origin !== value.origin) fail('task_fence_invalid');
237
+ // Archived worktrees remain valid historical identities; reading the ledger must not require them to exist.
238
+ return { root: value.root, account: value.account, origin: value.origin, device: value.device, provider: value.provider, session: value.session, worktree: value.worktree };
239
+ }
240
+ function admissionWriterMatches(state, item) {
241
+ const writer = state.writerCapabilities?.[digest([item.identity.provider, item.identity.session])], proof = item.admission;
242
+ return writer?.protocol === DISPATCH_PROTOCOL && writer.generation === state.generation && writer.pid === proof?.pid && writer.processIdentity === proof?.processIdentity && writer.moduleDigest === proof?.moduleDigest;
243
+ }
244
+ function validateTaskFences(state, root) {
245
+ if (state.taskFences === undefined) return;
246
+ if (state.minimumDispatchProtocol !== 3 || !exactKeys(state.taskFences, ['version', 'records']) || state.taskFences.version !== 1 || !isMap(state.taskFences.records)) fail('task_fence_invalid');
247
+ for (const [key, item] of Object.entries(state.taskFences.records)) {
248
+ if (!exactKeys(item, ['version', 'identity', 'identityHash', 'taskId', 'taskHash', 'generation', 'revision', 'status', 'reason', 'createdAt', 'admission']) || item.version !== 1 || !boundedText(item.taskId) || !Number.isSafeInteger(item.generation) || item.generation < 0 || item.generation > state.generation || !Number.isSafeInteger(item.revision) || item.revision < 1 || !['blocked', 'admitted'].includes(item.status) || !Number.isSafeInteger(item.createdAt) || !exactKeys(item.reason, ['code', 'evidenceRef']) || !REASONS.includes(item.reason.code) || !boundedText(item.reason.evidenceRef) || !exactKeys(item.identity, ['root', 'account', 'origin', 'device', 'provider', 'session', 'worktree']) || item.identity.root !== realpathSync(root)) fail('task_fence_invalid');
249
+ const identity = storedFenceIdentity(item.identity, root);
250
+ if (digest(identity) !== item.identityHash || digest(item.taskId) !== item.taskHash || key !== fenceKey(identity, item.taskId)) fail('task_fence_invalid');
251
+ if (item.status === 'blocked' ? item.admission !== null : !validObservation(item.admission, item, state, false)) fail('task_fence_invalid');
252
+ }
253
+ }
254
+ function assertTaskAdmissionState(state, { taskId, identity = null }) {
255
+ const records = state.taskFences?.records || {};
256
+ if (!identity) {
257
+ if (Object.values(records).some(item => item.taskId === taskId)) fail('task_admission_identity_required');
258
+ return true;
259
+ }
260
+ const item = records[fenceKey(identity, taskId)];
261
+ if (item && (item.status !== 'admitted' || item.generation !== state.generation || !admissionWriterMatches(state, item))) {
262
+ const error = new Error('Original task needs fresh verified provider policy and model admission.');
263
+ error.code = 'task_admission_blocked'; error.reasonCode = item.reason.code; throw error;
264
+ }
265
+ return true;
266
+ }
267
+ export function assertTaskAdmission(owner, { taskId, identity = null, provenance = null }) {
268
+ if (!owner || !boundedText(taskId)) fail('dispatch_identity_required');
269
+ identity = identity ? normalizeDispatchIdentity(identity) : provenance?.identity || null;
270
+ if (identity && identity.root !== owner.root) fail('identity_root_mismatch');
271
+ const state = read(owner.root);
272
+ if (owner.generation !== state.generation || owner.protocol !== DISPATCH_PROTOCOL || !['connected', 'ready'].includes(state.connection)) fail('owner_fenced');
273
+ return assertTaskAdmissionState(state, { taskId, identity });
274
+ }
275
+ export function readTaskAdmission({ root = detachmentRoot(), identity, taskId }) {
276
+ identity = storedFenceIdentity(identity, root);
277
+ if (identity.root !== realpathSync(root)) fail('identity_root_mismatch');
278
+ const state = read(root), record = state.taskFences?.records[fenceKey(identity, taskId)] || null;
279
+ return { revision: state.revision, generation: state.generation, record, canResume: ['connected', 'ready'].includes(state.connection) && (!record || record.status === 'admitted' && record.generation === state.generation && admissionWriterMatches(state, record)) };
280
+ }
281
+ function checkAdmissionOwner(state, owner, identity, taskId, expectedRevision) {
282
+ if (!owner || owner.root !== identity.root || owner.protocol !== DISPATCH_PROTOCOL || owner.generation !== state.generation) fail('owner_fenced');
283
+ if (!boundedText(taskId) || state.revision !== expectedRevision) fail('revision_conflict');
284
+ }
285
+ export function setTaskAdmissionFence({ owner, identity, taskId, expectedRevision, expectedFenceRevision = null, reason, writers }) {
286
+ identity = normalizeDispatchIdentity(identity);
287
+ return withLock(path(identity.root), () => {
288
+ const state = read(identity.root); checkAdmissionOwner(state, owner, identity, taskId, expectedRevision);
289
+ validateWriters(state, writers);
290
+ const registration = createStateStore(identity).read().tasks[digest(taskId)];
291
+ if (!registration?.identity || digest(registration.identity) !== digest(identity)) fail('task_admission_unregistered');
292
+ if (!exactKeys(reason, ['code', 'evidenceRef']) || !REASONS.includes(reason.code) || !boundedText(reason.evidenceRef)) fail('task_fence_reason_invalid');
293
+ const key = fenceKey(identity, taskId), previous = state.taskFences?.records[key];
294
+ if ((previous?.revision ?? null) !== expectedFenceRevision) fail('task_fence_revision_conflict');
295
+ state.taskFences ||= { version: 1, records: {} };
296
+ state.taskFences.records[key] = { version: 1, identity, identityHash: digest(identity), taskId, taskHash: digest(taskId), generation: state.generation, revision: (previous?.revision || 0) + 1, status: 'blocked', reason: { ...reason }, createdAt: Date.now(), admission: null };
297
+ // v2 writers validate minimumDispatchProtocol and cannot ignore taskFences.
298
+ state.schemaVersion = 2; state.minimumDispatchProtocol = 3; state.revision++;
299
+ atomicSave(path(identity.root), state); return state;
300
+ });
301
+ }
302
+ const OBSERVATION_KEYS = ['source', 'reference', 'observedAt', 'providerModel', 'permissionPolicyHash', 'pid', 'processIdentity', 'moduleDigest', 'identityHash', 'taskHash', 'generation', 'fenceRevision', 'nonce'];
303
+ function validObservation(observation, item, state, fresh) {
304
+ if (!exactKeys(observation, OBSERVATION_KEYS) || !['provider_observation', 'explicit_permission_confirmation'].includes(observation.source) || !['reference', 'providerModel', 'processIdentity', 'moduleDigest', 'nonce'].every(key => boundedText(observation[key])) || !/^[a-f0-9]{64}$/.test(observation.permissionPolicyHash) || !Number.isSafeInteger(observation.pid) || observation.pid < 2 || !Number.isSafeInteger(observation.observedAt) || observation.identityHash !== item.identityHash || observation.taskHash !== item.taskHash || observation.generation !== item.generation || observation.fenceRevision !== item.revision - (item.status === 'admitted' ? 1 : 0)) return false;
305
+ if (!fresh) return true;
306
+ const writer = state.writerCapabilities?.[digest([item.identity.provider, item.identity.session])];
307
+ return observation.observedAt >= item.createdAt && Date.now() - observation.observedAt >= 0 && Date.now() - observation.observedAt <= 30_000 && writer?.protocol === DISPATCH_PROTOCOL && writer.generation === state.generation && writer.pid === observation.pid && writer.processIdentity === observation.processIdentity && writer.moduleDigest === observation.moduleDigest;
308
+ }
309
+ export async function admitTaskDispatch({ owner, identity, taskId, expectedRevision, expectedFenceRevision, verifyObservation }) {
310
+ identity = normalizeDispatchIdentity(identity);
311
+ if (typeof verifyObservation !== 'function') fail('task_admission_verifier_required');
312
+ const release = acquireLock(path(identity.root));
313
+ try {
314
+ const state = read(identity.root); checkAdmissionOwner(state, owner, identity, taskId, expectedRevision);
315
+ const item = state.taskFences?.records[fenceKey(identity, taskId)];
316
+ if (!item || item.status !== 'blocked' || item.revision !== expectedFenceRevision || item.generation !== state.generation) fail('task_fence_revision_conflict');
317
+ const nonce = randomUUID(); let timer;
318
+ const observation = await Promise.race([
319
+ Promise.resolve().then(() => verifyObservation(Object.freeze({ identity, taskId, generation: state.generation, fenceRevision: item.revision, nonce }))),
320
+ new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('Provider admission observation timed out.'), { code: 'task_admission_observation_timeout' })), 2000); }),
321
+ ]).finally(() => clearTimeout(timer));
322
+ if (!validObservation(observation, item, state, true) || observation.nonce !== nonce) fail('task_admission_observation_invalid');
323
+ item.status = 'admitted'; item.revision++; item.admission = { ...observation }; state.revision++;
324
+ atomicSave(path(identity.root), state); return state;
325
+ } finally { release(); }
326
+ }
@@ -0,0 +1,78 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, realpathSync, readdirSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { createStateStore, digest, fail, readState } from './update-state-store.mjs';
5
+
6
+ const text = value => typeof value === 'string' && value.length > 0 && value.length <= 2048 ? value : null;
7
+ export function normalizeDispatchIdentity(value) {
8
+ if (!value || !['codex', 'claude'].includes(value.provider) || !['root', 'account', 'origin', 'device', 'session', 'worktree'].every(key => text(value[key]))) fail('identity_required');
9
+ const url = new URL(value.origin);
10
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname)) fail('identity_origin_invalid');
11
+ mkdirSync(value.root, { recursive: true, mode: 0o700 });
12
+ // Stable, whitelist-only ordering. No token, prompt, model or authority is copied.
13
+ return Object.freeze({ root: realpathSync(value.root), account: value.account, origin: url.origin,
14
+ device: value.device, provider: value.provider, session: value.session, worktree: realpathSync(value.worktree) });
15
+ }
16
+ function accountMetadata(root, origin) {
17
+ const hash = createHash('sha256').update(origin).digest('hex');
18
+ for (const file of [join(root, 'sessions', `${hash}.json`), join(root, 'session.json')]) {
19
+ let session;
20
+ try { session = JSON.parse(readFileSync(file, 'utf8')); }
21
+ catch (error) { if (error.code === 'ENOENT') continue; fail('account_metadata_unreadable'); }
22
+ if (session?.api_origin === origin) return session;
23
+ }
24
+ return null;
25
+ }
26
+ export function captureDispatchProvenance({ root, provider, providerSession = null, worktree, accountSession,
27
+ origin = accountSession?.api_origin || process.env.SIMY_WEB_ORIGIN || process.env.SIMY_API_ORIGIN || 'https://app.simy.one', taskId, recordRef }) {
28
+ if (!text(root) || !text(worktree) || !text(taskId) || !text(recordRef) || !['codex', 'claude'].includes(provider)) fail('provenance_invalid');
29
+ const url = new URL(origin);
30
+ if (url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname) || !['http:', 'https:'].includes(url.protocol)) fail('identity_origin_invalid');
31
+ mkdirSync(root, { recursive: true, mode: 0o700 }); root = realpathSync(root);
32
+ const source = accountSession === undefined ? accountMetadata(root, url.origin) : accountSession;
33
+ if (source?.api_origin && new URL(source.api_origin).origin !== url.origin) fail('account_origin_mismatch');
34
+ const context = Object.freeze({ root, account: text(source?.account_id) || text(source?.auth_user_id),
35
+ origin: url.origin, device: text(source?.device_id), provider, worktree: realpathSync(worktree) });
36
+ const identity = providerSession && context.account && context.device ? normalizeDispatchIdentity({ ...context, session: providerSession }) : null;
37
+ return Object.freeze({ taskId, recordRef, context, providerSession, identity });
38
+ }
39
+ export function registerDispatchProvenance(provenance, providerSession = provenance?.providerSession) {
40
+ if (!provenance || !providerSession || !provenance.context.account || !provenance.context.device) fail('identity_unavailable');
41
+ if (provenance.providerSession && provenance.providerSession !== providerSession) fail('provider_session_mismatch');
42
+ const identity = normalizeDispatchIdentity({ ...provenance.context, session: providerSession });
43
+ createStateStore(identity).registerTask({ taskId: provenance.taskId, recordRef: provenance.recordRef, identity });
44
+ return identity;
45
+ }
46
+ // A retained registration describes a past canonical scope; it does not grant
47
+ // execution admission. Its worktree may have been archived since registration.
48
+ function retainedRegistrationIdentity(value, root) {
49
+ const keys = ['root', 'account', 'origin', 'device', 'provider', 'session', 'worktree'];
50
+ if (!value || Object.keys(value).length !== keys.length || !keys.every(key => text(value[key])) ||
51
+ !['codex', 'claude'].includes(value.provider) || value.root !== realpathSync(root) ||
52
+ !isAbsolute(value.worktree) || resolve(value.worktree) !== value.worktree) fail('registration_corrupt');
53
+ let url;
54
+ try { url = new URL(value.origin); } catch { fail('registration_corrupt'); }
55
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash ||
56
+ !['', '/'].includes(url.pathname) || url.origin !== value.origin) fail('registration_corrupt');
57
+ return Object.freeze({ root: value.root, account: value.account, origin: value.origin,
58
+ device: value.device, provider: value.provider, session: value.session, worktree: value.worktree });
59
+ }
60
+ export function listDispatchRegistrations(root, { taskId = null, retained = false } = {}) {
61
+ if (taskId !== null && !text(taskId)) fail('registration_task_invalid');
62
+ const directory = join(resolve(root), 'update-detachment', 'scopes'); let names;
63
+ try { names = readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
64
+ const registrations = [];
65
+ for (const name of names) {
66
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
67
+ const state = readState(join(directory, name));
68
+ for (const [key, task] of Object.entries(state?.tasks || {})) {
69
+ if (taskId !== null && task.taskId !== taskId) continue;
70
+ if (!task.identity || !task.taskId) continue; // Older hash-only references stay unresolved.
71
+ const identity = retained ? retainedRegistrationIdentity(task.identity, root) : normalizeDispatchIdentity(task.identity);
72
+ const store = createStateStore(identity);
73
+ if (store.identityHash !== state.identityHash || digest(task.taskId) !== key) fail('registration_corrupt');
74
+ registrations.push({ identity, taskId: task.taskId, recordRef: task.recordRef });
75
+ }
76
+ }
77
+ return registrations;
78
+ }
@@ -0,0 +1,73 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync } from 'node:fs';
3
+ import { resolve, join, dirname } from 'node:path';
4
+
5
+ export const SCHEMA_VERSION = 1;
6
+ export const CONNECTION_STATES = ['connected', 'detaching', 'detached', 'reconciling', 'ready', 'recovery_required'];
7
+ export function fail(code) { const error = new Error(`Update detachment: ${code}`); error.code = code; throw error; }
8
+ export const isMap = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
10
+ export function readState(file, { versions = [SCHEMA_VERSION] } = {}) {
11
+ try { const state = JSON.parse(readFileSync(file, 'utf8')); if (!versions.includes(state.schemaVersion)) fail('unknown_schema'); return state; }
12
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
13
+ }
14
+ export function atomicSave(file, state) {
15
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
16
+ const temporary = `${file}.${randomUUID()}.tmp`;
17
+ const fd = openSync(temporary, 'wx', 0o600);
18
+ try {
19
+ try { writeFileSync(fd, `${JSON.stringify(state)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
20
+ renameSync(temporary, file);
21
+ // Node cannot fsync directory handles on Windows. Preserve file fsync and
22
+ // atomic rename there, matching coding-run-store; POSIX directory durability
23
+ // remains mandatory and every POSIX IO error still propagates.
24
+ if (process.platform !== 'win32') {
25
+ const directory = openSync(dirname(file), 'r');
26
+ try { fsyncSync(directory); } finally { closeSync(directory); }
27
+ }
28
+ }
29
+ finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
30
+ }
31
+ // A crashed owner leaves the lock in place. Never steal a lock using a timeout
32
+ // or a PID alone (PID reuse); recovery requires an operator-verified boundary.
33
+ export function acquireLock(file) {
34
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
35
+ const token = randomUUID();
36
+ let fd; try { fd = openSync(`${file}.lock`, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST') fail('state_busy'); throw error; }
37
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid, token })); fsyncSync(fd); }
38
+ catch (error) { closeSync(fd); unlinkSync(`${file}.lock`); throw error; }
39
+ let released = false;
40
+ return () => {
41
+ if (released) return; released = true; closeSync(fd);
42
+ let owner; try { owner = JSON.parse(readFileSync(`${file}.lock`, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
43
+ if (owner.token === token) unlinkSync(`${file}.lock`);
44
+ };
45
+ }
46
+ export function withLock(file, operation, { preserveResult = false } = {}) {
47
+ const release = acquireLock(file); let completed = false;
48
+ try { const result = operation(); completed = true; return result; }
49
+ finally { try { release(); } catch (error) { if (!completed || !preserveResult) throw error; } }
50
+ }
51
+ export function createStateStore({ root, account, origin, device, provider, session, worktree }) {
52
+ if (![root, account, origin, device, provider, session, worktree].every(value => typeof value === 'string' && value.length > 0)) fail('identity_required');
53
+ if (!['codex', 'claude'].includes(provider)) fail('provider_invalid');
54
+ const identityHash = digest([resolve(root), account, new URL(origin).origin, device, provider, session, resolve(worktree)]);
55
+ const file = join(resolve(root), 'update-detachment', 'scopes', `${identityHash}.json`);
56
+ const initial = () => ({ schemaVersion: 1, identityHash, revision: 0, generation: 0, connection: 'connected', tasks: {}, attempts: {}, receipts: {}, events: {} });
57
+ const read = () => { const state = readState(file) || initial(); if (state.identityHash !== identityHash || !Number.isSafeInteger(state.revision) || !Number.isSafeInteger(state.generation) || !CONNECTION_STATES.includes(state.connection) || !['tasks', 'attempts', 'receipts', 'events'].every(key => isMap(state[key]))) fail('invalid_state'); return state; };
58
+ const transact = (expectedRevision, change) => withLock(file, () => {
59
+ const state = read(); if (state.revision !== expectedRevision) fail('revision_conflict');
60
+ const next = change(structuredClone(state));
61
+ if (!next || next.identityHash !== identityHash || next.schemaVersion !== 1 || !CONNECTION_STATES.includes(next.connection) || !Number.isSafeInteger(next.generation) || next.generation < state.generation) fail('invalid_transition');
62
+ next.revision = state.revision + 1; atomicSave(file, next); return next;
63
+ });
64
+ const registerTask = ({ taskId, recordRef, identity = null }) => {
65
+ if (![taskId, recordRef].every(value => typeof value === 'string' && value.length > 0 && value.length <= 2048)) fail('task_reference_invalid');
66
+ return withLock(file, () => {
67
+ const state = read(), key = digest(taskId);
68
+ if (Object.hasOwn(state.tasks, key)) { if (state.tasks[key].recordRef !== recordRef || identity && state.tasks[key].identity && digest(state.tasks[key].identity) !== digest(identity)) fail('task_identity_conflict'); if (!identity || state.tasks[key].identity) return state; }
69
+ state.tasks[key] = { recordRef, taskId, ...(identity ? { identity } : {}) }; state.revision++; atomicSave(file, state); return state;
70
+ });
71
+ };
72
+ return { file, identityHash, read, transact, registerTask };
73
+ }
@@ -30,7 +30,7 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
30
30
  })),
31
31
  runs: records.filter(r => r.run && r.providerArchived !== true).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
32
32
  provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
33
- objective: r.run.objective, state: r.run.state, reason: r.run.reason, runId: r.run.id, revision: deliveryDecisionRevision(r.run), recoveryCause: r.run.recoveryCause, autoRecovery: r.run.managed?.autoRecovery === true,
33
+ objective: r.run.objective, state: r.run.state, reason: r.run.reason, runId: r.run.id, revision: deliveryDecisionRevision(r.run), recoveryCause: r.run.recoveryCause, stopReason: r.run.stopReason || null, autoRecovery: r.run.managed?.autoRecovery === true,
34
34
  continuations: r.run.continuations,
35
35
  continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
36
36
  startedAt: r.run.startedAt,