@awak-app/simy-cli 0.5.60 → 0.5.62

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.
@@ -1,7 +1,7 @@
1
1
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
2
2
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
3
3
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
4
- import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
4
+ import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
5
5
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
6
6
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
7
7
  import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
@@ -10,7 +10,7 @@ import { analyzeContinuation, readContinuationContext } from "./session-supervis
10
10
  import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
11
11
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
12
12
  import { spawn, execFile } from "node:child_process";
13
- import { promisify } from "node:util";
13
+ import { promisify, isDeepStrictEqual } from "node:util";
14
14
  import { createInterface } from "node:readline";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { realpathSync } from "node:fs";
@@ -59,6 +59,19 @@ function desktopActionError(action, error) {
59
59
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
60
60
  dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
61
61
  const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
62
+ // A heartbeat remains a complete native frame. Only its observation time is
63
+ // volatile; reuse the encoded body after comparing freshly authorized data.
64
+ // Detach the comparison value because registries may mutate nested objects.
65
+ let encodedStatus = null, lastStatus = null;
66
+ const emitStatus = status => {
67
+ const { sampledAt, ...content } = status;
68
+ if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
69
+ encodedStatus = JSON.stringify(content).slice(1, -1);
70
+ lastStatus = structuredClone(content);
71
+ }
72
+ const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
73
+ output.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`);
74
+ };
62
75
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
63
76
  const webOrigin = resolveWebOrigin(null, env);
64
77
  const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
@@ -67,8 +80,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
67
80
  }
68
81
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
69
82
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
83
+ const deliveryRoot = join(root, "delivery-hooks");
84
+ const recordReader = createRecordReader(deliveryRoot);
70
85
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
71
- const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
86
+ const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
72
87
  let actionAssociations = makeActionAssociations();
73
88
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
74
89
  const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
@@ -76,6 +91,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
76
91
  const previousScope = accessScope(currentAccess);
77
92
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
78
93
  if (previousScope !== accessScope(currentAccess)) {
94
+ discoveryController?.abort();
95
+ sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
79
96
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
80
97
  sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
81
98
  await analysisManager?.update([]);
@@ -99,28 +116,37 @@ export async function startDesktopHost({ input = process.stdin, output = process
99
116
 
100
117
  const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
101
118
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
102
- authorizeDispatch: authorizeWork,
119
+ authorizeDispatch: authorizeWork, readRecords: () => recordReader.list(),
103
120
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
104
121
  let shuttingDown = null;
105
122
  let commands;
106
123
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
107
- let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false;
124
+ let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
125
+ const stopDiscovery = async ({ drain = false } = {}) => {
126
+ discoveryController?.abort();
127
+ sessionRefreshAgain = false;
128
+ if (drain && !dependencies.discoverSessions) await sessionRefresh;
129
+ };
108
130
  const refreshSessions = (force = false) => {
109
- if (!desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
131
+ if (discoveryStopped && !closed && !relay) return sessionStatus;
132
+ if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
110
133
  if (force && sessionRefresh) sessionRefreshAgain = true;
111
134
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
112
135
  const requestedScope = accessScope(currentAccess);
113
- sessionRefresh = Promise.resolve().then(() => (dependencies.discoverSessions || discoverSessions)({ root: join(root, "delivery-hooks") }))
136
+ const controller = discoveryController = new AbortController();
137
+ sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal }); })
114
138
  .then(async value => {
139
+ if (closed || relay || controller.signal.aborted) return;
115
140
  const latestAccess = await accessSnapshot();
141
+ if (closed || relay || controller.signal.aborted) return;
116
142
  if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
117
143
  await analysisManager.update([]);
118
144
  sessionRefreshAgain = desktopAccessReady(latestAccess);
119
145
  return;
120
146
  }
121
147
  await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
122
- .catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
123
- .finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
148
+ .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
149
+ .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
124
150
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
125
151
  }
126
152
  return sessionStatus;
@@ -171,8 +197,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
171
197
  if (!scheduledPublish) {
172
198
  let resolve, reject;
173
199
  const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
174
- scheduledPublish = { promise };
175
- setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75);
200
+ scheduledPublish = { promise, resolve, timer: setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75) };
176
201
  }
177
202
  return scheduledPublish.promise;
178
203
  };
@@ -180,6 +205,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
180
205
  if (closed || relay || !agent) return Promise.resolve();
181
206
  if (publishing) { publishAgain = true; return publishing; }
182
207
  publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
208
+ if (closed || relay) return;
183
209
  await setAccessSnapshot(status.access);
184
210
  status.access = currentAccess;
185
211
  const requestedScope = accessScope(currentAccess);
@@ -188,39 +214,49 @@ export async function startDesktopHost({ input = process.stdin, output = process
188
214
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
189
215
  if (currentAccess.state !== 'checking') await analysisManager.update([]);
190
216
  status.tasks = { activeCount: status.tasks?.activeCount || 0, items: [] };
217
+ status.sqmChecks = { activeCount: 0, items: [] };
191
218
  status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
192
219
  status.deliveryHooks = { runs: [], providers: {} };
193
220
  status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
194
- if (!closed && !relay) emit({ type: 'status', status });
221
+ if (!closed && !relay) emitStatus(status);
195
222
  return;
196
223
  }
197
- status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
198
- const projection = await actionAssociations.beginProjection?.();
199
- await actionAssociations.decorate(status.deliveryHooks);
224
+ const records = await recordReader.list();
225
+ const projection = await actionAssociations.beginProjection?.({ records });
226
+ status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
227
+ await actionAssociations.decorate(status.deliveryHooks, projection);
200
228
  const discovery = refreshSessions();
201
229
  automaticContinuation?.tick(discovery.sessions);
202
230
  const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
203
231
  await actionAssociations.decorateSessions?.(sessions, projection);
232
+ const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
204
233
  for (const session of sessions) {
205
234
  const context = await actionAssociations.deadlineContext?.(session, projection);
206
- if (context) session.actionDeadline = await deadlines.reconcile(context);
235
+ if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
207
236
  else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
208
- const run = status.deliveryHooks.runs?.find(item => item.provider === session.provider && item.session === session.session);
237
+ const run = runsBySession.get(`${session.provider}:${session.session}`);
209
238
  if (run) run.actionDeadline = session.actionDeadline;
210
239
  }
211
240
  status.sessionSupervision = { ...discovery, sessions };
212
241
  const latestAccess = await accessSnapshot();
213
- if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) { publishAgain = true; return; }
214
- if (!closed && !relay) emit({ type: "status", status });
242
+ if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
243
+ actionAssociations.validateProjection && !await actionAssociations.validateProjection(projection)) { publishAgain = true; return; }
244
+ if (!closed && !relay) emitStatus(status);
215
245
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
216
246
  return publishing;
217
247
  };
218
248
  const stopAgent = async () => {
219
249
  clearInterval(timer);
250
+ discoveryStopped = true;
251
+ await stopDiscovery({ drain: true });
252
+ encodedStatus = null; lastStatus = null;
253
+ if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
254
+ await publishing?.catch(() => {});
220
255
  await actionAssociations.close();
221
256
  await automaticContinuation?.close();
222
257
  await analysisManager?.close();
223
258
  await managedSupervisor.close();
259
+ await recordReader.close();
224
260
  await stopWorkflows();
225
261
  if (agent) await createAgentShutdown(agent)();
226
262
  };
@@ -262,6 +298,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
262
298
  updater.on?.("change", () => { void publish().catch(() => {}); });
263
299
  updater.start({ registry: agent.registry, restartDaemon: async (targetVersion, installation) => {
264
300
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
301
+ discoveryStopped = true;
302
+ await stopDiscovery({ drain: true });
265
303
  // The candidate shares this cache. Stop inference before it starts so a
266
304
  // signed handoff cannot create two analysis writers or duplicate calls.
267
305
  await automaticContinuation.close();
@@ -276,7 +314,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
276
314
  env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
277
315
  }); } catch (error) {
278
316
  actionAssociations = makeActionAssociations();
279
- analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
317
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
280
318
  }
281
319
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
282
320
  let accepted = false;
@@ -321,18 +359,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
321
359
  lines.close(); await stopUpdateCandidate(child);
322
360
  if (!closed && !relay) {
323
361
  actionAssociations = makeActionAssociations();
324
- analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
362
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
325
363
  }
326
364
  throw error;
327
365
  }
328
366
  } });
329
367
  await refreshPlugins();
330
368
  await publish();
331
- void actionAssociations.tick().then(publish).catch(() => {});
369
+ const maintenance = new Map();
370
+ const tick = worker => {
371
+ if (closed || relay || maintenance.has(worker)) return;
372
+ const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
373
+ maintenance.set(worker, pending);
374
+ };
375
+ tick(actionAssociations);
376
+ tick(managedSupervisor);
332
377
  timer = setInterval(() => {
333
378
  if (desktopAccessReady(currentAccess)) {
334
- void actionAssociations.tick().then(publish).catch(() => {});
335
- void managedSupervisor.tick().then(publish).catch(() => {});
379
+ tick(actionAssociations);
380
+ tick(managedSupervisor);
336
381
  }
337
382
  void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
338
383
  }, 3_000);
package/src/runner.js CHANGED
@@ -86,7 +86,7 @@ const PR_PREPARATION_IDS = new Set(PR_PREPARATION_CHECKS.map((row) => row.id));
86
86
 
87
87
  export { LocalRunRegistry };
88
88
 
89
- export function createRun({ runId, request, session, apiOrigin, localStateRoot = null }) {
89
+ export function createRun({ runId, request, session, apiOrigin, localStateRoot = null, sqmHistoryRoot = null }) {
90
90
  const emitter = new EventEmitter();
91
91
  const normalizedRequest = {
92
92
  ...request,
@@ -111,6 +111,7 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
111
111
  session,
112
112
  apiOrigin,
113
113
  localStateRoot,
114
+ sqmHistoryRoot,
114
115
  status: "queued",
115
116
  lastOutput: "",
116
117
  startedAt: null,
@@ -160,7 +161,7 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
160
161
  };
161
162
  }
162
163
 
163
- export function restoreRun({ snapshot, session, apiOrigin, localPath = null, localStateRoot = null }) {
164
+ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, localStateRoot = null, sqmHistoryRoot = null }) {
164
165
  if (!snapshot || typeof snapshot !== "object" || !snapshot.charter) {
165
166
  throw new Error("Cannot restore an invalid agentic loop snapshot.");
166
167
  }
@@ -170,6 +171,7 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
170
171
  session,
171
172
  apiOrigin,
172
173
  localStateRoot,
174
+ sqmHistoryRoot,
173
175
  request: {
174
176
  backend: charter.backend === "claude" ? "claude" : "codex",
175
177
  execution_target: normalizeDesktopExecutionTarget(charter.execution_target),
@@ -678,6 +680,8 @@ async function runLocalCodingRun(
678
680
  token: run.session?.token,
679
681
  endpoint: sqmBundleEndpoint(run.session),
680
682
  evidenceEndpoint: sqmEvidenceEndpoint(run.session),
683
+ webOrigin: run.apiOrigin,
684
+ historyRoot: run.sqmHistoryRoot,
681
685
  accountId: run.session?.account_id || run.session?.auth_user_id || run.session?.device_id,
682
686
  organizationId: run.session?.organization_id || run.session?.org_id,
683
687
  deviceId: run.session?.device_id,
@@ -751,6 +755,8 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
751
755
  token: run.session?.token,
752
756
  endpoint: sqmBundleEndpoint(run.session),
753
757
  evidenceEndpoint: sqmEvidenceEndpoint(run.session),
758
+ webOrigin: run.apiOrigin,
759
+ historyRoot: run.sqmHistoryRoot,
754
760
  accountId: run.session?.account_id || run.session?.auth_user_id || run.session?.device_id,
755
761
  organizationId: run.session?.organization_id || run.session?.org_id,
756
762
  deviceId: run.session?.device_id,
@@ -43,7 +43,7 @@ async function boundedJson(response) {
43
43
  // login cannot associate work in the newly selected account.
44
44
  export function createActionAssociationApi({ webOrigin, sessionRoot,
45
45
  fetchImpl = fetch, readSessionImpl = readSession, now = Date.now, timeoutMs = 10_000 } = {}) {
46
- const identity = session => createHash('sha256').update(`${webOrigin}\0${session.token}`).digest('hex');
46
+ const identity = session => createHash('sha256').update(JSON.stringify([webOrigin, session.token, session.organization_id ?? null, session.user_id ?? null, session.account_id ?? null, session.device_id ?? null, session.api_base_url ?? null, session.expires_at])).digest('hex');
47
47
  const currentSession = async () => {
48
48
  const session = await readSessionImpl(webOrigin, sessionRoot);
49
49
  if (!isSessionValid(session, now(), webOrigin)) throw new ActionAssociationApiError('signed_out');
@@ -28,10 +28,11 @@ const deadlineBlocker = (record, candidate) => {
28
28
  // Association is optional metadata. Network failures never control the task's
29
29
  // work state; provider turns and their budgets remain owned by the supervisor.
30
30
  export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
31
- now = Date.now, refreshMs = 60_000, list = listRecords, read = readRecord, onChange = () => {} } = {}) {
31
+ now = Date.now, refreshMs = 60_000, list = listRecords, readRecords, read = readRecord, onChange = () => {} } = {}) {
32
32
  const abort = new AbortController(), views = new Map();
33
+ if (readRecords) list = () => readRecords();
33
34
  const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
34
- let auth = null, closed = false, running = null, serial = Promise.resolve();
35
+ let auth = null, closed = false, running = null, serial = Promise.resolve(), activeCursor = 0, historyCursor = 0;
35
36
  const enqueue = work => {
36
37
  const result = serial.then(work); serial = result.catch(() => {}); return result;
37
38
  };
@@ -44,7 +45,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
44
45
  if (!association || association.authRevision === next) continue;
45
46
  await updateRecord(root, record.provider, record.session, latest => {
46
47
  const local = latest?.run?.managed?.actionAssociation;
47
- if (!local || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
48
+ if (!local || local.authRevision === next || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
48
49
  local.invalidated = true; local.binding = null; delete local.outbox; local.status = 'unavailable';
49
50
  return latest;
50
51
  });
@@ -57,6 +58,9 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
57
58
  return eligible(record || {}) && record.run.managed.taskId === target.taskId ? record : null;
58
59
  };
59
60
  const valid = async expected => !closed && expected && await current() === expected;
61
+ // Called inside record locks: reading auth directly cannot recursively take
62
+ // the same lock through current()'s account-change invalidation pass.
63
+ const validateScope = expected => async () => !closed && await api.authRevision() === expected;
60
64
  const importEnvelope = async (record, envelope, expected, userCorrection = false) => {
61
65
  if (!await valid(expected)) throw new ActionAssociationApiError('account_changed');
62
66
  if (envelope.binding && envelope.binding.taskId !== record.run.managed.taskId) throw new ActionAssociationApiError('invalid_response');
@@ -64,9 +68,10 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
64
68
  const local = record.run.managed.actionAssociation;
65
69
  // An empty remote binding must not erase a cached ambiguous/no-match result.
66
70
  if (userCorrection || envelope.binding || local?.binding || local?.namespace && local.namespace !== namespace) {
67
- await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now() });
71
+ await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now(), validate: validateScope(expected) });
68
72
  }
69
- await updateRecord(root, record.provider, record.session, latest => {
73
+ await updateRecord(root, record.provider, record.session, async latest => {
74
+ if (!await validateScope(expected)()) throw new ActionAssociationApiError('account_changed');
70
75
  const local = latest?.run?.managed?.actionAssociation;
71
76
  if (latest?.run?.managed?.taskId !== record.run.managed.taskId) return latest;
72
77
  latest.run.managed.actionAssociation = { ...local, authRevision: expected };
@@ -81,11 +86,12 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
81
86
  const request = actionCandidateRequest(record);
82
87
  const contextRevision = request?.context.revision || null;
83
88
  if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
84
- 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() });
89
+ 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(), validate: validateScope(expected) });
85
90
  return;
86
91
  }
87
92
  view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
88
- views.set(target.taskId, view);
93
+ views.delete(target.taskId); views.set(target.taskId, view);
94
+ while (views.size > 500) views.delete(views.keys().next().value);
89
95
  try {
90
96
  // Never publish an outbox from an earlier organization or login before a
91
97
  // scoped server response confirms who owns the current request.
@@ -100,23 +106,24 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
100
106
  const pending = latest.run.managed.actionAssociation?.outbox;
101
107
  if (pending && pending.namespace === namespace && latest.run.managed.actionAssociation.authRevision === expected) {
102
108
  if (snapshot.binding?.origin === 'automatic' && snapshot.binding.action?.id === pending.actionId && snapshot.binding.contextRevision === pending.contextRevision && snapshot.binding.candidateRevision === pending.candidateRevision) {
103
- await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now() }); return;
109
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now(), validate: validateScope(expected) }); return;
104
110
  }
105
111
  if (snapshot.contextRevision !== pending.contextRevision || snapshot.candidateRevision !== pending.candidateRevision) {
106
- await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now() });
112
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now(), validate: validateScope(expected) });
107
113
  view.nextRefresh = 0; return;
108
114
  }
109
115
  const payload = Object.fromEntries(['expectedRevision', 'contextRevision', 'candidateRevision', 'actionId', 'actionRevision', 'relation', 'coveredWork', 'reason', 'evidence'].map(key => [key, pending[key]]));
110
116
  const saved = await api.saveBinding(target.taskId, { ...payload, origin: 'automatic' }, { signal: abort.signal });
111
117
  if (!await valid(expected) || namespaceFor(webOrigin, saved.scope) !== namespace) return;
112
- await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now() });
118
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now(), validate: validateScope(expected) });
113
119
  return;
114
120
  }
115
121
  if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
116
122
  // A read turn is queued only at the original session's safe boundary.
117
123
  // Blocked tasks retain candidates for auxiliary correction in the UI.
118
- await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now() });
119
- await updateRecord(root, latest.provider, latest.session, currentRecord => {
124
+ await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now(), validate: validateScope(expected) });
125
+ await updateRecord(root, latest.provider, latest.session, async currentRecord => {
126
+ if (!await validateScope(expected)()) throw new ActionAssociationApiError('account_changed');
120
127
  if (currentRecord?.run?.managed?.taskId === target.taskId && currentRecord.run.managed.actionAssociation) currentRecord.run.managed.actionAssociation.authRevision = expected;
121
128
  return currentRecord;
122
129
  });
@@ -131,43 +138,64 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
131
138
  const remote = await api.readBinding(target.taskId, { signal: abort.signal });
132
139
  const namespace = await importEnvelope(latest, remote, expected);
133
140
  const box = latest.run.managed.actionAssociation?.outbox;
134
- if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now() });
135
- else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now() });
141
+ if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now(), validate: validateScope(expected) });
142
+ else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now(), validate: validateScope(expected) });
136
143
  view.namespace = namespace; view.nextRefresh = 0;
137
144
  } catch { view.error = 'offline'; }
138
145
  } else if (hardFailure(error.code)) {
139
146
  view.candidates = []; view.snapshot = null;
140
- await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now() });
147
+ await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now(), validate: validateScope(expected) });
141
148
  } else view.nextRefresh = now() + Math.min(refreshMs, 15_000);
142
149
  }
143
150
  }
151
+ function window(records, cursor, limit) {
152
+ const count = Math.min(limit, records.length);
153
+ return { records: Array.from({ length: count }, (_, i) => records[(cursor + i) % records.length]), cursor: records.length ? (cursor + count) % records.length : 0 };
154
+ }
144
155
  async function reconcile() {
145
- const expected = await current(), records = (await list(root)).filter(eligible).slice(0, 30);
146
- let cursor = 0;
147
- await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
148
- while (!closed && cursor < records.length) {
149
- const record = records[cursor++];
156
+ const expected = await current(), all = (await list(root)).filter(eligible);
157
+ // Cancellation is a safety pass, independent of the optional network work
158
+ // budget. A large history must never hide an expired/paused matching job.
159
+ for (const record of all) {
160
+ if (closed) return;
150
161
  const managed = record.run.managed, job = managed.actionAssociation?.job;
151
162
  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' ||
152
163
  managedBudgetReason(record.run, now()) || now() >= record.run.deadline || record.run.continuations >= record.run.maxContinuations ||
153
164
  managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
154
- await cancelActionAssociation({ root, ...identity(record), now: now() });
165
+ await cancelActionAssociation({ root, ...identity(record), expectedJobId: job.id, now: now() });
155
166
  }
156
- if (!expected) {
157
- if (managed.actionAssociation?.namespace) await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now() });
158
- continue;
167
+ if (!expected && managed.actionAssociation?.namespace && managed.actionAssociation.status !== 'unavailable') {
168
+ await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now(), validate: validateScope(expected) });
159
169
  }
160
- try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
161
170
  }
171
+ if (!expected) return;
172
+ const active = all.filter(record => !TERMINAL.has(record.run.state)), history = all.filter(record => TERMINAL.has(record.run.state));
173
+ // Prefer live work while reserving a small history window. Both cursors
174
+ // rotate so the first hash-sorted records cannot monopolize every tick.
175
+ const nextActive = window(active, activeCursor, history.length ? 24 : 30);
176
+ const nextHistory = window(history, historyCursor, 30 - nextActive.records.length);
177
+ activeCursor = nextActive.cursor; historyCursor = nextHistory.cursor;
178
+ const records = [...nextActive.records, ...nextHistory.records];
179
+ let cursor = 0;
180
+ await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
181
+ while (!closed && cursor < records.length) {
182
+ const record = records[cursor++];
183
+ try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
184
+ }
162
185
  }));
163
186
  }
164
187
  return {
165
188
  // A projection is private to one desktop frame. Mutations and deadline
166
189
  // validation still reread the exact record and current authentication.
167
- async beginProjection() {
168
- await current();
169
- const records = await list(root);
170
- return { records, byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
190
+ async beginProjection({ records: suppliedRecords } = {}) {
191
+ const authRevision = await current();
192
+ const records = suppliedRecords || await list(root);
193
+ const taskIds = new Set(records.map(record => record.run?.managed?.taskId).filter(Boolean));
194
+ for (const taskId of views.keys()) if (!taskIds.has(taskId)) views.delete(taskId);
195
+ return { records, authRevision, byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
196
+ },
197
+ async validateProjection(projection) {
198
+ return !closed && await current() === projection.authRevision;
171
199
  },
172
200
  async decorateSessions(sessions, projection) {
173
201
  await ordinary.decorate(sessions, projection);
@@ -176,16 +204,16 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
176
204
  const record = records.get(`${item.provider}:${item.session}`);
177
205
  return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
178
206
  }) };
179
- await this.decorate(status);
207
+ await this.decorate(status, projection);
180
208
  for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
181
209
  return sessions;
182
210
  },
183
211
  async deadlineContext(candidate, projection) {
184
- const expected = await current(), record = projection
212
+ const expected = projection ? projection.authRevision : await current(), record = projection
185
213
  ? projection.byKey.get(`${candidate.provider}:${candidate.session}`)
186
214
  : await read(root, candidate.provider, candidate.session);
187
215
  const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
188
- if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
216
+ if (!expected || projection && auth !== expected || local?.authRevision !== expected || !(candidate.actionAssociation?.canCorrect || candidate.actionAssociation?.canReadBinding)) return null;
189
217
  const binding = candidate.actionAssociation.binding;
190
218
  const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
191
219
  return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
@@ -195,15 +223,17 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
195
223
  if (!await valid(expected)) return false;
196
224
  const latest = await read(root, candidate.provider, candidate.session);
197
225
  const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
226
+ if (!await valid(expected)) return false;
198
227
  return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
199
228
  } };
200
229
 
201
230
  },
202
231
  async prepareCached(record) {
232
+ const expected = await current();
203
233
  const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
204
234
  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;
205
235
  try {
206
- const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
236
+ const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
207
237
  return prepared || record;
208
238
  } catch { return record; }
209
239
  },
@@ -212,8 +242,8 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
212
242
  if (!running) running = reconcile().finally(() => { running = null; });
213
243
  return running;
214
244
  },
215
- async decorate(status) {
216
- const expected = await current();
245
+ async decorate(status, projection) {
246
+ const expected = projection ? projection.authRevision : await current();
217
247
  let remainingBytes = 24 * 1024;
218
248
  const perRunBytes = Math.floor(remainingBytes / Math.max(1,(status.runs || []).filter(run => run.managed).length));
219
249
  for (const run of status.runs || []) {
@@ -3,6 +3,10 @@ import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks
3
3
  import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
4
4
  export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
5
5
 
6
+ async function assertScope(validate) {
7
+ if (validate && !await validate()) throw Object.assign(new Error('Action association account changed.'), { code: 'account_changed' });
8
+ }
9
+
6
10
  function stable(run) {
7
11
  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
12
  }
@@ -16,8 +20,9 @@ function restoreAssociationWork(run, saved) {
16
20
  }
17
21
  managed.awaitingAssociationHook = false;
18
22
  }
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 => {
23
+ export async function queueActionAssociation({ root, provider, session, taskId, expectedRevision, candidates, candidateRevision, scope, namespace = null, remoteBinding = null, now = Date.now(), validate }) {
24
+ return updateRecord(root, provider, session, async record => {
25
+ await assertScope(validate);
21
26
  const run = record?.run, managed = run?.managed;
22
27
  if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
23
28
  if (!stable(run) || now >= run.deadline || run.continuations >= run.maxContinuations) {
@@ -45,8 +50,9 @@ export async function queueActionAssociation({ root, provider, session, taskId,
45
50
  return record;
46
51
  });
47
52
  }
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 => {
53
+ export async function importActionBinding({ root, provider, session, taskId, namespace, binding = null, unavailable = false, userCorrection = false, now = Date.now(), validate }) {
54
+ return updateRecord(root, provider, session, async record => {
55
+ await assertScope(validate);
50
56
  const run = record?.run, managed = run?.managed;
51
57
  if (managed?.taskId !== taskId || managed.handoff && managed.handoff.status !== 'received') return record;
52
58
  const previous = managed.actionAssociation || {};
@@ -79,7 +85,7 @@ export function actionCandidateRequest(record) {
79
85
  const context = associationSignals(record?.run?.managed?.sessionUnderstanding);
80
86
  return context ? { taskId: record.run.managed.taskId, context, limit: 10 } : null;
81
87
  }
82
- export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now() }) {
88
+ export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now(), validate }) {
83
89
  const request = actionCandidateRequest(record);
84
90
  if (!request || snapshot?.schemaVersion !== 1 || snapshot.taskId !== request.taskId || snapshot.contextRevision !== request.context.revision || !snapshot.scope?.organizationId || !snapshot.scope?.userId) return null;
85
91
  if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length > 10 || new Set(snapshot.candidates.map(item => item.id)).size !== snapshot.candidates.length) return null;
@@ -88,10 +94,11 @@ export async function prepareActionAssociation({ root, record, snapshot, webOrig
88
94
  }
89
95
  const namespace = JSON.stringify([new URL(webOrigin).origin,snapshot.scope.organizationId,snapshot.scope.userId]);
90
96
  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 });
97
+ candidates: snapshot.candidates, candidateRevision: snapshot.candidateRevision, scope: snapshot.scope, namespace, remoteBinding: snapshot.binding, now, validate });
92
98
  }
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 => {
99
+ export async function settleActionAssociation({ root, provider, session, taskId, outboxId, namespace, binding = null, unavailable = false, now = Date.now(), validate }) {
100
+ return updateRecord(root, provider, session, async record => {
101
+ await assertScope(validate);
95
102
  const association = record?.run?.managed?.actionAssociation;
96
103
  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
104
  if (binding && binding.taskId === taskId) {
@@ -102,10 +109,10 @@ export async function settleActionAssociation({ root, provider, session, taskId,
102
109
  return record;
103
110
  });
104
111
  }
105
- export async function cancelActionAssociation({ root, provider, session, taskId, now = Date.now() }) {
112
+ export async function cancelActionAssociation({ root, provider, session, taskId, expectedJobId, now = Date.now() }) {
106
113
  return updateRecord(root, provider, session, record => {
107
114
  const run = record?.run, managed = run?.managed, association = managed?.actionAssociation;
108
- if (managed?.taskId !== taskId || managed.resumeLease || !association?.job) return record;
115
+ if (managed?.taskId !== taskId || managed.resumeLease || !association?.job || expectedJobId && association.job.id !== expectedJobId) return record;
109
116
  const saved = association.job.saved;
110
117
  restoreAssociationWork(run, saved);
111
118
  managed.awaitingAssociationHook = false; association.status = 'unavailable'; delete association.job; association.updatedAt = now;
@@ -150,7 +150,7 @@ export function createActionDeadlineService({
150
150
  plan: context.plan ?? null,
151
151
  recommendation: deadlineRecommendation(context),
152
152
  });
153
- async function reconcile(context) {
153
+ async function reconcile(context, { displayOnly = false } = {}) {
154
154
  const key = identity(context),
155
155
  action = context.binding?.action;
156
156
  const bindingRevision =
@@ -164,6 +164,14 @@ export function createActionDeadlineService({
164
164
  action?.status,
165
165
  terminal.has(context.taskStatus),
166
166
  ]);
167
+ if (displayOnly) {
168
+ // Host display already owns a frame-scoped record/auth snapshot and an
169
+ // authorization exit fence. An unchanged durable deadline needs no write
170
+ // transaction or per-session authorization read; project still evaluates
171
+ // the current time and fresh local decisions on every heartbeat.
172
+ const previous = await store.read(key);
173
+ if (previous?.fingerprint === fingerprint && previous.status !== 'unavailable') return project(previous, context);
174
+ }
167
175
  const state = await transact(context, (previous) => {
168
176
  if (context.online === false) return previous;
169
177
  // Tombstones retain the last authoritative revision, so old snapshots