@zq-silk/yui 0.13.2 → 0.13.4

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.
@@ -22,21 +22,17 @@ export class CodexAppServerRuntime {
22
22
  async openConversation(input) {
23
23
  const result = await this.transport.request("thread/start", {
24
24
  cwd: text(input.cwd, "Codex thread cwd"),
25
- ...(input.model === undefined ? {} : { model: input.model }),
26
- ...(input.approvalPolicy === undefined ? {} : { approvalPolicy: input.approvalPolicy }),
27
- ...(input.sandbox === undefined ? {} : { sandbox: input.sandbox }),
28
- ...(input.developerInstructions === undefined
29
- ? {}
30
- : { developerInstructions: input.developerInstructions }),
31
- ...(input.runtimeWorkspaceRoots === undefined
32
- ? {}
33
- : { runtimeWorkspaceRoots: [...input.runtimeWorkspaceRoots] })
25
+ ...threadOptions(input)
34
26
  });
35
27
  return { conversationId: threadId(result) };
36
28
  }
37
- async resumeConversation(conversationId) {
29
+ async resumeConversation(conversationId, options = {}) {
38
30
  const id = text(conversationId, "Codex thread id");
39
- const result = await this.transport.request("thread/resume", { threadId: id });
31
+ const result = await this.transport.request("thread/resume", {
32
+ threadId: id,
33
+ ...(options.cwd === undefined ? {} : { cwd: text(options.cwd, "Codex thread cwd") }),
34
+ ...threadOptions(options)
35
+ });
40
36
  return parseThreadSnapshot(result, id, true);
41
37
  }
42
38
  async setConversationName(input) {
@@ -85,7 +81,11 @@ export class CodexAppServerRuntime {
85
81
  const snapshot = await this.readConversation(requestedThreadId);
86
82
  threadId = snapshot.threadId;
87
83
  if (input.expectedNoActiveTurn && snapshot.activeTurnId !== undefined) {
88
- return { status: "not-accepted", reason: `active-turn:${snapshot.activeTurnId}` };
84
+ return {
85
+ status: "busy",
86
+ activeTurnId: snapshot.activeTurnId,
87
+ reason: `active-turn:${snapshot.activeTurnId}`
88
+ };
89
89
  }
90
90
  }
91
91
  catch (error) {
@@ -249,12 +249,25 @@ function parseThreadSnapshot(result, expectedThreadId, loaded) {
249
249
  const turns = arrayMember(thread, "turns").map(object).filter((entry) => (entry !== null));
250
250
  const active = [...turns].reverse().find((turn) => (["inProgress", "in_progress", "running", "active"].includes(String(turn.status))));
251
251
  const latestStatus = optionalTurnStatus(turns.at(-1)?.status);
252
+ const turnSnapshots = turns.flatMap((turn) => {
253
+ const turnId = optionalId(turn.id);
254
+ if (turnId === undefined)
255
+ return [];
256
+ const status = optionalTurnStatus(turn.status);
257
+ const error = providerError(turn.error);
258
+ return [{
259
+ turnId,
260
+ ...(status === undefined ? {} : { status }),
261
+ ...(error === undefined ? {} : { error })
262
+ }];
263
+ });
252
264
  return {
253
265
  threadId: id,
254
266
  loaded,
255
267
  status: threadStatus(thread.status),
256
268
  ...(optionalId(active?.id) === undefined ? {} : { activeTurnId: optionalId(active?.id) }),
257
269
  ...(latestStatus === undefined ? {} : { latestTurnStatus: latestStatus }),
270
+ turns: Object.freeze(turnSnapshots),
258
271
  ...(optionalId(thread.parentThreadId) === undefined
259
272
  ? {}
260
273
  : { parentThreadId: optionalId(thread.parentThreadId) }),
@@ -262,6 +275,17 @@ function parseThreadSnapshot(result, expectedThreadId, loaded) {
262
275
  raw: result
263
276
  };
264
277
  }
278
+ function providerError(value) {
279
+ if (typeof value === "string" && value.trim().length > 0)
280
+ return value.trim();
281
+ const record = object(value);
282
+ if (record === null)
283
+ return undefined;
284
+ if (typeof record.message === "string" && record.message.trim().length > 0) {
285
+ return record.message.trim();
286
+ }
287
+ return JSON.stringify(record);
288
+ }
265
289
  function codexContinuationState(snapshot) {
266
290
  if (snapshot.status === "active" || snapshot.latestTurnStatus === "inProgress") {
267
291
  return {
@@ -317,12 +341,19 @@ function optionalTurnStatus(value) {
317
341
  }
318
342
  function classifyMutationError(error) {
319
343
  if (error instanceof CodexAppServerRequestError) {
344
+ if (codexAppServerErrorIsBusy(error)) {
345
+ return { status: "busy", reason: error.message };
346
+ }
320
347
  if (["INVALID_PARAMS", "NOT_FOUND", "TURN_NOT_ACTIVE", -32602].includes(error.code)) {
321
348
  return { status: "not-accepted", reason: error.message };
322
349
  }
323
350
  }
324
351
  return { status: "unknown", reason: error instanceof Error ? error.message : String(error) };
325
352
  }
353
+ function codexAppServerErrorIsBusy(error) {
354
+ return /\b(active turn|turn (?:is )?(?:already )?(?:active|in progress|running)|already has an active)\b/iu
355
+ .test(error.message);
356
+ }
326
357
  function isNotLoaded(error) {
327
358
  return error instanceof CodexAppServerRequestError
328
359
  && (String(error.code).toLowerCase().includes("not_loaded")
@@ -366,3 +397,23 @@ function text(value, label) {
366
397
  }
367
398
  return value.trim();
368
399
  }
400
+ function threadOptions(input) {
401
+ return {
402
+ ...(input.model === undefined ? {} : { model: text(input.model, "Codex model") }),
403
+ ...(input.approvalPolicy === undefined
404
+ ? {}
405
+ : { approvalPolicy: text(input.approvalPolicy, "Codex approval policy") }),
406
+ ...(input.sandbox === undefined ? {} : { sandbox: text(input.sandbox, "Codex sandbox") }),
407
+ ...(input.developerInstructions === undefined
408
+ ? {}
409
+ : {
410
+ developerInstructions: text(input.developerInstructions, "Codex developer instructions")
411
+ }),
412
+ ...(input.runtimeWorkspaceRoots === undefined
413
+ ? {}
414
+ : {
415
+ runtimeWorkspaceRoots: input.runtimeWorkspaceRoots.map((root) => (text(root, "Codex runtime workspace root")))
416
+ }),
417
+ ...(input.config === undefined ? {} : { config: { ...input.config } })
418
+ };
419
+ }
@@ -86,10 +86,25 @@ function validateProviderControl(control) {
86
86
  if (control.adapterId !== "codex" && control.adapterId !== "claude") {
87
87
  throw new Error("Agent Host Provider control adapter is invalid.");
88
88
  }
89
- if ((control.adapterId === "codex" && control.transport !== "codex-app-server-stdio")
89
+ if ((control.adapterId === "codex" && control.transport !== "codex-app-server-proxy")
90
90
  || (control.adapterId === "claude" && control.transport !== "claude-stream-json")) {
91
91
  throw new Error("Agent Host Provider control transport does not match its adapter.");
92
92
  }
93
+ if ((control.adapterId === "codex") !== (control.codexThread !== undefined)) {
94
+ throw new Error("Agent Host Provider thread settings do not match its adapter.");
95
+ }
96
+ if ((control.adapterId === "codex") !== (control.codexDaemonStartArgs !== undefined)) {
97
+ throw new Error("Agent Host Provider daemon bootstrap does not match its adapter.");
98
+ }
99
+ if (control.codexThread !== undefined)
100
+ validateCodexThreadOptions(control.codexThread);
101
+ if (control.codexDaemonStartArgs !== undefined) {
102
+ if (!Array.isArray(control.codexDaemonStartArgs)
103
+ || control.codexDaemonStartArgs.length === 0) {
104
+ throw new Error("Agent Host Codex daemon bootstrap args are invalid.");
105
+ }
106
+ control.codexDaemonStartArgs.forEach((value) => text(value, "Codex daemon argument"));
107
+ }
93
108
  if (control.mode !== "new" && control.mode !== "resume") {
94
109
  throw new Error("Agent Host Provider control mode is invalid.");
95
110
  }
@@ -105,6 +120,16 @@ function validateProviderControl(control) {
105
120
  if (control.kind === "ensure" && control.initialTurn !== undefined) {
106
121
  throw new Error("Managed Provider ensure launch cannot carry a new Turn.");
107
122
  }
123
+ if (control.kind !== "ensure" && "ownedTurn" in control) {
124
+ throw new Error("Only a managed Provider ensure launch can recover an owned Turn.");
125
+ }
126
+ if (control.kind === "ensure" && control.ownedTurn !== undefined) {
127
+ if (control.adapterId !== "codex") {
128
+ throw new Error("Only Managed Codex can recover an owned Turn across client attachment.");
129
+ }
130
+ text(control.ownedTurn.attemptId, "owned Provider input attemptId");
131
+ text(control.ownedTurn.turnId, "owned Provider Turn id");
132
+ }
108
133
  const requiresNativeSessionId = control.mode === "resume" || control.adapterId === "claude";
109
134
  if (requiresNativeSessionId !== (control.nativeSessionId !== undefined)) {
110
135
  throw new Error("Agent Host Provider resume identity is inconsistent.");
@@ -129,6 +154,31 @@ function validateProviderControl(control) {
129
154
  }
130
155
  }
131
156
  }
157
+ function validateCodexThreadOptions(options) {
158
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
159
+ throw new Error("Agent Host Codex thread settings are invalid.");
160
+ }
161
+ for (const [value, label] of [
162
+ [options.model, "model"],
163
+ [options.approvalPolicy, "approval policy"],
164
+ [options.sandbox, "sandbox"],
165
+ [options.developerInstructions, "developer instructions"]
166
+ ]) {
167
+ if (value !== undefined)
168
+ text(value, `Codex thread ${label}`);
169
+ }
170
+ if (options.runtimeWorkspaceRoots !== undefined) {
171
+ if (!Array.isArray(options.runtimeWorkspaceRoots)) {
172
+ throw new Error("Agent Host Codex runtime workspace roots are invalid.");
173
+ }
174
+ options.runtimeWorkspaceRoots.forEach((root) => text(root, "Codex runtime workspace root"));
175
+ }
176
+ if (options.config !== undefined
177
+ && (options.config === null || typeof options.config !== "object"
178
+ || Array.isArray(options.config))) {
179
+ throw new Error("Agent Host Codex thread config is invalid.");
180
+ }
181
+ }
132
182
  function text(value, label) {
133
183
  if (typeof value !== "string" || value.length === 0 || value.includes("\0")) {
134
184
  throw new Error(`Agent Host ${label} is invalid.`);
@@ -14,7 +14,15 @@ export function createRuntimeBinding(input) {
14
14
  const initialTurnRejectedRunId = input.initialTurnRejectedRunId === undefined
15
15
  ? undefined
16
16
  : requireSafeIdentity(input.initialTurnRejectedRunId, "Rejected initial Turn Run id");
17
- if ([initialTurnRunId, initialTurnDeliveryUnknownRunId, initialTurnRejectedRunId]
17
+ const initialTurnBusyRunId = input.initialTurnBusyRunId === undefined
18
+ ? undefined
19
+ : requireSafeIdentity(input.initialTurnBusyRunId, "Busy initial Turn Run id");
20
+ if ([
21
+ initialTurnRunId,
22
+ initialTurnDeliveryUnknownRunId,
23
+ initialTurnBusyRunId,
24
+ initialTurnRejectedRunId
25
+ ]
18
26
  .filter((value) => value !== undefined).length > 1) {
19
27
  throw new TypeError("Runtime binding must report at most one initial Turn outcome.");
20
28
  }
@@ -30,6 +38,7 @@ export function createRuntimeBinding(input) {
30
38
  ...(initialTurnDeliveryUnknownRunId === undefined
31
39
  ? {}
32
40
  : { initialTurnDeliveryUnknownRunId }),
41
+ ...(initialTurnBusyRunId === undefined ? {} : { initialTurnBusyRunId }),
33
42
  ...(initialTurnRejectedRunId === undefined ? {} : { initialTurnRejectedRunId }),
34
43
  ...(input.nativeSessionId === undefined
35
44
  ? {}
@@ -4,6 +4,8 @@ import { CodexAppServerRequestError, CodexAppServerRuntime, codexAppServerErrorI
4
4
  import { PROVIDER_ACCEPT_TIMEOUT_MS } from "./runtimeDeadlines.js";
5
5
  import { YUI_VERSION } from "../version.js";
6
6
  const PROVIDER_MESSAGE_MAX_BYTES = 16 * 1024 * 1024;
7
+ const CODEX_DAEMON_START_TIMEOUT_MS = 10_000;
8
+ const CODEX_DAEMON_OUTPUT_MAX_BYTES = 64 * 1024;
7
9
  export class ProviderDeliveryUnknownError extends Error {
8
10
  attemptId;
9
11
  name = "ProviderDeliveryUnknownError";
@@ -20,6 +22,17 @@ export class ProviderTurnRejectedError extends Error {
20
22
  this.attemptId = attemptId;
21
23
  }
22
24
  }
25
+ /** Another ordinary client currently owns the thread's active Turn. */
26
+ export class ProviderTurnBusyError extends Error {
27
+ attemptId;
28
+ activeTurnId;
29
+ name = "ProviderTurnBusyError";
30
+ constructor(message, attemptId, activeTurnId) {
31
+ super(message);
32
+ this.attemptId = attemptId;
33
+ this.activeTurnId = activeTurnId;
34
+ }
35
+ }
23
36
  export class ProviderConversationMissingError extends Error {
24
37
  conversationId;
25
38
  name = "ProviderConversationMissingError";
@@ -33,6 +46,9 @@ export async function startStructuredProviderSession(payload, input = {}) {
33
46
  if (control === undefined) {
34
47
  throw new Error("Managed Agent Host launch requires Provider control metadata.");
35
48
  }
49
+ if (control.adapterId === "codex") {
50
+ await ensureCodexAppServerDaemon(payload, control);
51
+ }
36
52
  const child = spawn(payload.command, [...payload.args], {
37
53
  cwd: payload.cwd,
38
54
  env: { ...payload.environment },
@@ -45,9 +61,16 @@ export async function startStructuredProviderSession(payload, input = {}) {
45
61
  child.stderr.on("data", (chunk) => mirror("stderr", chunk));
46
62
  const exit = childExit(child, processInstanceId);
47
63
  try {
48
- const session = control.adapterId === "codex"
49
- ? await CodexStructuredProviderSession.open(child, exit, processInstanceId, payload, control, input.onTerminal, mirror)
50
- : await ClaudeStructuredProviderSession.open(child, exit, processInstanceId, control, input.onTerminal, mirror);
64
+ if (control.adapterId === "codex") {
65
+ const opened = await CodexStructuredProviderSession.open(child, exit, processInstanceId, payload, control, input.onTerminal, mirror);
66
+ return Object.freeze({
67
+ session: opened.session,
68
+ ...(opened.recoveredTerminal === undefined
69
+ ? {}
70
+ : { recoveredTerminal: opened.recoveredTerminal })
71
+ });
72
+ }
73
+ const session = await ClaudeStructuredProviderSession.open(child, exit, processInstanceId, control, input.onTerminal, mirror);
51
74
  return Object.freeze({ session });
52
75
  }
53
76
  catch (error) {
@@ -55,6 +78,58 @@ export async function startStructuredProviderSession(payload, input = {}) {
55
78
  throw error;
56
79
  }
57
80
  }
81
+ async function ensureCodexAppServerDaemon(payload, control) {
82
+ if (payload.args.at(-2) !== "app-server" || payload.args.at(-1) !== "proxy")
83
+ return;
84
+ if (control.adapterId !== "codex")
85
+ return;
86
+ const environment = Object.fromEntries(Object.entries(payload.environment).filter(([key]) => (!key.startsWith("YUI_")
87
+ && key !== "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"
88
+ && !["TMPDIR", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR"]
89
+ .includes(key))));
90
+ const child = spawn(payload.command, [...control.codexDaemonStartArgs], {
91
+ cwd: payload.cwd,
92
+ env: environment,
93
+ stdio: ["ignore", "pipe", "pipe"]
94
+ });
95
+ await new Promise((resolvePromise, reject) => {
96
+ let output = "";
97
+ let settled = false;
98
+ let timer;
99
+ const settle = (error) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ if (timer !== undefined)
104
+ clearTimeout(timer);
105
+ if (error === undefined)
106
+ resolvePromise();
107
+ else
108
+ reject(error);
109
+ };
110
+ const capture = (chunk) => {
111
+ output += String(chunk);
112
+ if (Buffer.byteLength(output, "utf8") <= CODEX_DAEMON_OUTPUT_MAX_BYTES)
113
+ return;
114
+ child.kill("SIGTERM");
115
+ settle(new Error("Codex App Server daemon start output exceeded its bound."));
116
+ };
117
+ child.stdout.on("data", capture);
118
+ child.stderr.on("data", capture);
119
+ child.once("error", (error) => settle(error));
120
+ child.once("close", (code, signal) => {
121
+ if (code === 0)
122
+ settle();
123
+ else
124
+ settle(new Error(`Codex App Server daemon start failed (${code ?? signal ?? "unknown"})${output.trim().length === 0 ? "" : `: ${output.trim()}`}`));
125
+ });
126
+ timer = setTimeout(() => {
127
+ child.kill("SIGTERM");
128
+ settle(new Error("Codex App Server daemon start timed out."));
129
+ }, CODEX_DAEMON_START_TIMEOUT_MS);
130
+ timer.unref();
131
+ });
132
+ }
58
133
  class JsonLineChannel {
59
134
  child;
60
135
  mirror;
@@ -181,17 +256,24 @@ class CodexStructuredProviderSession {
181
256
  processInstanceId;
182
257
  conversationId;
183
258
  runtime;
259
+ onTerminal;
184
260
  adapterId = "codex";
185
261
  #activeTurnId;
186
- constructor(child, exit, processInstanceId, conversationId, runtime) {
262
+ #clientOwnedTurnId;
263
+ #submissionPending = false;
264
+ #bufferedTerminals = [];
265
+ constructor(child, exit, processInstanceId, conversationId, runtime, onTerminal) {
187
266
  this.child = child;
188
267
  this.exit = exit;
189
268
  this.processInstanceId = processInstanceId;
190
269
  this.conversationId = conversationId;
191
270
  this.runtime = runtime;
271
+ this.onTerminal = onTerminal;
192
272
  }
193
273
  static async open(child, exit, processInstanceId, payload, control, onTerminal, mirror) {
194
274
  const channel = new JsonLineChannel(child, mirror);
275
+ const openingMessages = [];
276
+ const stopOpeningBuffer = channel.onMessage((message) => openingMessages.push(message));
195
277
  await channel.request("initialize", {
196
278
  clientInfo: { name: "yui", title: "Yui", version: YUI_VERSION },
197
279
  capabilities: {
@@ -203,14 +285,22 @@ class CodexStructuredProviderSession {
203
285
  const runtime = new CodexAppServerRuntime(channel);
204
286
  let conversationId;
205
287
  let resumedActiveTurnId;
288
+ let resumedTurns = [];
206
289
  if (control.mode === "new") {
207
- conversationId = (await runtime.openConversation({ cwd: payload.cwd })).conversationId;
290
+ conversationId = (await runtime.openConversation({
291
+ cwd: payload.cwd,
292
+ ...control.codexThread
293
+ })).conversationId;
208
294
  }
209
295
  else {
210
296
  try {
211
- const resumed = await runtime.resumeConversation(control.nativeSessionId);
297
+ const resumed = await runtime.resumeConversation(control.nativeSessionId, {
298
+ cwd: payload.cwd,
299
+ ...control.codexThread
300
+ });
212
301
  conversationId = resumed.threadId;
213
302
  resumedActiveTurnId = resumed.activeTurnId;
303
+ resumedTurns = resumed.turns;
214
304
  }
215
305
  catch (error) {
216
306
  if (codexAppServerErrorIsMissing(error)) {
@@ -225,39 +315,81 @@ class CodexStructuredProviderSession {
225
315
  name: control.sessionTitle
226
316
  });
227
317
  }
228
- const session = new CodexStructuredProviderSession(child, exit, processInstanceId, conversationId, runtime);
318
+ const session = new CodexStructuredProviderSession(child, exit, processInstanceId, conversationId, runtime, onTerminal);
229
319
  session.#activeTurnId = resumedActiveTurnId;
320
+ const ownedTurn = control.kind === "ensure" ? control.ownedTurn : undefined;
321
+ session.#clientOwnedTurnId = ownedTurn?.turnId;
322
+ stopOpeningBuffer();
323
+ let recoveredTerminal;
324
+ for (const message of openingMessages) {
325
+ const terminal = session.#observeMessage(message, false);
326
+ if (terminal?.clientOwned === true)
327
+ recoveredTerminal = terminal;
328
+ }
230
329
  channel.onMessage((message) => {
231
- const method = typeof message.method === "string" ? message.method : "";
232
- const params = object(message.params) ?? {};
233
- if (optionalId(params.threadId) !== conversationId)
234
- return;
235
- if (method === "turn/started") {
236
- const turnId = nestedId(params, "turn") ?? optionalId(params.turnId);
237
- if (turnId !== undefined)
238
- session.#activeTurnId = turnId;
239
- return;
330
+ session.#observeMessage(message, true);
331
+ });
332
+ if (ownedTurn !== undefined && recoveredTerminal === undefined
333
+ && session.#activeTurnId !== ownedTurn.turnId) {
334
+ const recovered = resumedTurns.find((turn) => turn.turnId === ownedTurn.turnId);
335
+ if (recovered?.status === "completed"
336
+ || recovered?.status === "interrupted"
337
+ || recovered?.status === "failed") {
338
+ recoveredTerminal = {
339
+ conversationId,
340
+ nativeSessionId: conversationId,
341
+ nativeTurnId: ownedTurn.turnId,
342
+ clientOwned: true,
343
+ status: recovered.status === "failed"
344
+ ? "failed"
345
+ : recovered.status === "interrupted" ? "cancelled" : "completed",
346
+ observedAt: new Date().toISOString(),
347
+ ...(recovered.error === undefined ? {} : { error: recovered.error })
348
+ };
349
+ session.#clientOwnedTurnId = undefined;
240
350
  }
241
- if (method !== "turn/completed")
242
- return;
243
- const turn = object(params.turn) ?? {};
244
- const nativeTurnId = optionalId(turn.id) ?? optionalId(params.turnId);
245
- if (nativeTurnId === undefined)
246
- return;
247
- session.#activeTurnId = undefined;
248
- const status = turn.status === "failed"
249
- ? "failed"
250
- : turn.status === "interrupted" ? "cancelled" : "completed";
251
- onTerminal?.({
252
- conversationId,
253
- nativeSessionId: conversationId,
254
- nativeTurnId,
255
- status,
256
- observedAt: new Date().toISOString(),
257
- ...(status !== "failed" ? {} : { error: providerErrorText(turn.error) })
258
- });
351
+ else {
352
+ throw new ProviderDeliveryUnknownError(`Codex resume could not recover the persisted Yui Turn ${ownedTurn.turnId}.`, ownedTurn.attemptId);
353
+ }
354
+ }
355
+ return Object.freeze({
356
+ session,
357
+ ...(recoveredTerminal === undefined ? {} : { recoveredTerminal })
259
358
  });
260
- return session;
359
+ }
360
+ #observeMessage(message, emit) {
361
+ const method = typeof message.method === "string" ? message.method : "";
362
+ const params = object(message.params) ?? {};
363
+ if (optionalId(params.threadId) !== this.conversationId)
364
+ return undefined;
365
+ if (method === "turn/started") {
366
+ const turnId = nestedId(params, "turn") ?? optionalId(params.turnId);
367
+ if (turnId !== undefined)
368
+ this.#activeTurnId = turnId;
369
+ return undefined;
370
+ }
371
+ if (method !== "turn/completed")
372
+ return undefined;
373
+ const turn = object(params.turn) ?? {};
374
+ const nativeTurnId = optionalId(turn.id) ?? optionalId(params.turnId);
375
+ if (nativeTurnId === undefined)
376
+ return undefined;
377
+ const status = turn.status === "failed"
378
+ ? "failed"
379
+ : turn.status === "interrupted" ? "cancelled" : "completed";
380
+ const terminal = {
381
+ conversationId: this.conversationId,
382
+ nativeSessionId: this.conversationId,
383
+ nativeTurnId,
384
+ status,
385
+ observedAt: new Date().toISOString(),
386
+ ...(status !== "failed" ? {} : { error: providerErrorText(turn.error) })
387
+ };
388
+ if (this.#submissionPending && emit) {
389
+ this.#bufferedTerminals.push(terminal);
390
+ return undefined;
391
+ }
392
+ return this.#completeTerminal(terminal, emit);
261
393
  }
262
394
  get nativeSessionId() {
263
395
  return this.conversationId;
@@ -267,28 +399,55 @@ class CodexStructuredProviderSession {
267
399
  }
268
400
  async submitTurn(turn) {
269
401
  if (this.#activeTurnId !== undefined) {
270
- throw new ProviderTurnRejectedError(`Provider Conversation already has active Turn ${this.#activeTurnId}.`, turn.attemptId);
402
+ throw new ProviderTurnBusyError(`Provider Conversation already has active Turn ${this.#activeTurnId}.`, turn.attemptId, this.#activeTurnId);
271
403
  }
272
- const acceptance = await this.runtime.submitTurn({
273
- conversationId: this.conversationId,
274
- attemptId: turn.attemptId,
275
- text: turn.boundedText,
276
- expectedNoActiveTurn: true
277
- });
278
- if (acceptance.status === "unknown") {
279
- throw new ProviderDeliveryUnknownError(acceptance.reason, turn.attemptId);
404
+ this.#submissionPending = true;
405
+ try {
406
+ const acceptance = await this.runtime.submitTurn({
407
+ conversationId: this.conversationId,
408
+ attemptId: turn.attemptId,
409
+ text: turn.boundedText,
410
+ expectedNoActiveTurn: true
411
+ });
412
+ if (acceptance.status === "unknown") {
413
+ throw new ProviderDeliveryUnknownError(acceptance.reason, turn.attemptId);
414
+ }
415
+ if (acceptance.status === "busy") {
416
+ throw new ProviderTurnBusyError(acceptance.reason, turn.attemptId, acceptance.activeTurnId);
417
+ }
418
+ if (acceptance.status === "not-accepted") {
419
+ throw new ProviderTurnRejectedError(acceptance.reason, turn.attemptId);
420
+ }
421
+ this.#activeTurnId = acceptance.turnId;
422
+ this.#clientOwnedTurnId = acceptance.turnId;
423
+ return Object.freeze({
424
+ attemptId: turn.attemptId,
425
+ conversationId: this.conversationId,
426
+ nativeSessionId: this.conversationId,
427
+ nativeTurnId: acceptance.turnId,
428
+ acceptedAt: new Date().toISOString()
429
+ });
280
430
  }
281
- if (acceptance.status === "not-accepted") {
282
- throw new ProviderTurnRejectedError(acceptance.reason, turn.attemptId);
431
+ finally {
432
+ this.#submissionPending = false;
433
+ const buffered = this.#bufferedTerminals.splice(0);
434
+ for (const terminal of buffered)
435
+ this.#emitTerminal(terminal);
283
436
  }
284
- this.#activeTurnId = acceptance.turnId;
285
- return Object.freeze({
286
- attemptId: turn.attemptId,
287
- conversationId: this.conversationId,
288
- nativeSessionId: this.conversationId,
289
- nativeTurnId: acceptance.turnId,
290
- acceptedAt: new Date().toISOString()
291
- });
437
+ }
438
+ #emitTerminal(terminal) {
439
+ this.#completeTerminal(terminal, true);
440
+ }
441
+ #completeTerminal(terminal, emit) {
442
+ const clientOwned = terminal.nativeTurnId === this.#clientOwnedTurnId;
443
+ if (terminal.nativeTurnId === this.#activeTurnId)
444
+ this.#activeTurnId = undefined;
445
+ if (clientOwned)
446
+ this.#clientOwnedTurnId = undefined;
447
+ const completed = { ...terminal, clientOwned };
448
+ if (emit)
449
+ this.onTerminal?.(completed);
450
+ return completed;
292
451
  }
293
452
  waitForExit() {
294
453
  return this.exit;
@@ -420,6 +579,7 @@ class ClaudeStructuredProviderSession {
420
579
  conversationId: this.conversationId,
421
580
  nativeSessionId: this.conversationId,
422
581
  nativeTurnId,
582
+ clientOwned: true,
423
583
  status: failed ? "failed" : "completed",
424
584
  observedAt: new Date().toISOString(),
425
585
  ...(typeof message.result === "string" && message.result.length > 0
@@ -350,6 +350,7 @@ export class TmuxSessionHost {
350
350
  let hostCreated;
351
351
  let providerAcknowledged = false;
352
352
  let providerDeliveryUnknown = false;
353
+ let providerBusy = false;
353
354
  let providerRejected = false;
354
355
  let providerDispatchObserved = false;
355
356
  let providerSnapshot;
@@ -365,9 +366,11 @@ export class TmuxSessionHost {
365
366
  requireTurnAck: planned.initialTurnRunId !== undefined
366
367
  });
367
368
  providerDeliveryUnknown = providerSnapshot.state === "delivery-unknown";
369
+ providerBusy = planned.initialTurnRunId !== undefined
370
+ && providerSnapshot.state === "busy";
368
371
  providerRejected = planned.initialTurnRunId !== undefined
369
372
  && providerSnapshot.state === "rejected";
370
- providerAcknowledged = !providerDeliveryUnknown && !providerRejected;
373
+ providerAcknowledged = !providerDeliveryUnknown && !providerBusy && !providerRejected;
371
374
  if (providerSnapshot.state === "rejected" && !providerRejected) {
372
375
  throw new Error("Agent Host rejected a launch without an initial Provider Turn.");
373
376
  }
@@ -398,9 +401,11 @@ export class TmuxSessionHost {
398
401
  && controlResult.snapshot.state === "idle");
399
402
  const deliveryUnknownState = planned.initialTurnRunId !== undefined
400
403
  && controlResult.snapshot.state === "delivery-unknown";
404
+ const busyState = planned.initialTurnRunId !== undefined
405
+ && controlResult.snapshot.state === "busy";
401
406
  const rejectedState = planned.initialTurnRunId !== undefined
402
407
  && controlResult.snapshot.state === "rejected";
403
- if ((!acceptableState && !deliveryUnknownState && !rejectedState)
408
+ if ((!acceptableState && !deliveryUnknownState && !busyState && !rejectedState)
404
409
  || controlResult.snapshot.launchId !== reservation.launchId) {
405
410
  broker.revoke(request.launchId);
406
411
  throw new Error(`Agent Host did not return an exact Provider acknowledgement for ${reservation.launchId}.`);
@@ -408,6 +413,7 @@ export class TmuxSessionHost {
408
413
  providerSnapshot = controlResult.snapshot;
409
414
  providerAcknowledged = acceptableState;
410
415
  providerDeliveryUnknown = deliveryUnknownState;
416
+ providerBusy = busyState;
411
417
  providerRejected = rejectedState;
412
418
  providerDispatchObserved = true;
413
419
  }
@@ -449,6 +455,9 @@ export class TmuxSessionHost {
449
455
  ...(providerDeliveryUnknown && planned.initialTurnRunId !== undefined
450
456
  ? { initialTurnDeliveryUnknownRunId: planned.initialTurnRunId }
451
457
  : {}),
458
+ ...(providerBusy && planned.initialTurnRunId !== undefined
459
+ ? { initialTurnBusyRunId: planned.initialTurnRunId }
460
+ : {}),
452
461
  ...(providerRejected && planned.initialTurnRunId !== undefined
453
462
  ? { initialTurnRejectedRunId: planned.initialTurnRunId }
454
463
  : {}),
@@ -567,6 +576,8 @@ export class AgentHostPromptPushAdapter {
567
576
  });
568
577
  if (result.snapshot.state === "delivery-unknown")
569
578
  return "delivery-unknown";
579
+ if (result.snapshot.state === "busy")
580
+ return "busy";
570
581
  if (result.outcome === "rejected")
571
582
  return "rejected";
572
583
  if (result.snapshot.attemptId !== request.envelope.id) {