@awak-app/simy-cli 0.6.13 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Local execution agent for SIMY Local Tasks and Agentic Loop runs.
4
4
 
5
+ Action candidate search runs once for each authenticated live conversation context,
6
+ and again when that context changes or **Refresh** is selected. Background binding
7
+ renewal uses the lightweight binding endpoint instead of repeating candidate search.
8
+ Settled ordinary conversations and terminal managed tasks do not automatically
9
+ search candidates. Bound Actions still renew to reflect changes to deadlines and
10
+ manual links. Candidate text expires after one minute; select **Refresh** to retrieve
11
+ current suggestions after editing Actions in SIMY. Candidate search identities are
12
+ cached in memory, so restarting the host may retrieve live contexts again.
13
+
5
14
  `simy --version` prints the installed CLI version without starting an agent or
6
15
  checking for updates. `simy doctor` performs read-only local connection and
7
16
  provider login checks and prints a JSON diagnostic report: installed/running
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.13",
3
+ "version": "0.6.16",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -69,6 +69,9 @@ export class CliAutoUpdater extends EventEmitter {
69
69
  #stopped = false;
70
70
  #registryListener = null;
71
71
  #activeWork = 0;
72
+ #confirmedUpdate = null;
73
+ #confirmedReady = false;
74
+ #desktopIdle = () => true;
72
75
 
73
76
  constructor(options) {
74
77
  super();
@@ -95,7 +98,39 @@ export class CliAutoUpdater extends EventEmitter {
95
98
  }
96
99
 
97
100
  snapshot() {
98
- return structuredClone(this.#snapshot);
101
+ return structuredClone({ ...this.#snapshot, requires_task_stop: !this.#isIdle() });
102
+ }
103
+
104
+ // Close admission before cancellation begins. The same transaction owns the
105
+ // idle check and installation, so a releasing worker cannot race the stop.
106
+ applyConfirmed(stopWork) {
107
+ if (this.#confirmedUpdate) return this.#confirmedUpdate;
108
+ if (this.#applying) return this.#applying;
109
+ if (!this.#snapshot.target_version || !this.#options.lastRelease) {
110
+ return Promise.reject(new Error('Check for an available update first.'));
111
+ }
112
+ this.#setState('stopping_tasks', { message: 'Stopping unfinished tasks before updating…', error: null });
113
+ this.#confirmedUpdate = (async () => {
114
+ try {
115
+ await Promise.resolve().then(stopWork);
116
+ await this.#checking;
117
+ if (!this.#snapshot.target_version) throw new Error('The update is no longer available.');
118
+ if (!this.#isIdle()) throw new Error('Task shutdown did not finish. Restart SIMY and retry the update.');
119
+ if (!this.#snapshot.automatic || classifyVersionChange(this.#options.currentVersion, this.#snapshot.target_version) === 'major') {
120
+ this.#setState('update_available', { message: 'Tasks stopped. Opening the update installer…' });
121
+ await this.performAction();
122
+ return this.snapshot();
123
+ }
124
+ this.#confirmedReady = true;
125
+ this.#setState('waiting_for_idle', { message: 'Tasks stopped. Installing the update…' });
126
+ return await this.maybeApply();
127
+ } catch (error) {
128
+ this.#setState('failed', { message: 'Update could not finish after stopping tasks. Restart SIMY and retry.',
129
+ error: errorMessage(error), action: updateAction(this.#snapshot.target_version, this.#options) });
130
+ return this.snapshot();
131
+ } finally { this.#confirmedUpdate = null; this.#confirmedReady = false; }
132
+ })();
133
+ return this.#confirmedUpdate;
99
134
  }
100
135
 
101
136
  async performAction() {
@@ -110,7 +145,7 @@ export class CliAutoUpdater extends EventEmitter {
110
145
  }
111
146
 
112
147
  acceptsNewWork() {
113
- return !["updating", "restarting"].includes(this.#snapshot.state);
148
+ return !this.#confirmedUpdate && !["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state);
114
149
  }
115
150
 
116
151
  beginWork() {
@@ -127,10 +162,11 @@ export class CliAutoUpdater extends EventEmitter {
127
162
  };
128
163
  }
129
164
 
130
- start({ registry, restartDaemon } = {}) {
165
+ start({ registry, restartDaemon, isDesktopIdle = () => true } = {}) {
131
166
  if (this.#snapshot.state === "disabled" || this.#checkTimer || this.#stopped) return this;
132
167
  this.#registry = registry;
133
168
  this.#restartDaemon = restartDaemon;
169
+ this.#desktopIdle = isDesktopIdle;
134
170
  this.#registryListener = () => {
135
171
  if (this.#snapshot.state === "waiting_for_idle") void this.maybeApply();
136
172
  };
@@ -156,7 +192,7 @@ export class CliAutoUpdater extends EventEmitter {
156
192
  async checkNow() {
157
193
  if (this.#stopped || this.#snapshot.state === "disabled") return this.snapshot();
158
194
  if (this.#checking) return this.#checking;
159
- if (["updating", "restarting"].includes(this.#snapshot.state)) return this.snapshot();
195
+ if (["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state) || this.#confirmedUpdate) return this.snapshot();
160
196
 
161
197
  this.#checking = this.#performCheck().finally(() => {
162
198
  this.#checking = null;
@@ -237,6 +273,7 @@ export class CliAutoUpdater extends EventEmitter {
237
273
  }
238
274
 
239
275
  async maybeApply() {
276
+ if (this.#confirmedUpdate && !this.#confirmedReady) return this.snapshot();
240
277
  if (this.#stopped || this.#snapshot.state !== "waiting_for_idle") {
241
278
  return this.snapshot();
242
279
  }
@@ -333,7 +370,7 @@ export class CliAutoUpdater extends EventEmitter {
333
370
  }
334
371
 
335
372
  #isIdle() {
336
- return this.#activeWork === 0 && isRegistryIdle(this.#registry);
373
+ return this.#activeWork === 0 && isRegistryIdle(this.#registry) && this.#desktopIdle();
337
374
  }
338
375
 
339
376
  #clearIdleTimer() {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.13";
16
+ export const CLI_VERSION = "0.6.16";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -52,7 +52,9 @@ export function createDesktopAccessPolicy({ readSession, isValid, fetchAccess, i
52
52
  result = { state: payload.state,
53
53
  organizationId: payload.organizationId, organizationName: payload.organizationName.slice(0, 120), organizationSlug: payload.organizationSlug, settingsPath: payload.settingsPath, membersPath: payload.membersPath };
54
54
  } catch (error) {
55
- result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable' };
55
+ result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable',
56
+ reason: error?.status === 401 ? 'authentication_required' :
57
+ ['TimeoutError', 'AbortError'].includes(error?.name) ? 'timeout' : 'service_unavailable' };
56
58
  }
57
59
  // Late responses never authorize a new account or a switched organization.
58
60
  if (readSession() === session && basic().state === 'checking') {
@@ -4,10 +4,11 @@ import { inspectProviderPlugins, unavailablePluginStatus } from './sqm/provider-
4
4
  import { openCodexAppServer } from './session-supervision/codex-app-server.js';
5
5
  import { conversationSqmChecks } from './session-supervision/sqm-presentation.js';
6
6
  import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
7
+ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
7
8
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
8
9
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
9
10
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
10
- import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
11
+ import { controlDelivery, verifyDelivery, createRecordReader, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
11
12
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
12
13
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
13
14
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
@@ -63,6 +64,7 @@ function desktopActionError(action, error) {
63
64
  if (["sqm_set_enabled", "sqm_set_engine_fallback", "sqm_retry_environment"].includes(action)) {
64
65
  return "Could not save SQM settings. Check your SIMY connection and try again.";
65
66
  }
67
+ if (action === 'update') return 'Update could not finish. Restart SIMY and retry; stopped tasks stay stopped.';
66
68
  return "Action could not finish. Refresh the status and try again.";
67
69
  }
68
70
 
@@ -115,14 +117,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
115
117
  };
116
118
  const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
117
119
  let stoppingForUpdate = false;
120
+ let unfinishedDesktopWork = true;
118
121
  let organizationAnalysesSuspended = false;
119
122
  let organizationAnalysisDrain = Promise.resolve();
120
123
  let organizationAnalysisEpoch = 0;
121
124
  const authorizeWork = async expectedScope => {
122
- if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
125
+ if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
123
126
  if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
124
127
  const access = await accessSnapshot();
125
- if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
128
+ if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
126
129
  requireDesktopAccess(access);
127
130
  const scope = accessScope(access);
128
131
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
@@ -142,11 +145,37 @@ export async function startDesktopHost({ input = process.stdin, output = process
142
145
  let commands;
143
146
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
144
147
  let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
148
+ const hasUnfinishedSessions = records => records.some(record => record.autoContinue === true || record.run && !TERMINAL.has(record.run.state)) ||
149
+ sessionStatus.sessions.some(candidate => candidate.autoContinue === true || ['queued', 'analyzing'].includes(analysisManager.project(candidate).analysis?.state));
145
150
  const stopDiscovery = async ({ drain = false } = {}) => {
146
151
  discoveryController?.abort();
147
152
  sessionRefreshAgain = false;
148
153
  if (drain && !dependencies.discoverSessions) await sessionRefresh;
149
154
  };
155
+ let confirmedUpdateStop;
156
+ const stopWorkForUpdate = () => confirmedUpdateStop ||= (async () => {
157
+ stoppingForUpdate = true; discoveryStopped = true;
158
+ void stopDiscovery();
159
+ await stopDesktopWorkForUpdate({
160
+ stopWork: () => Promise.all([
161
+ agent.stopDesktopTasks(), managedSupervisor.close(),
162
+ automaticContinuation?.close(), analysisManager?.close(),
163
+ (async () => {
164
+ const records = await recordReader.list();
165
+ await Promise.all(records.map(async record => {
166
+ if (record.autoContinue === true) await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({
167
+ root: deliveryRoot, provider: record.provider, session: record.session, enabled: false });
168
+ if (record.run && !TERMINAL.has(record.run.state)) await controlDelivery({ root: deliveryRoot,
169
+ provider: record.provider, session: record.session, taskId: record.run.managed?.taskId, action: 'stop' });
170
+ }));
171
+ })(),
172
+ ]),
173
+ killOwned: () => (dependencies.stopOwnedHost || stopOwnedDesktopHost)(process.pid,
174
+ { parentPid: process.ppid, includeRoot: false }),
175
+ timeoutMs: dependencies.updateStopTimeoutMs ?? 12_000,
176
+ });
177
+ unfinishedDesktopWork = false;
178
+ })();
150
179
  const refreshSessions = (force = false) => {
151
180
  if (discoveryStopped && !closed && !relay) return sessionStatus;
152
181
  if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
@@ -277,6 +306,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
277
306
  const requestedScope = accessScope(currentAccess);
278
307
  status.pluginSetup = { ...pluginSetup };
279
308
  status.providerPlugins = structuredClone(providerPlugins);
309
+ const records = await recordReader.list();
310
+ const update = status.update || updater.snapshot?.() || {};
311
+ if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(records);
312
+ status.update = { ...update, requires_task_stop: update.requires_task_stop === true ||
313
+ (status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || hasUnfinishedSessions(records) };
280
314
  if (!desktopAccessReady(currentAccess)) {
281
315
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
282
316
  if (currentAccess.state !== 'checking') await analysisManager.update([]);
@@ -289,7 +323,6 @@ export async function startDesktopHost({ input = process.stdin, output = process
289
323
  return;
290
324
  }
291
325
  await analysisManager.updateProviderStatuses?.(status.providers);
292
- const records = await recordReader.list();
293
326
  stopNotifier.observe(records);
294
327
  const projection = await actionAssociations.beginProjection?.({ records });
295
328
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
@@ -378,7 +411,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
378
411
  });
379
412
  stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
380
413
  updater.on?.("change", () => { void publish().catch(() => {}); });
381
- updater.start({ registry: agent.registry, restartDaemon: async (targetVersion, installation) => {
414
+ updater.start({ registry: agent.registry, isDesktopIdle: () => !unfinishedDesktopWork,
415
+ restartDaemon: async (targetVersion, installation) => {
382
416
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
383
417
  discoveryStopped = true;
384
418
  await stopDiscovery({ drain: true });
@@ -499,6 +533,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
499
533
  try {
500
534
  const action = command.action;
501
535
  if (action === "quit") {
536
+ if (command.forUpdate === true) {
537
+ if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
538
+ await stopWorkForUpdate();
539
+ }
502
540
  if (command.force !== true) {
503
541
  const status = await agent.getDesktopStatus();
504
542
  if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0) throw new Error("Tasks are still running. Confirm before quitting.");
@@ -595,16 +633,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
595
633
  }
596
634
  } else if (action === "check_update") await updater.checkNow();
597
635
  else if (action === "update") {
598
- if (command.force === true) {
599
- stoppingForUpdate = true; discoveryStopped = true;
600
- void stopDiscovery();
601
- // Abort dispatch immediately; existing Delivery Loop records retain
602
- // their cancelled checkpoint so another host cannot silently resume.
603
- await Promise.all([agent.stopDesktopTasks(), managedSupervisor.close(),
604
- automaticContinuation?.close(), analysisManager?.close()]);
636
+ if (command.force === true && updater.applyConfirmed) await updater.applyConfirmed(stopWorkForUpdate);
637
+ else if (command.force === true) { await stopWorkForUpdate(); await updater.performAction(); }
638
+ else {
639
+ const status = await agent.getDesktopStatus();
640
+ if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
641
+ updater.snapshot().requires_task_stop === true || hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping unfinished tasks.');
642
+ if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
643
+ else await updater.performAction();
605
644
  }
606
- if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
607
- else await updater.performAction();
608
645
  if (command.force === true && ["failed", "error"].includes(updater.snapshot().state)) throw new Error("Update failed after stopping tasks.");
609
646
  } else if (action === "codex_login" || action === "claude_login") {
610
647
  const provider = action === "claude_login" ? "claude" : "codex";
@@ -710,7 +747,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
710
747
  emit({ type: "result", id: command.id, ok: false, message: command.action === "update" && stoppingForUpdate ? "Could not finish the update after stopping tasks. Reopen SIMY and retry the update." : desktopActionError(command.action, error) });
711
748
  }
712
749
  };
713
- if (command.action === "quit") {
750
+ if (command.action === "quit" || command.action === "update" && command.force === true) {
714
751
  void execute();
715
752
  } else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
716
753
  authorizationQueue = authorizationQueue.then(execute).catch(() => {});
@@ -0,0 +1,22 @@
1
+ // Confirmed updates must not wait indefinitely on a provider or remote flush.
2
+ // SIGKILL is ownership checked by the caller; the desktop host itself survives
3
+ // long enough to settle its local checkpoints and perform the signed handoff.
4
+ export async function stopDesktopWorkForUpdate({ stopWork, killOwned, timeoutMs = 12_000 }) {
5
+ let timer;
6
+ const stopping = Promise.resolve().then(stopWork);
7
+ // A kill failure or deadline can win the race before checkpoint cancellation
8
+ // settles. Observe its eventual rejection as well.
9
+ void stopping.catch(() => {});
10
+ const deadline = new Promise((_, reject) => {
11
+ timer = setTimeout(() => reject(new Error('Task shutdown timed out. Restart SIMY and retry the update.')), timeoutMs);
12
+ });
13
+ try {
14
+ await Promise.race([(async () => {
15
+ // Start cancellation before taking the process snapshot so queued work is
16
+ // fenced and live workers observe their stop intent before termination.
17
+ await Promise.resolve();
18
+ await killOwned();
19
+ await stopping;
20
+ })(), deadline]);
21
+ } finally { clearTimeout(timer); }
22
+ }
@@ -3,6 +3,7 @@ import { listRecords, readRecord, updateRecord, TERMINAL, managedBudgetReason }
3
3
  import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
4
4
  import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
5
5
  settleActionAssociation, cancelActionAssociation } from './action-association.js';
6
+ import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
6
7
 
7
8
  const namespaceFor = (origin, scope) => JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
8
9
  const identity = record => ({ provider: record.provider, session: record.session, taskId: record.run.managed.taskId });
@@ -90,22 +91,34 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
90
91
  let view = views.get(target.taskId);
91
92
  const request = actionCandidateRequest(record);
92
93
  const contextRevision = request?.context.revision || null;
93
- if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
94
- 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) });
94
+ const active = !TERMINAL.has(record.run.state);
95
+ if (!force && view?.auth === expected && view.active === active && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
96
+ if (queue && !view.error && hasFreshCandidates(view, now()) && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
95
97
  return;
96
98
  }
97
- view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
99
+ const search = request && (needsCandidateSearch(view, expected, contextRevision, active, force) ||
100
+ association?.outbox && !view?.snapshot);
101
+ view = { ...view, auth: expected, active, contextRevision, nextRefresh: now() + refreshMs };
98
102
  views.delete(target.taskId); views.set(target.taskId, view);
99
103
  while (views.size > 500) views.delete(views.keys().next().value);
100
104
  try {
101
105
  // Never publish an outbox from an earlier organization or login before a
102
106
  // scoped server response confirms who owns the current request.
103
- const snapshot = request ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
104
- const namespace = await importEnvelope(record, snapshot, expected);
107
+ const envelope = search ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
108
+ const namespace = await importEnvelope(record, envelope, expected);
109
+ const reusable = view.auth === expected && view.namespace === namespace && view.snapshot?.contextRevision === contextRevision;
110
+ const snapshot = search ? envelope : { ...(reusable ? view.snapshot : {}), ...envelope };
105
111
  view.namespace = namespace; view.error = null;
106
- view.candidates = (snapshot.candidates || []).map(publicCandidate);
112
+ if (search) {
113
+ view.candidateAuth = expected; view.candidateContextRevision = contextRevision;
114
+ view.candidatesExpireAt = now() + CANDIDATE_DISPLAY_MS;
115
+ } else if (!reusable) {
116
+ view.candidateAuth = null; view.candidateContextRevision = null;
117
+ }
118
+ view.candidates = (search || hasFreshCandidates(view, now()) ? snapshot.candidates || [] : []).map(publicCandidate);
107
119
  view.candidateTruncated = snapshot.retrieval?.truncated === true || view.candidates.length > 5;
108
- view.snapshot = request ? snapshot : null;
120
+ view.snapshot = snapshot.candidateRevision ? snapshot : null;
121
+ if (!active && !snapshot.binding?.action && !association?.outbox) view.nextRefresh = Infinity;
109
122
  let latest = await fresh(target);
110
123
  if (!latest || !await valid(expected)) return;
111
124
  const pending = latest.run.managed.actionAssociation?.outbox;
@@ -123,7 +136,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
123
136
  await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now(), validate: validateScope(expected) });
124
137
  return;
125
138
  }
126
- if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
139
+ if (queue && request && hasFreshCandidates(view, now()) && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
127
140
  // A read turn is queued only at the original session's safe boundary.
128
141
  // Blocked tasks retain candidates for auxiliary correction in the UI.
129
142
  await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now(), validate: validateScope(expected) });
@@ -245,7 +258,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
245
258
  async prepareCached(record) {
246
259
  const expected = await current();
247
260
  const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
248
- 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;
261
+ if (closed || !auth || !view || view.auth !== auth || view.error || !hasFreshCandidates(view, now()) || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
249
262
  try {
250
263
  const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
251
264
  return prepared || record;
@@ -268,7 +281,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
268
281
  !view || view.auth !== expected ? { status: 'loading', binding: null, candidates: [], canCorrect: false } : {
269
282
  status: view.error || local?.status || 'waiting_boundary',
270
283
  binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
271
- candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
284
+ candidates: (hasFreshCandidates(view, now()) ? view.candidates || [] : []).slice(0,5), canCorrect: !view.error,
272
285
  contextRevision: view.contextRevision, candidateRevision: view.snapshot?.candidateRevision || null, retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
273
286
  };
274
287
  let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
@@ -0,0 +1,12 @@
1
+ // Candidate search is event-driven; binding reads remain independently renewable.
2
+ // Retain only the search identity after expiry, so a timer cannot restart search
3
+ // or keep displaying candidate text after its authorization window has elapsed.
4
+ export const CANDIDATE_DISPLAY_MS = 60_000;
5
+
6
+ export function needsCandidateSearch(view, auth, contextRevision, active, force = false) {
7
+ return force || active && (view?.candidateAuth !== auth || view.candidateContextRevision !== contextRevision);
8
+ }
9
+
10
+ export function hasFreshCandidates(view, now) {
11
+ return Boolean(view?.snapshot?.candidateRevision && now < view.candidatesExpireAt);
12
+ }
@@ -3,9 +3,14 @@ import {
3
3
  listRecords,
4
4
  readRecord,
5
5
  updateRecord,
6
+ TERMINAL,
6
7
  } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
7
8
  import { associationRevision } from './action-association.js';
8
9
  import { ActionAssociationApiError } from './action-association-api.js';
10
+ import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
11
+
12
+ const activeConversation = (candidate, record) => !TERMINAL.has(record.run?.state) &&
13
+ (candidate.isRunning !== false || candidate.autoContinue === true || candidate.analysis?.state === 'continue');
9
14
 
10
15
  export const sessionAssociationContext = (candidate) => {
11
16
  const grounded =
@@ -111,6 +116,7 @@ export function createOrdinaryAssociationStore({
111
116
  if (record.run?.managed) return;
112
117
  const auth = await api.authRevision(),
113
118
  context = sessionAssociationContext(candidate);
119
+ const active = activeConversation(candidate, record);
114
120
  let view = views.get(taskId);
115
121
  if (!auth) {
116
122
  rememberView(taskId, { auth, status: 'signed_out' });
@@ -120,13 +126,15 @@ export function createOrdinaryAssociationStore({
120
126
  !force &&
121
127
  view?.auth === auth &&
122
128
  view.contextRevision === context.revision &&
129
+ view.active === active &&
123
130
  now() < view.nextRefresh
124
131
  )
125
132
  return;
126
133
  const previous = view?.auth === auth ? view : null;
127
134
  const sameContext = previous?.contextRevision === context.revision;
135
+ const search = needsCandidateSearch(previous, auth, context.revision, active, force);
128
136
  view = {
129
- ...previous, auth, contextRevision: context.revision,
137
+ ...previous, auth, active, contextRevision: context.revision,
130
138
  // Background renewal retains the last stable presentation. A user refresh
131
139
  // can show progress without flashing every cached result back to loading.
132
140
  status: force ? 'loading' : previous?.status || 'loading',
@@ -134,19 +142,22 @@ export function createOrdinaryAssociationStore({
134
142
  };
135
143
  rememberView(taskId, view);
136
144
  try {
137
- const snapshot = await api.candidates(
145
+ const envelope = search ? await api.candidates(
138
146
  { taskId, context, limit: 10 },
139
147
  { signal: abort.signal },
140
- );
148
+ ) : await api.readBinding(taskId, { signal: abort.signal });
141
149
  if (closed || (await api.authRevision()) !== auth)
142
150
  throw new ActionAssociationApiError('account_changed');
143
151
  if (
144
- snapshot.taskId !== taskId ||
145
- snapshot.contextRevision !== context.revision ||
146
- (snapshot.binding && snapshot.binding.taskId !== taskId)
152
+ search && (envelope.taskId !== taskId || envelope.contextRevision !== context.revision) ||
153
+ (envelope.binding && envelope.binding.taskId !== taskId)
147
154
  )
148
155
  throw new ActionAssociationApiError('invalid_response');
149
- const namespace = namespaceFor(webOrigin, snapshot.scope);
156
+ const namespace = namespaceFor(webOrigin, envelope.scope);
157
+ const reusable = sameContext && previous.namespace === namespace && hasFreshCandidates(previous, now());
158
+ const snapshot = search ? envelope : {
159
+ ...(reusable ? previous.snapshot : {}), ...envelope, taskId, contextRevision: context.revision,
160
+ };
150
161
  const persisted = await updateRecord(
151
162
  root,
152
163
  candidate.provider,
@@ -184,7 +195,10 @@ export function createOrdinaryAssociationStore({
184
195
  Object.assign(view, {
185
196
  namespace,
186
197
  snapshot,
187
- nextRefresh: now() + (snapshot.binding?.action ? 15_000 : 60_000),
198
+ candidateAuth: search ? auth : previous?.namespace === namespace ? previous?.candidateAuth : null,
199
+ candidateContextRevision: search ? context.revision : previous?.namespace === namespace ? previous?.candidateContextRevision : null,
200
+ candidatesExpireAt: search ? now() + CANDIDATE_DISPLAY_MS : previous?.candidatesExpireAt,
201
+ nextRefresh: snapshot.binding?.action ? now() + 15_000 : active ? now() + 60_000 : Infinity,
188
202
  failures: 0,
189
203
  status: snapshot.binding?.action
190
204
  ? 'bound'
@@ -230,7 +244,8 @@ export function createOrdinaryAssociationStore({
230
244
  candidate.binding = { ...candidate.binding, taskId };
231
245
  if (record.run?.managed) continue;
232
246
  const cached = views.get(taskId),
233
- context = sessionAssociationContext(candidate);
247
+ context = sessionAssociationContext(candidate),
248
+ active = activeConversation(candidate, record);
234
249
  if (
235
250
  auth &&
236
251
  !closed &&
@@ -238,6 +253,7 @@ export function createOrdinaryAssociationStore({
238
253
  (!cached ||
239
254
  cached.auth !== auth ||
240
255
  cached.contextRevision !== context.revision ||
256
+ cached.active !== active ||
241
257
  now() >= cached.nextRefresh)
242
258
  ) {
243
259
  const scopedBinding = cached?.auth === auth && cached.snapshot && record.actionAssociation?.authRevision === auth && record.actionAssociation.namespace === cached.namespace;
@@ -247,7 +263,7 @@ export function createOrdinaryAssociationStore({
247
263
  }
248
264
  const view = views.get(taskId);
249
265
  const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
250
- const snapshot = bindingSnapshot?.contextRevision === context.revision ? bindingSnapshot : null;
266
+ const snapshot = bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
251
267
  const local = record.actionAssociation;
252
268
  const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
253
269
  const newer = scoped &&