@awak-app/simy-cli 0.5.38 → 0.5.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
3
+ import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
4
+ settleActionAssociation, cancelActionAssociation } from './action-association.js';
5
+
6
+ const namespaceFor = (origin, scope) => JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
7
+ const identity = record => ({ provider: record.provider, session: record.session, taskId: record.run.managed.taskId });
8
+ const eligible = record => record.run?.managed?.taskId && (!record.run.managed.handoff || record.run.managed.handoff.status === 'received');
9
+ const publicCandidate = item => ({ id: item.id, revision: item.revision, title: item.title.slice(0,120) });
10
+ const publicBinding = binding => binding ? { ...binding, reason: (binding.reason || '').slice(0,60), coveredWork: (binding.coveredWork || '').slice(0,60), evidence: [],
11
+ action: binding.action ? { ...binding.action, title: (binding.action.title || '').slice(0,120), description: null, projectName: (binding.action.projectName || '').slice(0,60) } : null } : null;
12
+ const hardFailure = code => ['unauthorized', 'unavailable', 'invalid_request', 'invalid_response'].includes(code);
13
+
14
+ // Association is optional metadata. Network failures never control the task's
15
+ // work state; provider turns and their budgets remain owned by the supervisor.
16
+ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
17
+ now = Date.now, refreshMs = 60_000, list = listRecords } = {}) {
18
+ const abort = new AbortController(), views = new Map();
19
+ let auth = null, closed = false, running = null, serial = Promise.resolve();
20
+ const enqueue = work => {
21
+ const result = serial.then(work); serial = result.catch(() => {}); return result;
22
+ };
23
+ const current = async () => {
24
+ const next = await api.authRevision();
25
+ if (next !== auth) {
26
+ auth = next; views.clear();
27
+ for (const record of (await list(root)).filter(eligible)) {
28
+ const association = record.run.managed.actionAssociation;
29
+ if (!association || association.authRevision === next) continue;
30
+ await updateRecord(root, record.provider, record.session, latest => {
31
+ const local = latest?.run?.managed?.actionAssociation;
32
+ if (!local || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
33
+ local.invalidated = true; local.binding = null; delete local.outbox; local.status = 'unavailable';
34
+ return latest;
35
+ });
36
+ }
37
+ }
38
+ return next;
39
+ };
40
+ const fresh = async target => (await list(root)).find(record => eligible(record) && record.provider === target.provider && record.session === target.session && record.run.managed.taskId === target.taskId);
41
+ const valid = async expected => !closed && expected && await current() === expected;
42
+ const importEnvelope = async (record, envelope, expected, userCorrection = false) => {
43
+ if (!await valid(expected)) throw new ActionAssociationApiError('account_changed');
44
+ if (envelope.binding && envelope.binding.taskId !== record.run.managed.taskId) throw new ActionAssociationApiError('invalid_response');
45
+ const namespace = namespaceFor(webOrigin, envelope.scope);
46
+ const local = record.run.managed.actionAssociation;
47
+ // An empty remote binding must not erase a cached ambiguous/no-match result.
48
+ if (userCorrection || envelope.binding || local?.binding || local?.namespace && local.namespace !== namespace) {
49
+ await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now() });
50
+ }
51
+ await updateRecord(root, record.provider, record.session, latest => {
52
+ const local = latest?.run?.managed?.actionAssociation;
53
+ if (latest?.run?.managed?.taskId !== record.run.managed.taskId) return latest;
54
+ latest.run.managed.actionAssociation = { ...local, authRevision: expected };
55
+ if (!local?.job) delete latest.run.managed.actionAssociation.invalidated;
56
+ return latest;
57
+ });
58
+ return namespace;
59
+ };
60
+ async function sync(record, expected, force = false, queue = true) {
61
+ const target = identity(record), association = record.run.managed.actionAssociation;
62
+ let view = views.get(target.taskId);
63
+ const request = actionCandidateRequest(record);
64
+ const contextRevision = request?.context.revision || null;
65
+ if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
66
+ if (queue && !view.error && view.snapshot && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
67
+ return;
68
+ }
69
+ view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
70
+ views.set(target.taskId, view);
71
+ try {
72
+ // Never publish an outbox from an earlier organization or login before a
73
+ // scoped server response confirms who owns the current request.
74
+ const snapshot = request ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
75
+ const namespace = await importEnvelope(record, snapshot, expected);
76
+ view.namespace = namespace; view.error = null;
77
+ view.candidates = (snapshot.candidates || []).map(publicCandidate);
78
+ view.candidateTruncated = snapshot.retrieval?.truncated === true || view.candidates.length > 5;
79
+ view.snapshot = request ? snapshot : null;
80
+ let latest = await fresh(target);
81
+ if (!latest || !await valid(expected)) return;
82
+ const pending = latest.run.managed.actionAssociation?.outbox;
83
+ if (pending && pending.namespace === namespace && latest.run.managed.actionAssociation.authRevision === expected) {
84
+ if (snapshot.binding?.origin === 'automatic' && snapshot.binding.action?.id === pending.actionId && snapshot.binding.contextRevision === pending.contextRevision && snapshot.binding.candidateRevision === pending.candidateRevision) {
85
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now() }); return;
86
+ }
87
+ if (snapshot.contextRevision !== pending.contextRevision || snapshot.candidateRevision !== pending.candidateRevision) {
88
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now() });
89
+ view.nextRefresh = 0; return;
90
+ }
91
+ const payload = Object.fromEntries(['expectedRevision', 'contextRevision', 'candidateRevision', 'actionId', 'actionRevision', 'relation', 'coveredWork', 'reason', 'evidence'].map(key => [key, pending[key]]));
92
+ const saved = await api.saveBinding(target.taskId, { ...payload, origin: 'automatic' }, { signal: abort.signal });
93
+ if (!await valid(expected) || namespaceFor(webOrigin, saved.scope) !== namespace) return;
94
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now() });
95
+ return;
96
+ }
97
+ if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
98
+ // A read turn is queued only at the original session's safe boundary.
99
+ // Blocked tasks retain candidates for auxiliary correction in the UI.
100
+ await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now() });
101
+ await updateRecord(root, latest.provider, latest.session, currentRecord => {
102
+ if (currentRecord?.run?.managed?.taskId === target.taskId && currentRecord.run.managed.actionAssociation) currentRecord.run.managed.actionAssociation.authRevision = expected;
103
+ return currentRecord;
104
+ });
105
+ }
106
+ } catch (error) {
107
+ if (!await valid(expected)) return;
108
+ view.error = error.code || 'offline';
109
+ const latest = await fresh(target);
110
+ if (!latest) return;
111
+ if (error.code === 'conflict') {
112
+ try {
113
+ const remote = await api.readBinding(target.taskId, { signal: abort.signal });
114
+ const namespace = await importEnvelope(latest, remote, expected);
115
+ const box = latest.run.managed.actionAssociation?.outbox;
116
+ if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now() });
117
+ else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now() });
118
+ view.namespace = namespace; view.nextRefresh = 0;
119
+ } catch { view.error = 'offline'; }
120
+ } else if (hardFailure(error.code)) {
121
+ view.candidates = []; view.snapshot = null;
122
+ await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now() });
123
+ } else view.nextRefresh = now() + Math.min(refreshMs, 15_000);
124
+ }
125
+ }
126
+ async function reconcile() {
127
+ const expected = await current(), records = (await list(root)).filter(eligible).slice(0, 30);
128
+ let cursor = 0;
129
+ await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
130
+ while (!closed && cursor < records.length) {
131
+ const record = records[cursor++];
132
+ const managed = record.run.managed, job = managed.actionAssociation?.job;
133
+ if (job && !managed.resumeLease && (!expected || TERMINAL.has(record.run.state) || ['paused', 'blocked'].includes(record.run.state) || record.run.state === 'recovery_required' && record.run.recoveryCause !== 'session_supervision' ||
134
+ managedBudgetReason(record.run, now()) || now() >= record.run.deadline || record.run.continuations >= record.run.maxContinuations ||
135
+ managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
136
+ await cancelActionAssociation({ root, ...identity(record), now: now() });
137
+ }
138
+ if (!expected) {
139
+ if (managed.actionAssociation?.namespace) await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now() });
140
+ continue;
141
+ }
142
+ try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
143
+ }
144
+ }));
145
+ }
146
+ return {
147
+ async prepareCached(record) {
148
+ const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
149
+ if (closed || !auth || !view || view.auth !== auth || view.error || !view.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
150
+ try {
151
+ const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
152
+ return prepared || record;
153
+ } catch { return record; }
154
+ },
155
+ tick() {
156
+ if (closed) return Promise.resolve();
157
+ if (!running) running = reconcile().finally(() => { running = null; });
158
+ return running;
159
+ },
160
+ async decorate(status) {
161
+ const expected = await current();
162
+ let remainingBytes = 24 * 1024;
163
+ const perRunBytes = Math.floor(remainingBytes / Math.max(1,(status.runs || []).filter(run => run.managed).length));
164
+ for (const run of status.runs || []) {
165
+ if (!run.managed) continue;
166
+ const local = run.actionAssociation, view = views.get(run.taskId);
167
+ // Durable caches alone are not authorization to disclose Action text.
168
+ run.actionAssociation = !expected ? { status: 'signed_out', binding: null, candidates: [], canCorrect: false } :
169
+ !view || view.auth !== expected ? { status: 'loading', binding: null, candidates: [], canCorrect: false } : {
170
+ status: view.error || local?.status || 'waiting_boundary',
171
+ binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
172
+ candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
173
+ retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
174
+ };
175
+ let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
176
+ if (bytes > Math.min(remainingBytes,perRunBytes)) { run.actionAssociation.candidates = []; run.actionAssociation.truncated = true; bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation)); }
177
+ if (bytes > Math.min(remainingBytes,perRunBytes) && run.actionAssociation.binding) {
178
+ const binding = run.actionAssociation.binding;
179
+ run.actionAssociation.binding = { taskId: binding.taskId, revision: binding.revision, origin: binding.origin, locked: binding.locked, relation: binding.relation, action: binding.action ? { id: binding.action.id, revision: binding.action.revision, title: binding.action.title.slice(0,40), status: binding.action.status, dueAt: binding.action.dueAt, timeZone: binding.action.timeZone } : null };
180
+ bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
181
+ }
182
+ remainingBytes = Math.max(0,remainingBytes - bytes);
183
+ }
184
+ return status;
185
+ },
186
+ correct(command) {
187
+ return enqueue(async () => {
188
+ const expected = await current(), record = await fresh(command);
189
+ if (!expected) throw new ActionAssociationApiError('signed_out');
190
+ if (!record) throw new ActionAssociationApiError('invalid_request');
191
+ await sync(record, expected, true, false);
192
+ const view = views.get(command.taskId);
193
+ if (!await valid(expected) || !view?.namespace || view.error) throw new ActionAssociationApiError(view?.error || 'unavailable');
194
+ if (command.action === 'session_action_refresh') { view.nextRefresh = 0; return; }
195
+ const latest = await fresh(command), binding = latest?.run.managed.actionAssociation?.binding;
196
+ if ((binding?.revision ?? null) !== command.expectedBindingRevision) throw new ActionAssociationApiError('conflict');
197
+ const selected = command.action === 'session_action_select' ? view.snapshot?.candidates.find(item => item.id === command.actionId) || (!view.snapshot && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.actionId || '') ? { id: command.actionId, revision: null } : null) : null;
198
+ if (command.action !== 'session_action_unlink' && !selected) throw new ActionAssociationApiError('invalid_request');
199
+ const envelope = await api.saveBinding(command.taskId, {
200
+ expectedRevision: command.expectedBindingRevision, contextRevision: view.contextRevision,
201
+ candidateRevision: view.snapshot?.candidateRevision || null, origin: 'user', actionId: selected?.id || null,
202
+ actionRevision: selected?.revision || null, relation: selected ? 'part_of' : null,
203
+ coveredWork: latest.run.managed.sessionUnderstanding?.objective || latest.run.objective,
204
+ reason: selected ? 'Selected by the user.' : 'Unlinked by the user.', evidence: [],
205
+ }, { signal: abort.signal });
206
+ if (namespaceFor(webOrigin, envelope.scope) !== view.namespace) throw new ActionAssociationApiError('account_changed');
207
+ await importEnvelope(latest, envelope, expected, true);
208
+ view.error = null; view.nextRefresh = 0;
209
+ });
210
+ },
211
+ async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running]); views.clear(); },
212
+ };
213
+ }
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
4
+ export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
5
+
6
+ function stable(run) {
7
+ return run?.managed && (run.state === 'active' || run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' && run.managed.autoRecovery === true) && !TERMINAL.has(run.state) && run.managed.phase === 'active' && !run.managed.resumeLease && (!run.managed.handoff || run.managed.handoff.status === 'received') && !run.managed.pendingApproval && !run.managed.approvedAction && !run.managed.providerWarning && !run.managed.executionUncertain && (!run.managed.executionAttempt || run.managed.executionAttempt.phase === 'checkpointed');
8
+ }
9
+ function restoreAssociationWork(run, saved) {
10
+ const managed = run.managed;
11
+ managed.phase = saved.phase; managed.plan = saved.plan; managed.decision = saved.decision;
12
+ run.nextStep = saved.nextStep; run.checkpoint = saved.checkpoint;
13
+ if (run.state === saved.state) run.reason = saved.reason;
14
+ if (run.state === 'recovery_required' && run.recoveryCause === 'provider_error' && !managed.providerWarning && !managed.attempts?.at(-1)?.restorationUnverified && run.failures < run.maxFailures) {
15
+ run.state = saved.state; run.reason = saved.reason; run.recoveryCause = saved.recoveryCause; run.blocker = saved.blocker; managed.autoRecovery = saved.autoRecovery;
16
+ }
17
+ managed.awaitingAssociationHook = false;
18
+ }
19
+ export async function queueActionAssociation({ root, provider, session, taskId, expectedRevision, candidates, candidateRevision, scope, namespace = null, remoteBinding = null, now = Date.now() }) {
20
+ return updateRecord(root, provider, session, record => {
21
+ const run = record?.run, managed = run?.managed;
22
+ if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
23
+ if (!stable(run) || now >= run.deadline || run.continuations >= run.maxContinuations) {
24
+ if (!managed.actionAssociation?.job && !managed.actionAssociation?.outbox && !managed.actionAssociation?.manualOverride) managed.actionAssociation = { ...managed.actionAssociation, status: 'waiting_boundary', retryOn: 'original_session_safe_boundary', updatedAt: now };
25
+ return record;
26
+ }
27
+ if (managed.actionAssociation?.manualOverride && !managed.actionAssociation.namespace) return record;
28
+ if (managed.actionAssociation?.namespace !== namespace) managed.actionAssociation = { status: 'unavailable', namespace };
29
+ if (managed.actionAssociation.manualOverride || managed.actionAssociation.binding?.locked) return record;
30
+ if (remoteBinding && managed.actionAssociation.binding?.revision > remoteBinding.revision) return record;
31
+ if (remoteBinding?.taskId === taskId) { managed.actionAssociation.binding = remoteBinding; managed.actionAssociation.manualOverride = remoteBinding.locked === true; managed.actionAssociation.status = remoteBinding.action ? 'bound' : 'unlinked'; if (remoteBinding.action && remoteBinding.contextRevision === associationSignals(managed.sessionUnderstanding)?.revision) return record; }
32
+ if (managed.actionAssociation?.manualOverride || managed.actionAssociation?.job || managed.actionAssociation?.outbox) return record;
33
+ const signals = associationSignals(managed.sessionUnderstanding);
34
+ if (!signals || !Array.isArray(candidates) || candidates.length > 10 || !candidateRevision || !scope) return record;
35
+ const key = associationRevision({ context: signals, candidateRevision, scope, namespace });
36
+ if (managed.actionAssociation?.cacheKey === key) return record;
37
+ const job = { id: randomUUID(), taskId, provider, session, runRevision: run.revision, understandingRevision: managed.understandingRevision, context: signals, candidates, candidateRevision, scope, namespace, cacheKey: key,
38
+ saved: { phase: managed.phase, plan: managed.plan, decision: managed.decision, nextStep: run.nextStep, state: run.state, reason: run.reason, checkpoint: run.checkpoint, recoveryCause: run.recoveryCause, blocker: run.blocker, autoRecovery: managed.autoRecovery }, createdAt: now };
39
+ managed.actionAssociation = { ...managed.actionAssociation, status: candidates.length ? 'assessing' : 'no_match', namespace, cacheKey: key, ...(candidates.length ? { job } : {}) };
40
+ if (!candidates.length) return record;
41
+ managed.phase = 'action_association';
42
+ managed.decision = { id: job.id, outcome: 'proceed', action: { kind: 'read', scope: 'task', summary: 'Compare scoped Action candidates with the original user request' } };
43
+ run.nextStep = managed.decision.action.summary;
44
+ if (managed.capability === 'live_hook') managed.awaitingAssociationHook = true;
45
+ return record;
46
+ });
47
+ }
48
+ export async function importActionBinding({ root, provider, session, taskId, namespace, binding = null, unavailable = false, userCorrection = false, now = Date.now() }) {
49
+ return updateRecord(root, provider, session, record => {
50
+ const run = record?.run, managed = run?.managed;
51
+ if (managed?.taskId !== taskId || managed.handoff && managed.handoff.status !== 'received') return record;
52
+ const previous = managed.actionAssociation || {};
53
+ if (previous.namespace && previous.namespace !== namespace && previous.job && managed.resumeLease) { previous.namespace = namespace; previous.binding = null; previous.status = 'unavailable'; previous.invalidated = true; delete previous.outbox; return record; }
54
+ if (userCorrection && previous.job && !managed.resumeLease) {
55
+ const saved = previous.job.saved;
56
+ restoreAssociationWork(run, saved);
57
+ delete previous.job;
58
+ }
59
+ if (binding && binding.taskId !== taskId) throw new Error('Remote binding task differs.');
60
+ const association = previous.namespace && previous.namespace !== namespace ? {} : previous;
61
+ association.namespace = namespace;
62
+ if (binding && association.binding?.revision > binding.revision) return record;
63
+ if (binding) {
64
+ association.binding = binding; association.manualOverride = binding.locked === true;
65
+ association.status = binding.action ? 'bound' : 'unlinked';
66
+ if (userCorrection || binding.locked) delete association.outbox;
67
+ } else if (unavailable) { association.binding = null; association.status = 'unavailable'; delete association.outbox; }
68
+ else { association.binding = null; association.manualOverride = false; if (!association.job && !association.outbox) association.status = 'waiting_boundary'; }
69
+ association.updatedAt = now; managed.actionAssociation = association;
70
+ return record;
71
+ });
72
+ }
73
+ export const correctActionAssociation = options => importActionBinding({ ...options, userCorrection: true });
74
+ export async function pendingAssociationTasks(root) {
75
+ return (await listRecords(root)).filter(record => stable(record.run) && !record.run.managed.actionAssociation?.manualOverride && !record.run.managed.actionAssociation?.job && !record.run.managed.actionAssociation?.outbox && associationSignals(record.run.managed.sessionUnderstanding));
76
+ }
77
+
78
+ export function actionCandidateRequest(record) {
79
+ const context = associationSignals(record?.run?.managed?.sessionUnderstanding);
80
+ return context ? { taskId: record.run.managed.taskId, context, limit: 10 } : null;
81
+ }
82
+ export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now() }) {
83
+ const request = actionCandidateRequest(record);
84
+ if (!request || snapshot?.schemaVersion !== 1 || snapshot.taskId !== request.taskId || snapshot.contextRevision !== request.context.revision || !snapshot.scope?.organizationId || !snapshot.scope?.userId) return null;
85
+ if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length > 10 || new Set(snapshot.candidates.map(item => item.id)).size !== snapshot.candidates.length) return null;
86
+ for (const item of snapshot.candidates) {
87
+ if (typeof item.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(item.id) || typeof item.revision !== 'string' || item.revision.length > 200 || typeof item.title !== 'string' || item.title.length > 500 || typeof item.description === 'string' && item.description.length > 2000 || !Array.isArray(item.evidence) || item.evidence.length > 10 || new Set(item.evidence.map(fact => fact.ref)).size !== item.evidence.length || item.evidence.some(fact => typeof fact.ref !== 'string' || fact.ref.length > 200 || !['goal','deliverable','project','source'].includes(fact.kind) || typeof fact.text !== 'string' || fact.text.length > 2000)) return null;
88
+ }
89
+ const namespace = JSON.stringify([new URL(webOrigin).origin,snapshot.scope.organizationId,snapshot.scope.userId]);
90
+ return queueActionAssociation({ root, provider: record.provider, session: record.session, taskId: request.taskId, expectedRevision: record.run.revision,
91
+ candidates: snapshot.candidates, candidateRevision: snapshot.candidateRevision, scope: snapshot.scope, namespace, remoteBinding: snapshot.binding, now });
92
+ }
93
+ export async function settleActionAssociation({ root, provider, session, taskId, outboxId, namespace, binding = null, unavailable = false, now = Date.now() }) {
94
+ return updateRecord(root, provider, session, record => {
95
+ const association = record?.run?.managed?.actionAssociation;
96
+ if (record?.run?.managed?.taskId !== taskId || association?.outbox?.id !== outboxId || association.outbox.namespace !== namespace || record.run.managed.handoff && record.run.managed.handoff.status !== 'received') return record;
97
+ if (binding && binding.taskId === taskId) {
98
+ association.binding = binding; association.manualOverride = binding.locked === true;
99
+ association.status = binding.action ? 'bound' : 'unlinked'; delete association.outbox;
100
+ } else if (unavailable) { association.status = 'unavailable'; association.binding = null; delete association.outbox; }
101
+ association.updatedAt = now;
102
+ return record;
103
+ });
104
+ }
105
+ export async function cancelActionAssociation({ root, provider, session, taskId, now = Date.now() }) {
106
+ return updateRecord(root, provider, session, record => {
107
+ const run = record?.run, managed = run?.managed, association = managed?.actionAssociation;
108
+ if (managed?.taskId !== taskId || managed.resumeLease || !association?.job) return record;
109
+ const saved = association.job.saved;
110
+ restoreAssociationWork(run, saved);
111
+ managed.awaitingAssociationHook = false; association.status = 'unavailable'; delete association.job; association.updatedAt = now;
112
+ return record;
113
+ });
114
+ }
@@ -9,7 +9,7 @@ const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting',
9
9
  const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
10
10
  const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
11
11
  export const continuationAnalysisRevision = candidate => createHash('sha256')
12
- .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate)]))
12
+ .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate), ...(candidate.isRunning === true ? ['running'] : [])]))
13
13
  .digest('hex');
14
14
  const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
15
15
  ['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged');
@@ -60,15 +60,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
60
60
  } finally { await rm(temporary, { force: true }); }
61
61
  };
62
62
  const changed = () => { void Promise.resolve().then(onChange).catch(() => {}); };
63
- const enqueue = (candidate, force = false) => {
63
+ const enqueue = (candidate, force = false, forceAnalysis = false) => {
64
64
  const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate);
65
65
  const entry = entries.get(key);
66
66
  if (entry?.revision === revision && (['queued', 'analyzing'].includes(entry.result?.state) ||
67
67
  !force && !['failed', 'uncertain'].includes(entry.result?.state))) return false;
68
68
  entries.delete(key);
69
- entries.set(key, { revision, result: { state: 'queued' } });
69
+ entries.set(key, { revision, cwd: candidate.cwd, forceAnalysis, settledResult: entry?.cwd === candidate.cwd ? entry.settledResult : null,
70
+ result: { state: 'queued' } });
70
71
  return true;
71
72
  };
73
+ const finishedAnalysis = (result, previous) => result.contextDigest && result.settledTurnId
74
+ ? { ...result, awaitingTurn: false, analyzedAt: result.contextDigest === previous?.contextDigest ? previous.analyzedAt : now() }
75
+ : previous;
72
76
  const startDrain = () => {
73
77
  if (closed || draining) return;
74
78
  draining = (async () => {
@@ -80,7 +84,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
80
84
  if (entry.result?.state !== 'queued' || !candidate || !eligible(candidate) || entry.revision !== continuationAnalysisRevision(candidate)) continue;
81
85
  entry.result = { state: 'analyzing' };
82
86
  await persist(); changed();
83
- return { key, revision: entry.revision, candidate };
87
+ return { key, revision: entry.revision, candidate, previousAnalysis: entry.forceAnalysis ? null : entry.settledResult };
84
88
  }
85
89
  return null;
86
90
  });
@@ -96,7 +100,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
96
100
  });
97
101
  let result, inference;
98
102
  try {
99
- inference = Promise.resolve(analyze(selected.candidate, { signal }));
103
+ inference = Promise.resolve(analyze(selected.candidate, { signal, previousAnalysis: selected.previousAnalysis }));
100
104
  result = await Promise.race([inference, cancellation]);
101
105
  if (!RESULT_STATES.has(result?.state) || typeof result.reason !== 'string' ||
102
106
  (result.state === 'continue' && (!result.objective || !result.remaining?.length || !result.nextAction || !result.basis?.some(item => item.role === 'user')))) {
@@ -114,12 +118,22 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
114
118
  const current = candidates.get(selected.key), entry = entries.get(selected.key);
115
119
  if (closed) return;
116
120
  if (!current || !eligible(current) || continuationAnalysisRevision(current) !== selected.revision || entry?.revision !== selected.revision) {
121
+ // Live timestamps may advance during inference while the completed
122
+ // boundary stays identical. Retain that snapshot for display/reuse;
123
+ // never publish it as a current actionable recommendation.
124
+ if (current && eligible(current) && current.cwd === selected.candidate.cwd && entry?.cwd === current.cwd &&
125
+ result.contextDigest && result.settledTurnId && ['queued', 'analyzing'].includes(entry.result?.state)) {
126
+ entry.settledResult = finishedAnalysis(result, selected.previousAnalysis);
127
+ await persist(); changed();
128
+ }
117
129
  if (entry?.revision === selected.revision && entry.result?.state === 'analyzing') {
118
130
  entry.result = { state: 'queued' }; await persist(); changed();
119
131
  }
120
132
  return;
121
133
  }
122
- entries.set(selected.key, { revision: selected.revision, result: { ...result, analyzedAt: now() } });
134
+ const settledResult = finishedAnalysis(result, selected.previousAnalysis || entry.settledResult);
135
+ entries.set(selected.key, { revision: selected.revision, cwd: current.cwd, settledResult,
136
+ result: { ...result, analyzedAt: settledResult?.analyzedAt || now() } });
123
137
  await persist(); changed();
124
138
  });
125
139
  }
@@ -141,10 +155,21 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
141
155
  for (const candidate of sorted) {
142
156
  const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate), previous = known.get(key);
143
157
  const active = activityOf(candidate);
144
- const newActivity = document.initialized && (previous ? active > previous.activity : active > document.baselineAt);
145
- if (eligible(candidate) && (backfill.has(key) || newActivity)) dirty = enqueue(candidate) || dirty;
158
+ const newActivity = document.initialized && (previous ? active > previous.activity || (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
159
+ const entry = entries.get(key);
160
+ // Migrate prior idle cache entries without treating historical sessions
161
+ // as fresh activity. Its old revision already proves this exact cwd.
162
+ if (entry && !entry.cwd && entry.revision === continuationAnalysisRevision({ ...candidate, isRunning: false })) {
163
+ entry.cwd = candidate.cwd;
164
+ entry.settledResult = entry.result?.contextDigest ? entry.result : null;
165
+ dirty = true;
166
+ }
167
+ // A reader that raced a terminal boundary (or a provider without live
168
+ // status) is retried at discovery cadence, never a busy inference loop.
169
+ const recheck = entry?.result?.reasonCode === 'turn_settling' || entry?.result?.awaitingTurn === true && candidate.isRunning !== true;
170
+ if (eligible(candidate) && (backfill.has(key) || newActivity || recheck)) dirty = enqueue(candidate, recheck) || dirty;
146
171
  if (previous?.revision !== revision) {
147
- known.delete(key); known.set(key, { revision, activity: active }); dirty = true;
172
+ known.delete(key); known.set(key, { revision, activity: active, isRunning: candidate.isRunning }); dirty = true;
148
173
  }
149
174
  }
150
175
  // Empty discovery during provider startup must not consume backfill.
@@ -155,14 +180,23 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
155
180
  },
156
181
  project(candidate) {
157
182
  const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
158
- return { ...candidate, analysis: entry?.revision === revision ? { ...entry.result, revision } : { state: 'unanalysed', revision } };
183
+ let analysis = entry?.revision === revision ? { ...entry.result, revision } : { state: 'unanalysed', revision };
184
+ const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
185
+ if (candidate.isRunning === true || analysis.awaitingTurn === true) {
186
+ analysis = { state: 'waiting', awaitingTurn: true, revision,
187
+ reason: 'This turn is still running. SIMY analyzes only finished turns and will update automatically when it ends.',
188
+ ...(previous ? { previousAnalysis: previous } : {}) };
189
+ } else if (previous && ['queued', 'analyzing'].includes(analysis.state)) {
190
+ analysis.previousAnalysis = previous;
191
+ }
192
+ return { ...candidate, analysis };
159
193
  },
160
194
  async request(candidate) {
161
195
  if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
162
196
  await exclusive(async () => {
163
197
  if (closed) throw new Error('Session analysis is unavailable.');
164
198
  candidates.set(keyOf(candidate), candidate);
165
- if (enqueue(candidate, true)) { await persist(); changed(); }
199
+ if (enqueue(candidate, true, true)) { await persist(); changed(); }
166
200
  });
167
201
  startDrain();
168
202
  },
@@ -178,7 +212,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
178
212
  requireRecommendation(candidate) {
179
213
  if (closed) throw new Error('Session analysis is unavailable.');
180
214
  const analysis = this.project(candidate).analysis;
181
- if (!eligible(candidate) || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
215
+ if (!eligible(candidate) || candidate.isRunning === true || analysis.awaitingTurn === true || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
182
216
  return analysis;
183
217
  },
184
218
  async whenIdle() { do { await serial; await draining; } while (draining); await serial; },
@@ -1,3 +1,4 @@
1
+ import { associationContract } from '../codex-plugins/simy/hooks/action-association.mjs';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { realpath } from 'node:fs/promises';
3
4
  import { inspectBackendExecutable } from '../backend-executable.js';
@@ -6,6 +7,9 @@ import { boundConversation, textContent } from './conversation-context.js';
6
7
 
7
8
  const REQUEST_TIMEOUT = 15_000;
8
9
  const MAX_LINE = 256 * 1024;
10
+ const ANALYSIS_MAX_LINE = 1024 * 1024;
11
+ const ANALYSIS_MAX_TURNS = 16;
12
+ const ANALYSIS_PAGE_SIZE = 6;
9
13
  const SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'];
10
14
 
11
15
  export class CodexAppServerError extends Error {
@@ -34,7 +38,10 @@ export class CodexAppServerClient {
34
38
  const end = newline < 0 ? chunk.length : newline;
35
39
  const piece = chunk.subarray(offset, end);
36
40
  if (!discard) {
37
- if (pending.length + piece.length > this.maxLineBytes) { pending = Buffer.alloc(0); discard = true; }
41
+ if (pending.length + piece.length > this.maxLineBytes) {
42
+ pending = Buffer.alloc(0); discard = true;
43
+ this.rejectPending(new CodexAppServerError('response_too_large', 'Codex response exceeded the bounded context limit.'));
44
+ }
38
45
  else if (piece.length) pending = pending.length ? Buffer.concat([pending, piece]) : Buffer.from(piece);
39
46
  }
40
47
  if (newline >= 0) {
@@ -50,7 +57,10 @@ export class CodexAppServerClient {
50
57
  }
51
58
 
52
59
  consume(line) {
53
- if (Buffer.byteLength(line) > this.maxLineBytes) return;
60
+ if (Buffer.byteLength(line) > this.maxLineBytes) {
61
+ this.rejectPending(new CodexAppServerError('response_too_large', 'Codex response exceeded the bounded context limit.'));
62
+ return;
63
+ }
54
64
  let message;
55
65
  try { message = JSON.parse(line); } catch { return; }
56
66
  if (typeof message.method === 'string') {
@@ -68,6 +78,10 @@ export class CodexAppServerClient {
68
78
  fail(error) {
69
79
  if (this.closed) return;
70
80
  this.closed = true;
81
+ this.rejectPending(error);
82
+ }
83
+
84
+ rejectPending(error) {
71
85
  for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
72
86
  this.pending.clear();
73
87
  }
@@ -138,6 +152,7 @@ export function codexThreadMetadata(thread) {
138
152
  lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
139
153
  model: typeof thread.model === 'string' ? thread.model : null,
140
154
  observation: { source: 'codex_app_server', observedAt: Date.now() },
155
+ ...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
141
156
  availability: 'unknown',
142
157
  };
143
158
  }
@@ -166,26 +181,75 @@ export async function readCodexSession(client, session) {
166
181
 
167
182
  export async function readCodexConversation(candidate, { openClient = openCodexAppServer, signal } = {}) {
168
183
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
169
- const client = await openClient({ signal, maxLineBytes: 8 * 1024 * 1024 });
184
+ const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE });
170
185
  const cancelled = () => { void client.close(); };
171
186
  signal?.addEventListener('abort', cancelled, { once: true });
172
187
  try {
173
- const response = await client.request('thread/read', { threadId: candidate.session, includeTurns: true });
174
- const metadata = codexThreadMetadata(response?.thread);
175
- if (!metadata || metadata.session !== candidate.session || metadata.cwd !== candidate.cwd) {
176
- throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
188
+ // Full thread history can contain tens of MiB of tool output. Analysis
189
+ // reads only metadata and a fixed number of recent display summaries.
190
+ const metadata = await readCodexSession(client, candidate.session);
191
+ if (metadata.cwd !== candidate.cwd) throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
192
+ if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
193
+ const terminal = turn => ['completed', 'interrupted', 'failed'].includes(turn?.status);
194
+ const turns = [];
195
+ const turnIds = new Set(), cursors = new Set();
196
+ let cursor = null;
197
+ do {
198
+ if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
199
+ const limit = Math.min(ANALYSIS_PAGE_SIZE, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
200
+ let page;
201
+ try {
202
+ page = await client.request('thread/turns/list', { threadId: candidate.session, cursor, limit, sortDirection: 'desc', itemsView: 'summary' });
203
+ } catch (error) {
204
+ if (String(error?.code) === '-32601' || /unknown method|method.*not found|unrecognized method/i.test(String(error?.message || ''))) {
205
+ throw new CodexAppServerError('unsupported', 'This Codex version does not support bounded conversation history.');
206
+ }
207
+ throw error;
208
+ }
209
+ if (!Array.isArray(page?.data) || page.data.length > limit || page.nextCursor != null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 2048)) {
210
+ throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid bounded conversation page.');
211
+ }
212
+ for (const turn of page.data) {
213
+ if (typeof turn?.id !== 'string' || !turn.id || turnIds.has(turn.id)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
214
+ turnIds.add(turn.id); turns.push(turn);
215
+ }
216
+ cursor = page.nextCursor || null;
217
+ if (cursor && (!page.data.length || cursors.has(cursor))) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
218
+ if (cursor) cursors.add(cursor);
219
+ } while (cursor && turns.filter(terminal).length < ANALYSIS_MAX_TURNS && turns.length < ANALYSIS_MAX_TURNS + ANALYSIS_PAGE_SIZE);
220
+ // A timestamp covers the whole live thread. Instead pin the last terminal
221
+ // turn and check it again: streaming output in the newer turn is irrelevant
222
+ // to this read-only snapshot, but a newly settled turn must invalidate it.
223
+ const settled = turns.filter(terminal).slice(0, ANALYSIS_MAX_TURNS);
224
+ const boundary = settled[0];
225
+ const verification = await client.request('thread/turns/list', {
226
+ threadId: candidate.session, cursor: null, limit: ANALYSIS_PAGE_SIZE, sortDirection: 'desc', itemsView: 'summary',
227
+ });
228
+ if (!Array.isArray(verification?.data) || verification.data.length > ANALYSIS_PAGE_SIZE) {
229
+ throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid snapshot check.');
230
+ }
231
+ const latestBoundary = verification.data.find(terminal);
232
+ // Compare the immutable settled summary, never the running turn's prose.
233
+ if (JSON.stringify(boundary) !== JSON.stringify(latestBoundary)) {
234
+ throw new CodexAppServerError('turn_settling', 'The latest turn settled during the snapshot read.');
177
235
  }
178
- if (Number.isFinite(candidate.lastActivityAt) && metadata.lastActivityAt !== candidate.lastActivityAt) {
179
- throw new CodexAppServerError('revision_changed', 'Conversation changed since discovery.');
236
+ const latestMetadata = await readCodexSession(client, candidate.session);
237
+ if (latestMetadata.cwd !== candidate.cwd) throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
238
+ if (latestMetadata.model !== metadata.model) {
239
+ throw new CodexAppServerError('revision_changed', 'Conversation changed while reading recent history.');
180
240
  }
181
241
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
182
- const turns = response.thread.turns;
183
- if (!Array.isArray(turns)) return boundConversation(null);
184
242
  const messages = [];
185
- let incomplete = false;
186
- for (const [index, turn] of turns.entries()) {
187
- if (['inProgress', 'in_progress'].includes(turn?.status)) incomplete = true;
243
+ // A display summary omits intermediate reasoning/tool context even when
244
+ // every turn fits. Never represent it as the complete conversation; the
245
+ // classifier must establish a self-contained objective from retained text.
246
+ const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verification.data].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
247
+ let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !['inProgress', 'in_progress'].includes(turn?.status)), truncated = true;
248
+ for (const [index, turn] of settled.toReversed().entries()) {
249
+ if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
250
+ if (turn.itemsView !== 'summary') throw new CodexAppServerError('context_unavailable', 'Codex did not return the requested conversation summaries.');
188
251
  if (!Array.isArray(turn?.items)) { incomplete = true; continue; }
252
+ if (turn === boundary && !turn.items.length) incomplete = true;
189
253
  for (const [itemIndex, item] of turn.items.entries()) {
190
254
  const role = item?.type === 'userMessage' ? 'user' : item?.type === 'agentMessage' ? 'assistant' : null;
191
255
  if (!role) continue;
@@ -194,7 +258,7 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
194
258
  messages.push({ role, turnRef: `${turn.id || index}:${item.id || itemIndex}`, text });
195
259
  }
196
260
  }
197
- return boundConversation(messages, { incomplete });
261
+ return { ...boundConversation(messages, { incomplete, truncated }), awaitingTurn, settledTurnId: boundary?.id || null };
198
262
  } finally {
199
263
  signal?.removeEventListener('abort', cancelled);
200
264
  await client.close();
@@ -235,7 +299,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
235
299
  ...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
236
300
  budgets: { deadline: run.deadline, continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures } };
237
301
  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. ` : '';
238
- 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.`;
302
+ const prompt = run.managed.phase === 'action_association' ? associationContract(run.managed.actionAssociation.job) : 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.`;
239
303
  const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
240
304
  const currentSandbox = resumed.sandbox;
241
305
  originalSandbox = currentSandbox;