@jskit-ai/assistant-core 0.1.222 → 0.1.224

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,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
 
3
4
  const INSTRUCTION_REVISION_ENV = "JSKIT_CONVERSATION_INSTRUCTIONS";
4
5
 
@@ -10,6 +11,7 @@ export function createCodexConversationAdapter(host) {
10
11
  const instructionPrompts = new Map();
11
12
  const tasks = new Map();
12
13
  const goalChanges = new Map();
14
+ const settingsUpdates = new Map();
13
15
  const threadGenerations = new Map();
14
16
  let contextGeneration = 0;
15
17
 
@@ -38,246 +40,300 @@ export function createCodexConversationAdapter(host) {
38
40
  // A loaded native thread can ignore shell_environment_policy on resume.
39
41
  // Detaching alone is insufficient when another subscriber retains it: prove
40
42
  // the effective managed environment before recording a new binding.
41
- async function withThreadContext(threadId, params, operation) {
43
+ async function withThreadContext(threadId, params, operation, settings) {
42
44
  const threadGeneration = threadGenerations.get(threadId) || 0;
43
45
  const previous = tasks.get(threadId) || Promise.resolve();
44
46
  const task = previous.catch(() => null).then(() => host.runRequest(async () => {
45
- const client = await host.client();
46
- const generation = contextGeneration;
47
- const bound = bindings.get(threadId);
48
- const requestedParams = params;
49
- const retainInstalledInstructions = bound?.params.developerInstructions != null && !params.ephemeral && !bound.params.ephemeral;
50
- params = await prepareInstructions(retainInstalledInstructions
51
- ? { ...params, systemPrompt: undefined, developerInstructions: bound.params.developerInstructions }
52
- : params, threadId);
53
- let signal = AbortSignal.timeout(host.timeoutMs || 10_000);
54
- const controlDeadline = performance.now() + (host.timeoutMs || 10_000);
55
- const assertRecoveryOpen = () => {
56
- signal.throwIfAborted();
57
- if (generation !== contextGeneration || threadGeneration !== (threadGenerations.get(threadId) || 0)) {
58
- throw new Error("The assistant connection closed during recovery.");
59
- }
60
- };
61
- const request = (method, input) => {
62
- assertRecoveryOpen();
63
- return client.request(method, input, { signal });
64
- };
65
- const fixedPolicy = params.config?.shell_environment_policy ||
66
- (bound?.managed === false ? bound.params.config.shell_environment_policy : null);
67
- if (fixedPolicy && (params.developerInstructions == null || params.ephemeral || bound?.params.ephemeral)) {
68
- if (bound && params.developerInstructions != null && instructionPrompts.get(threadId) !== params.developerInstructions) {
69
- throw new Error("Start a new ephemeral conversation to change its instructions.");
70
- }
71
- assertRecoveryOpen();
72
- return operation(threadParameters({ ...bound?.params, ...params }, {}));
73
- }
74
- let environment = instructionEnvironment(params, fixedPolicy?.set || await host.prepareEnvironment(
75
- bound?.params?.config?.shell_environment_policy?.set || host.environment || {}
76
- ));
77
- const previousConfig = { ...bound?.params?.config };
78
- if (params.modelProvider) {
79
- for (const key of Object.keys(previousConfig)) {
80
- if (key === "model_providers" || key === "model_catalog_json" || key === "web_search" || key === "openai_base_url" || key.startsWith("model_providers.")) delete previousConfig[key];
81
- }
82
- }
83
- let requestParams = {
84
- ...bound?.params,
85
- ...params,
86
- config: {
87
- ...previousConfig,
88
- ...params?.config,
89
- shell_environment_policy: { ...(fixedPolicy || { inherit: "none" }), set: environment }
90
- }
91
- };
92
- let nativeModelProvider = "";
93
- let nativeHistoryPath = "";
94
- const readStatus = async () => {
95
- const { thread } = await request("thread/read", { threadId, includeTurns: false });
96
- if (thread?.historyMode !== "paginated") {
97
- throw Object.assign(new Error("This conversation uses an unsupported Codex history format. Renew the session to keep your files and saved chat and start a fresh conversation. You can write the handover yourself."), {
98
- code: "assistant_codex_history_unsupported"
99
- });
100
- }
101
- nativeModelProvider = thread?.modelProvider || "";
102
- nativeHistoryPath = thread?.path || "";
103
- return typeof thread?.status === "string" ? thread.status : thread?.status?.type;
104
- };
105
- let nativeStatus = await readStatus();
106
- if (!bound && !requestParams.modelProvider && nativeModelProvider) {
107
- // A restored observer has no saved request parameters. Recover the
108
- // native thread's provider configuration before resuming its controls.
109
- requestParams = { ...requestParams, modelProvider: nativeModelProvider };
110
- requestParams = await host.prepareParameters?.(requestParams) || requestParams;
111
- }
112
- requestParams = await host.prepareHistory(requestParams, client, { signal, historyPath: nativeHistoryPath });
113
- const providerChanged = Boolean(requestParams.modelProvider && nativeModelProvider &&
114
- requestParams.modelProvider !== nativeModelProvider);
115
- if (providerChanged) {
116
- const { goal } = await request("thread/goal/get", { threadId });
117
- if (!["idle", "notLoaded"].includes(nativeStatus) || goal && !["complete", "completed"].includes(goal.status)) {
118
- throw Object.assign(new Error("Stop the current turn and finish or clear its goal before changing model providers."), {
119
- code: "assistant_codex_provider_switch_busy"
120
- });
121
- }
122
- }
123
- const executionId = String(host.runtime()?.executionId || "");
124
- // Refresh project hook trust only for a cold thread in a replacement
125
- // process. A new socket or a changed command environment is not evidence
126
- // that the provider process died.
127
- if (nativeStatus === "notLoaded" && executionId && bound?.executionId !== executionId) {
128
- requestParams = await host.prepareResume?.(threadId, requestParams, {
129
- runtime: host.runtime(),
130
- processChanged: Boolean(bound?.executionId)
131
- }) || requestParams;
132
- }
133
- let bindingMatches = !providerChanged && bound?.client === client &&
134
- bound.params.developerInstructions === requestParams.developerInstructions &&
135
- bound.params.config.openai_base_url === requestParams.config.openai_base_url &&
136
- JSON.stringify(bound.params.config.model_providers) === JSON.stringify(requestParams.config.model_providers) &&
137
- Object.keys(requestParams.config).filter((key) => key.startsWith("model_providers.")).every((key) =>
138
- JSON.stringify(bound.params.config[key]) === JSON.stringify(requestParams.config[key])) &&
139
- JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
140
- const controlBindingMatches = bindingMatches;
141
- const installedRequestParams = requestParams;
142
- if (retainInstalledInstructions && (!bindingMatches || nativeStatus !== "active")) {
143
- // An active healthy turn keeps its installed prompt. Real control
144
- // recovery and the next idle admission still read current instructions.
145
- assertRecoveryOpen();
146
- const remainingControlMs = Math.floor(controlDeadline - performance.now());
147
- if (remainingControlMs <= 0) {
148
- throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
149
- }
150
- // Instruction composition preceded the control clock originally. Only
151
- // this deferred host read is excluded; spent native time is retained.
152
- params = await prepareInstructions(requestedParams, threadId);
153
- signal = AbortSignal.timeout(remainingControlMs);
154
- environment = instructionEnvironment(params, environment);
155
- requestParams = { ...requestParams,
156
- ...(Object.hasOwn(params, "developerInstructions") ? { developerInstructions: params.developerInstructions } : {}),
157
- config: { ...requestParams.config, shell_environment_policy: { ...requestParams.config.shell_environment_policy, set: environment } }
158
- };
159
- bindingMatches = bindingMatches && bound.params.developerInstructions === requestParams.developerInstructions &&
160
- JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
161
- }
162
- const instructionOnly = retainInstalledInstructions && controlBindingMatches && !bindingMatches;
163
- let instructionsChanged = requestParams.developerInstructions != null &&
164
- instructionPrompts.get(threadId) !== requestParams.developerInstructions;
165
47
  let pausedGoal = null;
166
- let resumed = null;
167
48
  const goalChange = goalChanges.get(threadId);
168
49
  const isSamePausedGoal = (goal, original) => goal?.status === "paused" &&
169
50
  goal.createdAt === original.createdAt && goal.objective === original.objective;
170
- const log = (event, fields = {}) => host.log?.(event, fields, { threadId, environment });
171
- try {
172
- assertRecoveryOpen();
173
- if (instructionOnly) {
174
- // A native goal can start work while the host reads its prompt.
175
- nativeStatus = await readStatus();
176
- if (nativeStatus === "active") {
177
- requestParams = installedRequestParams;
178
- environment = requestParams.config.shell_environment_policy.set;
179
- bindingMatches = true;
180
- instructionsChanged = false;
181
- }
182
- }
183
- if (!bindingMatches || nativeStatus === "notLoaded") {
184
- log("reload_started", { reason: bound ? "control-environment-changed" : "unverified-thread-binding", nativeStatus });
185
- if (nativeStatus !== "notLoaded") {
51
+ async function apply() {
52
+ const client = await host.client();
53
+ const generation = contextGeneration;
54
+ const bound = bindings.get(threadId);
55
+ let requestedSettings;
56
+ if (settings) {
57
+ settings.controller.signal.throwIfAborted();
58
+ settings.client = client;
59
+ if (!settings.prepared) {
60
+ const request = (method, input) => client.request(method, input, {
61
+ signal: AbortSignal.any([settings.controller.signal, AbortSignal.timeout(host.timeoutMs || 10_000)])
62
+ });
186
63
  const { goal } = await request("thread/goal/get", { threadId });
187
64
  if (goal?.status === "active") {
65
+ settings.goal = goal;
188
66
  const result = await request("thread/goal/set", { threadId, status: "paused" });
189
67
  pausedGoal = result.goal;
190
- if (!isSamePausedGoal(pausedGoal, goal)) {
191
- throw new Error("Codex did not confirm that the same goal stopped scheduling work.");
192
- }
68
+ if (!isSamePausedGoal(pausedGoal, goal)) throw new Error("Codex did not confirm the goal's scheduling pause.");
69
+ settings.goal = pausedGoal;
193
70
  }
194
- if (await readStatus() !== "idle") {
195
- if (instructionOnly) {
196
- // Never interrupt work solely to refresh its instructions.
197
- requestParams = installedRequestParams;
198
- environment = requestParams.config.shell_environment_policy.set;
199
- bindingMatches = true;
200
- instructionsChanged = false;
201
- } else {
202
- const page = await request("thread/turns/list", { threadId, limit: 1, sortDirection: "desc", itemsView: "summary" });
203
- const turnId = page.data?.[0]?.id;
204
- if (!turnId) throw new Error("Codex's running turn could not be identified for control recovery.");
205
- assertRecoveryOpen();
206
- host.recordInterruption?.(threadId, turnId);
207
- await request("turn/interrupt", { threadId, turnId });
208
- while (await readStatus() !== "idle") {
209
- signal.throwIfAborted();
210
- await new Promise((resolve) => setTimeout(resolve, 25));
211
- }
71
+ for (;;) {
72
+ const { thread } = await request("thread/read", { threadId, includeTurns: false });
73
+ const status = typeof thread?.status === "string" ? thread.status : thread?.status?.type;
74
+ if (["idle", "notLoaded"].includes(status)) break;
75
+ if (status !== "active") throw new Error("Codex's current turn could not be observed before changing settings.");
76
+ if (!settings.deferred) {
77
+ settings.deferred = true;
78
+ try { host.log?.("settings_deferred", {}, { threadId }); } catch { /* Diagnostics do not own scheduling. */ }
79
+ settings.accepted.resolve({ ok: true, pending: true });
212
80
  }
81
+ await delay(250, undefined, { signal: settings.controller.signal });
213
82
  }
214
- if (pausedGoal) {
215
- const { goal } = await request("thread/goal/get", { threadId });
216
- pausedGoal = isSamePausedGoal(goal, pausedGoal) ? goal : null;
217
- }
218
- if (!bindingMatches) {
219
- const detached = await request("thread/unsubscribe", { threadId });
220
- if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(detached?.status)) {
221
- throw new Error("Codex did not confirm detachment before control recovery.");
222
- }
83
+ settings.prepared = true;
84
+ }
85
+ requestedSettings = settings.params;
86
+ params = await host.prepareParameters?.(requestedSettings) || requestedSettings;
87
+ }
88
+ const requestedParams = params;
89
+ const retainInstalledInstructions = bound?.params.developerInstructions != null && !params.ephemeral && !bound.params.ephemeral;
90
+ params = await prepareInstructions(retainInstalledInstructions
91
+ ? { ...params, systemPrompt: undefined, developerInstructions: bound.params.developerInstructions }
92
+ : params, threadId);
93
+ let signal = AbortSignal.timeout(host.timeoutMs || 10_000);
94
+ const controlDeadline = performance.now() + (host.timeoutMs || 10_000);
95
+ const assertRecoveryOpen = () => {
96
+ signal.throwIfAborted();
97
+ settings?.controller.signal.throwIfAborted();
98
+ if (generation !== contextGeneration || threadGeneration !== (threadGenerations.get(threadId) || 0)) {
99
+ throw new Error("The assistant connection closed during recovery.");
100
+ }
101
+ };
102
+ const request = (method, input) => {
103
+ assertRecoveryOpen();
104
+ return client.request(method, input, { signal });
105
+ };
106
+ const fixedPolicy = params.config?.shell_environment_policy ||
107
+ (bound?.managed === false ? bound.params.config.shell_environment_policy : null);
108
+ if (!settings && fixedPolicy && (params.developerInstructions == null || params.ephemeral || bound?.params.ephemeral)) {
109
+ if (bound && params.developerInstructions != null && instructionPrompts.get(threadId) !== params.developerInstructions) {
110
+ throw new Error("Start a new ephemeral conversation to change its instructions.");
111
+ }
112
+ assertRecoveryOpen();
113
+ return operation(threadParameters({ ...bound?.params, ...params }, {}));
114
+ }
115
+ let environment = instructionEnvironment(params, fixedPolicy?.set || await host.prepareEnvironment(
116
+ bound?.params?.config?.shell_environment_policy?.set || host.environment || {}
117
+ ));
118
+ const previousConfig = { ...bound?.params?.config };
119
+ if (params.modelProvider) {
120
+ for (const key of Object.keys(previousConfig)) {
121
+ if (key === "model_providers" || key === "model_catalog_json" || key === "web_search" || key === "openai_base_url" || key.startsWith("model_providers.")) delete previousConfig[key];
122
+ }
123
+ }
124
+ let requestParams = {
125
+ ...bound?.params,
126
+ ...params,
127
+ config: {
128
+ ...previousConfig,
129
+ ...params?.config,
130
+ shell_environment_policy: { ...(fixedPolicy || { inherit: "none" }), set: environment }
131
+ }
132
+ };
133
+ let nativeModelProvider = "";
134
+ let nativeHistoryPath = "";
135
+ const readStatus = async () => {
136
+ const { thread } = await request("thread/read", { threadId, includeTurns: false });
137
+ if (thread?.historyMode !== "paginated") {
138
+ throw Object.assign(new Error("This conversation uses an unsupported Codex history format. Renew the session to keep your files and saved chat and start a fresh conversation. You can write the handover yourself."), {
139
+ code: "assistant_codex_history_unsupported"
140
+ });
141
+ }
142
+ nativeModelProvider = thread?.modelProvider || "";
143
+ nativeHistoryPath = thread?.path || "";
144
+ return typeof thread?.status === "string" ? thread.status : thread?.status?.type;
145
+ };
146
+ let nativeStatus = await readStatus();
147
+ if (settings && nativeStatus === "active") {
148
+ settings.prepared = false;
149
+ return apply();
150
+ }
151
+ if (!bound && !requestParams.modelProvider && nativeModelProvider) {
152
+ // A restored observer has no saved request parameters. Recover the
153
+ // native thread's provider configuration before resuming its controls.
154
+ requestParams = { ...requestParams, modelProvider: nativeModelProvider };
155
+ requestParams = await host.prepareParameters?.(requestParams) || requestParams;
156
+ }
157
+ requestParams = await host.prepareHistory(requestParams, client, { signal, historyPath: nativeHistoryPath });
158
+ const providerChanged = Boolean(requestParams.modelProvider && nativeModelProvider &&
159
+ requestParams.modelProvider !== nativeModelProvider);
160
+ if (providerChanged) {
161
+ if (!["idle", "notLoaded"].includes(nativeStatus)) {
162
+ throw Object.assign(new Error("Stop the current turn before immediately changing model providers."), {
163
+ code: "assistant_codex_provider_switch_busy"
164
+ });
165
+ }
166
+ }
167
+ const executionId = String(host.runtime()?.executionId || "");
168
+ // Refresh project hook trust only for a cold thread in a replacement
169
+ // process. A new socket or a changed command environment is not evidence
170
+ // that the provider process died.
171
+ if (nativeStatus === "notLoaded" && executionId && bound?.executionId !== executionId) {
172
+ requestParams = await host.prepareResume?.(threadId, requestParams, {
173
+ runtime: host.runtime(),
174
+ processChanged: Boolean(bound?.executionId)
175
+ }) || requestParams;
176
+ }
177
+ let bindingMatches = !providerChanged && bound?.client === client &&
178
+ bound.params.developerInstructions === requestParams.developerInstructions &&
179
+ bound.params.config.openai_base_url === requestParams.config.openai_base_url &&
180
+ JSON.stringify(bound.params.config.model_providers) === JSON.stringify(requestParams.config.model_providers) &&
181
+ Object.keys(requestParams.config).filter((key) => key.startsWith("model_providers.")).every((key) =>
182
+ JSON.stringify(bound.params.config[key]) === JSON.stringify(requestParams.config[key])) &&
183
+ JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
184
+ const controlBindingMatches = bindingMatches;
185
+ const installedRequestParams = requestParams;
186
+ if (retainInstalledInstructions && (!bindingMatches || nativeStatus !== "active")) {
187
+ // An active healthy turn keeps its installed prompt. Real control
188
+ // recovery and the next idle admission still read current instructions.
189
+ assertRecoveryOpen();
190
+ const remainingControlMs = Math.floor(controlDeadline - performance.now());
191
+ if (remainingControlMs <= 0) {
192
+ throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
193
+ }
194
+ // Instruction composition preceded the control clock originally. Only
195
+ // this deferred host read is excluded; spent native time is retained.
196
+ params = await prepareInstructions(requestedParams, threadId);
197
+ signal = AbortSignal.timeout(remainingControlMs);
198
+ environment = instructionEnvironment(params, environment);
199
+ requestParams = { ...requestParams,
200
+ ...(Object.hasOwn(params, "developerInstructions") ? { developerInstructions: params.developerInstructions } : {}),
201
+ config: { ...requestParams.config, shell_environment_policy: { ...requestParams.config.shell_environment_policy, set: environment } }
202
+ };
203
+ bindingMatches = bindingMatches && bound.params.developerInstructions === requestParams.developerInstructions &&
204
+ JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
205
+ }
206
+ const instructionOnly = retainInstalledInstructions && controlBindingMatches && !bindingMatches;
207
+ let instructionsChanged = requestParams.developerInstructions != null &&
208
+ instructionPrompts.get(threadId) !== requestParams.developerInstructions;
209
+ let resumed = null;
210
+ const log = (event, fields = {}) => host.log?.(event, fields, { threadId, environment });
211
+ try {
212
+ assertRecoveryOpen();
213
+ if (instructionOnly) {
214
+ // A native goal can start work while the host reads its prompt.
215
+ nativeStatus = await readStatus();
216
+ if (nativeStatus === "active") {
217
+ requestParams = installedRequestParams;
218
+ environment = requestParams.config.shell_environment_policy.set;
219
+ bindingMatches = true;
220
+ instructionsChanged = false;
223
221
  }
224
222
  }
225
223
  if (!bindingMatches || nativeStatus === "notLoaded") {
226
- bindings.delete(threadId);
227
- await host.beforeResume?.(threadId);
228
- resumed = await request("thread/resume", { excludeTurns: true, ...requestParams, threadId });
229
- if (requestParams.modelProvider && resumed.modelProvider !== requestParams.modelProvider) {
230
- throw Object.assign(new Error("Another native subscriber retained the previous model provider. Close the native assistant terminal and retry."), {
231
- code: "assistant_codex_provider_binding_retained"
232
- });
233
- }
234
- if (!["idle", "active"].includes(await readStatus())) throw new Error("Codex did not confirm the thread loaded.");
224
+ log("reload_started", { reason: bound ? "control-environment-changed" : "unverified-thread-binding", nativeStatus });
235
225
  if (nativeStatus !== "notLoaded") {
236
- await host.verifyEnvironment(threadId, environment, request, signal, {
237
- requiredKeys: requestParams.developerInstructions == null ? [] : [INSTRUCTION_REVISION_ENV]
238
- });
239
- }
240
- const checked = fixedPolicy ? environment : instructionEnvironment(params, await host.prepareEnvironment(environment));
241
- if (JSON.stringify(checked) !== JSON.stringify(environment)) {
242
- throw new Error("Managed controls changed again during thread recovery.");
226
+ const { goal } = await request("thread/goal/get", { threadId });
227
+ if (goal?.status === "active") {
228
+ const result = await request("thread/goal/set", { threadId, status: "paused" });
229
+ pausedGoal = result.goal;
230
+ if (!isSamePausedGoal(pausedGoal, goal)) {
231
+ throw new Error("Codex did not confirm that the same goal stopped scheduling work.");
232
+ }
233
+ }
234
+ if (await readStatus() !== "idle") {
235
+ if (settings) {
236
+ settings.prepared = false;
237
+ return apply();
238
+ } else if (instructionOnly) {
239
+ // Never interrupt work solely to refresh its instructions.
240
+ requestParams = installedRequestParams;
241
+ environment = requestParams.config.shell_environment_policy.set;
242
+ bindingMatches = true;
243
+ instructionsChanged = false;
244
+ } else {
245
+ const page = await request("thread/turns/list", { threadId, limit: 1, sortDirection: "desc", itemsView: "summary" });
246
+ const turnId = page.data?.[0]?.id;
247
+ if (!turnId) throw new Error("Codex's running turn could not be identified for control recovery.");
248
+ assertRecoveryOpen();
249
+ host.recordInterruption?.(threadId, turnId);
250
+ await request("turn/interrupt", { threadId, turnId });
251
+ while (await readStatus() !== "idle") {
252
+ signal.throwIfAborted();
253
+ await new Promise((resolve) => setTimeout(resolve, 25));
254
+ }
255
+ }
256
+ }
257
+ if (pausedGoal) {
258
+ const { goal } = await request("thread/goal/get", { threadId });
259
+ pausedGoal = isSamePausedGoal(goal, pausedGoal) ? goal : null;
260
+ }
261
+ if (!bindingMatches) {
262
+ const detached = await request("thread/unsubscribe", { threadId });
263
+ if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(detached?.status)) {
264
+ throw new Error("Codex did not confirm detachment before control recovery.");
265
+ }
266
+ }
243
267
  }
244
- if (instructionsChanged) {
245
- // Resume config supplies the next compacted context, but existing
246
- // history retains its original developer message. A revision in
247
- // the developer lane makes this change effective before new work.
248
- // An unknown binding also needs restoration; ordinary turns and
249
- // socket reconnects with an acknowledged binding do not repeat it.
250
- await request("thread/inject_items", { threadId, items: [{ type: "message", role: "developer", content: [{
251
- type: "input_text", text: "These are the current application instructions. They replace earlier application instructions; other instructions remain in effect.\n\n" + requestParams.developerInstructions
252
- }] }] });
268
+ if (!bindingMatches || nativeStatus === "notLoaded") {
269
+ bindings.delete(threadId);
270
+ await host.beforeResume?.(threadId);
271
+ resumed = await request("thread/resume", { excludeTurns: true, ...requestParams, threadId });
272
+ if (requestParams.modelProvider && resumed.modelProvider !== requestParams.modelProvider) {
273
+ throw Object.assign(new Error("Another native subscriber retained the previous model provider. Close the native assistant terminal and retry."), {
274
+ code: "assistant_codex_provider_binding_retained"
275
+ });
276
+ }
277
+ if (!["idle", "active"].includes(await readStatus())) throw new Error("Codex did not confirm the thread loaded.");
278
+ if (nativeStatus !== "notLoaded") {
279
+ await host.verifyEnvironment(threadId, environment, request, signal, {
280
+ requiredKeys: requestParams.developerInstructions == null ? [] : [INSTRUCTION_REVISION_ENV]
281
+ });
282
+ }
283
+ const checked = fixedPolicy ? environment : instructionEnvironment(params, await host.prepareEnvironment(environment));
284
+ if (JSON.stringify(checked) !== JSON.stringify(environment)) {
285
+ throw new Error("Managed controls changed again during thread recovery.");
286
+ }
287
+ if (instructionsChanged) {
288
+ // Resume config supplies the next compacted context, but existing
289
+ // history retains its original developer message. A revision in
290
+ // the developer lane makes this change effective before new work.
291
+ // An unknown binding also needs restoration; ordinary turns and
292
+ // socket reconnects with an acknowledged binding do not repeat it.
293
+ await request("thread/inject_items", { threadId, items: [{ type: "message", role: "developer", content: [{
294
+ type: "input_text", text: "These are the current application instructions. They replace earlier application instructions; other instructions remain in effect.\n\n" + requestParams.developerInstructions
295
+ }] }] });
296
+ assertRecoveryOpen();
297
+ instructionPrompts.set(threadId, requestParams.developerInstructions);
298
+ }
253
299
  assertRecoveryOpen();
254
- instructionPrompts.set(threadId, requestParams.developerInstructions);
300
+ bindings.set(threadId, { client, executionId, params: requestParams, managed: !fixedPolicy });
301
+ log("ready");
255
302
  }
256
- assertRecoveryOpen();
257
- bindings.set(threadId, { client, executionId, params: requestParams, managed: !fixedPolicy });
258
- log("ready");
259
303
  }
304
+ } catch (cause) {
305
+ host.onRecoveryFailure?.(threadId);
306
+ bindings.delete(threadId);
307
+ log("recovery_failed", { code: cause?.code || "", reason: cause.message });
308
+ throw Object.assign(new Error(["assistant_agent_control_binding_retained", "assistant_codex_provider_binding_retained"].includes(cause?.code)
309
+ ? `${cause.message} Your conversation and work are preserved.`
310
+ : "The assistant connection could not be verified. Your conversation and files are preserved. Retry the connection, or renew the session to continue in a fresh conversation.", { cause }), {
311
+ code: "assistant_agent_control_recovery_failed", retryable: true
312
+ });
260
313
  }
261
- } catch (cause) {
262
- host.onRecoveryFailure?.(threadId);
263
- bindings.delete(threadId);
264
- log("recovery_failed", { code: cause?.code || "", reason: cause.message });
265
- throw Object.assign(new Error(["assistant_agent_control_binding_retained", "assistant_codex_provider_binding_retained"].includes(cause?.code)
266
- ? `${cause.message} Your conversation and work are preserved.`
267
- : "The assistant connection could not be verified. Your conversation and files are preserved. Retry the connection, or renew the session to continue in a fresh conversation.", { cause }), {
268
- code: "assistant_agent_control_recovery_failed", retryable: true
269
- });
270
- }
271
- // Never retry the caller's operation: its outcome may already be durable.
272
- assertRecoveryOpen();
273
- const result = await operation(requestParams, resumed);
274
- if (pausedGoal && goalChanges.get(threadId) === goalChange) {
275
- const { goal } = await request("thread/goal/get", { threadId });
276
- if (isSamePausedGoal(goal, pausedGoal) && goal.updatedAt === pausedGoal.updatedAt) {
277
- await request("thread/goal/set", { threadId, status: "active" });
314
+ // A newer settings save supersedes the older choice before scheduling
315
+ // another turn. Ordinary caller operations are never retried.
316
+ assertRecoveryOpen();
317
+ if (settings && settings.params !== requestedSettings) return apply();
318
+ const result = await operation(requestParams, resumed);
319
+ if (settings && settings.params !== requestedSettings) return apply();
320
+ if (pausedGoal && goalChanges.get(threadId) === goalChange) {
321
+ const { goal } = await request("thread/goal/get", { threadId });
322
+ if (settings && settings.params !== requestedSettings) return apply();
323
+ if (goalChanges.get(threadId) === goalChange && isSamePausedGoal(goal, pausedGoal) && goal.updatedAt === pausedGoal.updatedAt) {
324
+ await request("thread/goal/set", { threadId, status: "active" });
325
+ }
278
326
  }
327
+ if (settings && settings.params !== requestedSettings) {
328
+ settings.prepared = false;
329
+ return apply();
330
+ }
331
+ // Retire the completed update before yielding to its caller, so a new
332
+ // save cannot attach itself to an operation that has already finished.
333
+ if (settings && settingsUpdates.get(threadId) === settings) settingsUpdates.delete(threadId);
334
+ return result;
279
335
  }
280
- return result;
336
+ return apply();
281
337
  }, "codex-app-server-thread-controls"));
282
338
  tasks.set(threadId, task);
283
339
  try {
@@ -287,7 +343,52 @@ export function createCodexConversationAdapter(host) {
287
343
  }
288
344
  }
289
345
 
346
+ function scheduleSettings(threadId, params, operation) {
347
+ const pending = settingsUpdates.get(threadId);
348
+ if (pending) {
349
+ pending.params = params;
350
+ return pending.accepted.promise;
351
+ }
352
+ const accepted = Promise.withResolvers();
353
+ const settings = { params, accepted, controller: new AbortController(),
354
+ goalChange: goalChanges.get(threadId) };
355
+ settingsUpdates.set(threadId, settings);
356
+ const task = withThreadContext(threadId, params, operation, settings);
357
+ void task.then(result => {
358
+ try { host.log?.("settings_applied", { model: settings.params.model, modelProvider: settings.params.modelProvider }, { threadId }); } catch { /* Diagnostics do not own scheduling. */ }
359
+ accepted.resolve(result);
360
+ }, async error => {
361
+ accepted.reject(error);
362
+ try { host.log?.("settings_failed", { code: error.code || "native_settings_failed" }, { threadId }); } catch { /* Logging cannot own native cleanup. */ }
363
+ // If a deferred reload fails, expose the actual paused goal for explicit
364
+ // recovery. Never resume it speculatively or hide a user's later control.
365
+ if (settingsUpdates.get(threadId) === settings) settingsUpdates.delete(threadId);
366
+ if (!settings.controller.signal.aborted && settings.client?.isOpen?.()) {
367
+ try {
368
+ const { goal } = await settings.client.request("thread/goal/get", { threadId }, {
369
+ signal: AbortSignal.timeout(host.timeoutMs || 10_000)
370
+ });
371
+ host.publishGoal?.(threadId, goal);
372
+ } catch { /* Native observation owns disconnect failures. */ }
373
+ }
374
+ }).finally(() => {
375
+ if (settingsUpdates.get(threadId) === settings) settingsUpdates.delete(threadId);
376
+ });
377
+ return accepted.promise;
378
+ }
379
+
380
+ function projectGoal(threadId, goal) {
381
+ const pending = settingsUpdates.get(threadId);
382
+ if (goal?.status === "paused" && pending?.goal && pending.goalChange === goalChanges.get(threadId) &&
383
+ goal.createdAt === pending.goal.createdAt && goal.objective === pending.goal.objective &&
384
+ (pending.goal.status === "active" || goal.updatedAt === pending.goal.updatedAt)) return { ...goal, status: "active" };
385
+ return goal;
386
+ }
387
+
290
388
  return Object.freeze({
389
+ scheduleSettings,
390
+ hasPendingSettings: id => settingsUpdates.has(id),
391
+ projectGoal,
291
392
  prepareInstructions,
292
393
  threadParameters,
293
394
  withThreadContext,
@@ -296,6 +397,8 @@ export function createCodexConversationAdapter(host) {
296
397
  if (binding.params.developerInstructions != null) instructionPrompts.set(id, binding.params.developerInstructions);
297
398
  },
298
399
  invalidateThread(id) {
400
+ settingsUpdates.get(id)?.controller.abort();
401
+ settingsUpdates.delete(id);
299
402
  threadGenerations.set(id, (threadGenerations.get(id) || 0) + 1);
300
403
  bindings.delete(id);
301
404
  },
@@ -303,6 +406,12 @@ export function createCodexConversationAdapter(host) {
303
406
  get bindingCount() { return bindings.size; },
304
407
  get threadEnvironments() { return bindings; },
305
408
  get threadEnvironmentTasks() { return tasks; },
306
- close() { contextGeneration += 1; bindings.clear(); instructionPrompts.clear(); }
409
+ close() {
410
+ contextGeneration += 1;
411
+ for (const pending of settingsUpdates.values()) pending.controller.abort();
412
+ settingsUpdates.clear();
413
+ bindings.clear();
414
+ instructionPrompts.clear();
415
+ }
307
416
  });
308
417
  }