@ours.network/fleet 1.2.0-nightly.57 → 1.2.0-nightly.59

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,6 +1,6 @@
1
1
  import { constants } from 'node:fs';
2
- import { mkdir, open } from 'node:fs/promises';
3
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { open, readlink } from 'node:fs/promises';
3
+ import { isAbsolute, resolve } from 'node:path';
4
4
  export const MAX_FILE_BYTES = 20 * 1024 * 1024;
5
5
  const FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
6
6
  /** Pin every component; neither trusted root ancestors nor agent components follow symlinks. */
@@ -21,74 +21,43 @@ export async function openDirectory(path) {
21
21
  throw error;
22
22
  }
23
23
  }
24
- export async function pinExportRoot(path) {
25
- const handle = await openDirectory(path);
24
+ /** The agent's provided path, resolved from its working directory; no export subtree. */
25
+ export function chatFilePath(cwd, input) {
26
+ if (typeof input !== 'string' || !input || input.length > 4096 || /[\x00-\x1f]/.test(input))
27
+ throw Error('INVALID_CHAT_FILE_PATH');
28
+ return resolve(cwd, input);
29
+ }
30
+ /**
31
+ * Runs in the harness bridge, never in the supervisor's OS context. Ancestor
32
+ * symlinks resolve normally; the named file itself must be one regular,
33
+ * singly-linked file within the size limit. `path` is the opened file's real path.
34
+ */
35
+ export async function openChatFile(cwd, input) {
36
+ if (process.platform !== 'linux')
37
+ throw Error('CHAT_FILE_REQUIRES_LINUX');
38
+ let file;
26
39
  try {
27
- const stat = await handle.stat();
28
- if (stat.uid !== process.getuid?.() || (stat.mode & 0o022))
29
- throw Error('EXPORT_ROOT_NOT_PRIVATE_OWNED');
30
- return { root: { path, dev: stat.dev, ino: stat.ino }, handle };
40
+ file = await open(chatFilePath(cwd, input), FLAGS);
31
41
  }
32
42
  catch (error) {
33
- await handle.close();
34
- throw error;
35
- }
36
- }
37
- export function exportPath(root, cwd, input) {
38
- if (!input || input.length > 4096 || /[\\\x00-\x1f]/.test(input) || input.split('/').some(p => p === '.' || p === '..' || !p) && !isAbsolute(input))
39
- throw Error('INVALID_EXPORT_PATH');
40
- if (input.split('/').some(p => p === '..' || p === '.'))
41
- throw Error('INVALID_EXPORT_PATH');
42
- const path = relative(root.path, resolve(cwd, input));
43
- const parts = path.split('/');
44
- if (!path || isAbsolute(path) || parts.some(p => !p || p === '..' || p === '.' || p.startsWith('.') || /^(credentials?|secrets?|tokens?)(\.|$)/i.test(p)))
45
- throw Error('OUTSIDE_EXPORT_DIRECTORY');
46
- return parts;
47
- }
48
- /** Runs in the harness bridge, never in the supervisor's OS context. */
49
- export async function openExportFile(root, cwd, input) {
50
- const parts = exportPath(root, cwd, input);
51
- let dir = await openDirectory(root.path);
52
- try {
53
- const stat = await dir.stat();
54
- if (stat.dev !== root.dev || stat.ino !== root.ino)
55
- throw Error('EXPORT_ROOT_CHANGED');
56
- for (const part of parts.slice(0, -1)) {
57
- const next = await open(`/proc/self/fd/${dir.fd}/${part}`, FLAGS | constants.O_DIRECTORY);
58
- await dir.close();
59
- dir = next;
60
- }
61
- const file = await open(`/proc/self/fd/${dir.fd}/${parts.at(-1)}`, FLAGS);
62
- try {
63
- const stat = await file.stat();
64
- if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.nlink !== 1 || stat.size > MAX_FILE_BYTES)
65
- throw Error('INVALID_EXPORT_FILE');
66
- return file;
67
- }
68
- catch (error) {
69
- await file.close();
43
+ const code = error.code;
44
+ if (error instanceof Error && error.message === 'INVALID_CHAT_FILE_PATH')
70
45
  throw error;
71
- }
72
- }
73
- finally {
74
- await dir.close();
46
+ throw Error(code === 'ELOOP' ? 'CHAT_FILE_IS_SYMLINK' : code === 'ENOENT' || code === 'ENOTDIR' ? 'CHAT_FILE_NOT_FOUND'
47
+ : code === 'EACCES' || code === 'EPERM' ? 'CHAT_FILE_NOT_READABLE' : 'CHAT_FILE_OPEN_FAILED');
75
48
  }
76
- }
77
- export async function ensureExportDirectory(cwd, directory) {
78
- let dir = await openDirectory(cwd);
79
49
  try {
80
- for (const part of directory.split('/')) {
81
- if (!part || part.startsWith('.') || /[\\\x00-\x1f]/.test(part))
82
- throw Error('INVALID_EXPORT_DIRECTORY');
83
- const path = `/proc/self/fd/${dir.fd}/${part}`;
84
- await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
85
- throw error; });
86
- const next = await open(path, FLAGS | constants.O_DIRECTORY);
87
- await dir.close();
88
- dir = next;
89
- }
50
+ const stat = await file.stat();
51
+ if (!stat.isFile())
52
+ throw Error('CHAT_FILE_NOT_REGULAR');
53
+ if (stat.nlink !== 1)
54
+ throw Error('CHAT_FILE_HARDLINKED');
55
+ if (stat.size > MAX_FILE_BYTES)
56
+ throw Error('FILE_EXCEEDS_20_MIB');
57
+ return { file, path: await readlink(`/proc/self/fd/${file.fd}`) };
90
58
  }
91
- finally {
92
- await dir.close();
59
+ catch (error) {
60
+ await file.close();
61
+ throw error;
93
62
  }
94
63
  }
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { CodexFileInventory } from './codex-file-inventory.js';
3
2
  import { spawn } from 'node:child_process';
4
3
  import { createRequire } from 'node:module';
5
4
  import { createInterface } from 'node:readline';
@@ -447,7 +446,6 @@ export function runCodexAppServerProxy() {
447
446
  });
448
447
  }
449
448
  };
450
- const inventory = process.env.FLEET_CURRENT_CHAT_FILES === '1' ? new CodexFileInventory(writeToChild, writeToClient) : undefined;
451
449
  const recovery = new CodexTerminalRecovery({
452
450
  sendToCodex: writeToChild,
453
451
  emitToClient: writeToClient,
@@ -458,7 +456,6 @@ export function runCodexAppServerProxy() {
458
456
  return;
459
457
  finished = true;
460
458
  recovery.close();
461
- inventory?.close();
462
459
  input.close();
463
460
  process.stdin.destroy();
464
461
  // stdout/stderr are often pipes under Fleet and tests. Setting exitCode
@@ -491,11 +488,10 @@ export function runCodexAppServerProxy() {
491
488
  return;
492
489
  const rewritten = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp, managedOursTools);
493
490
  recovery.observeClientLine(rewritten);
494
- if (!inventory || inventory.observeClientLine(rewritten))
495
- writeToChild(rewritten);
491
+ writeToChild(rewritten);
496
492
  });
497
493
  output.on('line', line => {
498
- if (!finished && (!inventory || inventory.observeServerLine(line)) && recovery.observeServerLine(line))
494
+ if (!finished && recovery.observeServerLine(line))
499
495
  writeToClient(line);
500
496
  });
501
497
  input.once('close', () => {
@@ -262,7 +262,6 @@ function codexAcpEnvironment(role, dirs) {
262
262
  if (!source)
263
263
  throw new Error('Codex app-server proxy is missing; rebuild ours-fleet');
264
264
  writeFileSync(proxyModule, readFileSync(source, 'utf8'), { mode: 0o600 });
265
- writeFileSync(join(runtimeDir, 'codex-file-inventory.js'), readFileSync(join(dirname(source), 'codex-file-inventory.js'), 'utf8'), { mode: 0o600 });
266
265
  const windows = process.platform === 'win32';
267
266
  const command = join(runtimeDir, windows ? 'codex-app-server-proxy.cmd' : 'codex-app-server-proxy');
268
267
  const script = windows
@@ -483,7 +482,7 @@ export function makeCodexAdapter(exec = realExec, transport, nativeTransport) {
483
482
  catch (error) {
484
483
  throw new Error(`Codex ACP runtime check failed: ${error.message}; check CODEX_PATH or reinstall Fleet with optional dependencies`);
485
484
  }
486
- const minimum = role.file_delivery?.enabled ? '0.159.0' : role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
485
+ const minimum = role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
487
486
  ? '0.153.3' : '0.145.0';
488
487
  if (!codexVersionAtLeast(runtime.version, minimum))
489
488
  throw new Error(`${role.model ?? 'Codex ACP'} requires a newer Codex (>=${minimum}): ACP selects ${runtime.executable} (${runtime.version}); upgrade Fleet or set CODEX_PATH to a supported executable in the agent/service environment`);
@@ -1985,6 +1985,9 @@ export class OwnerChannel {
1985
1985
  const progressMs = this.options.config.progress_interval_ms;
1986
1986
  let lastSeq = activityCursor;
1987
1987
  let startedAt;
1988
+ // When one turn delivers several waiting prompts, its events carry the turn's
1989
+ // id, not this request's: follow the turn that actually carries this request.
1990
+ const inTurn = (turnId) => turnId === (this.options.session.sharedTurnId?.(queued.promptId) ?? queued.promptId);
1988
1991
  let phase = 'starting request';
1989
1992
  let timer;
1990
1993
  const flushCommentary = () => {
@@ -2041,7 +2044,7 @@ export class OwnerChannel {
2041
2044
  };
2042
2045
  const acceptCommentary = (event) => {
2043
2046
  if (!this.commentsEnabled
2044
- || active.commentaryDisabled || active.finalizing || event.turnId !== queued.promptId
2047
+ || active.commentaryDisabled || active.finalizing || !inTurn(event.turnId)
2045
2048
  || event.kind !== 'agent_text' || event.messagePhase !== 'commentary'
2046
2049
  || event.replayed || event.origin?.kind !== 'owner'
2047
2050
  || event.origin.requestId !== active.requestId
@@ -2082,7 +2085,7 @@ export class OwnerChannel {
2082
2085
  const reportProgress = () => {
2083
2086
  const events = this.options.session.eventsSince(lastSeq);
2084
2087
  lastSeq = Math.max(lastSeq, this.latestEventSeq(events));
2085
- const activity = events.filter(event => event.turnId === queued.promptId);
2088
+ const activity = events.filter(event => inTurn(event.turnId));
2086
2089
  if (!activity.length)
2087
2090
  return;
2088
2091
  startedAt ??= Date.parse(activity[0].at) || Date.now();
@@ -2107,10 +2110,10 @@ export class OwnerChannel {
2107
2110
  const startProgress = (event) => {
2108
2111
  if (timer || progressMs <= 0)
2109
2112
  return;
2110
- if (event && event.turnId !== queued.promptId)
2113
+ if (event && !inTurn(event.turnId))
2111
2114
  return;
2112
2115
  const observed = event ? [event] : this.options.session.eventsSince(activityCursor)
2113
- .filter(item => item.turnId === queued.promptId);
2116
+ .filter(item => inTurn(item.turnId));
2114
2117
  if (!observed.length)
2115
2118
  return;
2116
2119
  startedAt = Date.parse(observed[0].at) || Date.now();
@@ -5,6 +5,7 @@ import type { CommonPermissions } from '../config.js';
5
5
  import type { AcpMcpServer } from '../harness/types.js';
6
6
  import { ConversationEventStore } from './conversation-store.js';
7
7
  import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
8
+ import { type PendingPrompt } from './prompt-batch.js';
8
9
  import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, AgentSession, RuntimeSelectorMetadata, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
9
10
  /** Bound safe-boundary waiting without turning a hung tool into cancellation. */
10
11
  export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
@@ -27,6 +28,17 @@ export declare const STEERING_OCCUPANCY_IDLE_MS = 150000;
27
28
  export declare const CODEX_DISABLE_INHERITED_MCP_ENV = "OURS_FLEET_CODEX_DISABLE_INHERITED_MCP";
28
29
  /** Server-generated typed provenance followed by the exact human-authored body. */
29
30
  export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
31
+ /**
32
+ * One adapter turn that carries several admitted prompts.
33
+ *
34
+ * A single prompt's provenance block describes "the accompanying text", which
35
+ * is only unambiguous when there is one text. Here every member is one text
36
+ * block, unchanged, immediately preceded by its own server-generated block
37
+ * that names its route and binds its exact bytes — so a monitor wake next to
38
+ * an owner prompt is never read under the owner's authority, and text that
39
+ * imitates a marker stays text: it cannot produce a typed block.
40
+ */
41
+ export declare function batchContentBlocks(members: ReadonlyArray<Pick<PendingPrompt, 'text' | 'origin'>>): acp.ContentBlock[];
30
42
  export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
31
43
  /** Fresh ACP response including the legacy model report still used by some adapters. */
32
44
  export type AcpStartupSessionResponse = acp.NewSessionResponse & {
@@ -139,6 +151,12 @@ export declare class AcpSession implements AgentSession {
139
151
  private lastUpdateAt?;
140
152
  private lastError?;
141
153
  private promptTail;
154
+ /** Admitted prompts not yet handed to the adapter, in admission order. */
155
+ private readonly pendingPrompts;
156
+ /** Admitted prompt → the adapter turn that carries it, while that turn is open. */
157
+ private readonly sharedTurns;
158
+ /** Commands the adapter currently advertises; only these are control operations. */
159
+ private advertisedCommands;
142
160
  private queueDepth;
143
161
  private exit;
144
162
  private steeringSupported;
@@ -236,6 +254,22 @@ export declare class AcpSession implements AgentSession {
236
254
  * for it is what turned a busy agent into a timeout and then into "dead".
237
255
  */
238
256
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
257
+ /** The turn whose events and result answer this admitted prompt; itself unless a release shared it. */
258
+ sharedTurnId(promptId: string): string | undefined;
259
+ /**
260
+ * Queue an admitted prompt behind the current work. Each prompt keeps its
261
+ * own step in the chain, so its place in line is unchanged; what the step
262
+ * does when it is reached is release a whole snapshot, not one prompt.
263
+ */
264
+ private enqueue;
265
+ /**
266
+ * The turn boundary. The first waiting prompt to get here takes every
267
+ * pending prompt delivered with it, synchronously, and they share one adapter
268
+ * turn. A prompt admitted after that — while the turn runs, or while a
269
+ * cancellation settles — stays pending for the next boundary. A step whose
270
+ * prompt an earlier boundary already took just reports that turn's result.
271
+ */
272
+ private release;
239
273
  /**
240
274
  * Prepare the session for a prompt that asked to pre-empt current work.
241
275
  *
@@ -319,6 +353,11 @@ export declare class AcpSession implements AgentSession {
319
353
  private recoverStall;
320
354
  /** Keep the original queue slot (including startup) until recovery finishes. */
321
355
  private runPrompt;
356
+ /**
357
+ * One adapter turn. `members` are the admitted prompts it carries, the first
358
+ * being the turn's own id; each keeps its own started and completed records
359
+ * so none is left open and none is reported delivered that was not sent.
360
+ */
322
361
  private runSinglePrompt;
323
362
  private steerPrompt;
324
363
  private requestPermission;
@@ -8,6 +8,7 @@ import * as acp from '@agentclientprotocol/sdk';
8
8
  import { normalizeSessionUpdate } from './conversation-normalizer.js';
9
9
  import { ConversationEventStore } from './conversation-store.js';
10
10
  import { SessionEvents } from './events.js';
11
+ import { commandName, memberAuthority, memberBindings, pendingInventory, takeBatch, } from './prompt-batch.js';
11
12
  import { recordClaudeRateLimit } from '../subscriptions/observed.js';
12
13
  import { DEFAULT_STALL_TIMEOUT_MS, STALL_RECOVERY_PROMPT, StallWatchdog, StallToolHistory, hasStallRecoveryClaim } from './stall-watchdog.js';
13
14
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, classifyChildExit, sessionBackendCapabilities, turnResult, } from './types.js';
@@ -182,6 +183,38 @@ export function promptContentBlocks(text, origin) {
182
183
  mimeType: 'application/vnd.ours-fleet.prompt-provenance+json',
183
184
  }, { type: 'text', text }];
184
185
  }
186
+ /**
187
+ * One adapter turn that carries several admitted prompts.
188
+ *
189
+ * A single prompt's provenance block describes "the accompanying text", which
190
+ * is only unambiguous when there is one text. Here every member is one text
191
+ * block, unchanged, immediately preceded by its own server-generated block
192
+ * that names its route and binds its exact bytes — so a monitor wake next to
193
+ * an owner prompt is never read under the owner's authority, and text that
194
+ * imitates a marker stays text: it cannot produce a typed block.
195
+ */
196
+ export function batchContentBlocks(members) {
197
+ if (members.length === 1)
198
+ return promptContentBlocks(members[0].text, members[0].origin);
199
+ const mimeType = 'application/vnd.ours-fleet.prompt-provenance+json';
200
+ return [{
201
+ type: 'resource_link',
202
+ uri: `ours-fleet://prompt-batch?members=${members.length}`,
203
+ name: 'Fleet queued prompt batch',
204
+ description: `Server-generated. This turn delivers ${members.length} separately admitted prompts in `
205
+ + 'submission order. Each prompt is one text block immediately preceded by its own '
206
+ + 'server-generated provenance block, which describes only that one text block.',
207
+ mimeType,
208
+ }, ...memberBindings(members, origin => conversationSource(origin).source).flatMap((binding, at) => [{
209
+ type: 'resource_link',
210
+ uri: `ours-fleet://prompt-provenance?source=${binding.source}&member=${binding.index}`
211
+ + `&of=${binding.of}&bytes=${binding.bytes}&sha256=${binding.sha256}`,
212
+ name: binding.source === 'owner_admin_console' ? 'Direct owner admin console' : 'Fleet queued prompt',
213
+ description: `Server-generated provenance for prompt ${binding.index} of ${binding.of}: `
214
+ + `${memberAuthority(binding.source)}. Applies only to the text block that immediately follows.`,
215
+ mimeType,
216
+ }, { type: 'text', text: members[at].text }])];
217
+ }
185
218
  export function runtimeSelector(options, category) {
186
219
  const option = options?.find(candidate => candidate.category === category);
187
220
  if (!option || typeof option.currentValue !== 'string')
@@ -239,6 +272,12 @@ export class AcpSession {
239
272
  lastUpdateAt;
240
273
  lastError;
241
274
  promptTail = Promise.resolve();
275
+ /** Admitted prompts not yet handed to the adapter, in admission order. */
276
+ pendingPrompts = [];
277
+ /** Admitted prompt → the adapter turn that carries it, while that turn is open. */
278
+ sharedTurns = new Map();
279
+ /** Commands the adapter currently advertises; only these are control operations. */
280
+ advertisedCommands = new Set();
242
281
  queueDepth = 0;
243
282
  exit = null;
244
283
  steeringSupported = false;
@@ -411,8 +450,7 @@ export class AcpSession {
411
450
  const text = open.text;
412
451
  const promptId = open.promptId;
413
452
  this.queueDepth++;
414
- const run = this.promptTail.then(() => this.runPrompt(text, promptId));
415
- this.promptTail = run.then(() => undefined, () => undefined);
453
+ const run = this.enqueue(promptId, text, undefined);
416
454
  void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); });
417
455
  }
418
456
  }
@@ -467,7 +505,9 @@ export class AcpSession {
467
505
  readiness: this.readiness === 'idle' && this.steeringOccupied
468
506
  ? 'running' : this.readiness,
469
507
  sessionId: this.sessionId,
470
- ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.origin.kind } : {}),
508
+ // A shared turn is a human console turn if any member is: inbox traffic must not cut it short.
509
+ ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.console
510
+ ? 'owner-admin-console' : this.activeTurn.origin.kind } : {}),
471
511
  lastError: this.lastError,
472
512
  pendingPermissionId: this.pendingPermissions.keys().next().value,
473
513
  runtimeModel: this.runtimeModel,
@@ -584,7 +624,8 @@ export class AcpSession {
584
624
  async steerOrQueueWake(text, options) {
585
625
  // A tool-boundary wait may outlive its original turn. Recheck the current
586
626
  // typed source at delivery, since an Owner turn can begin during the wait.
587
- if (options.origin?.kind === 'fleet-monitor' && this.activeTurn?.origin?.kind === 'owner-admin-console')
627
+ // `console` covers a shared turn whose first member is not the console prompt.
628
+ if (options.origin?.kind === 'fleet-monitor' && this.activeTurn?.console)
588
629
  return this.submitPrompt(text, { ...options, interrupt: false, steer: false });
589
630
  if (this.stallRecoveryClaimed)
590
631
  return this.submitPrompt(text, { ...options, interrupt: false, steer: false });
@@ -675,8 +716,7 @@ export class AcpSession {
675
716
  const queuedBehind = this.queueDepth;
676
717
  this.admitToLedger(promptId, text, queuedBehind, options);
677
718
  this.queueDepth++;
678
- const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
679
- this.promptTail = run.then(() => undefined, () => undefined);
719
+ const run = this.enqueue(promptId, text, options.origin);
680
720
  const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
681
721
  this.queueDepth = Math.max(0, this.queueDepth - 1);
682
722
  return turnResult(false, 'failed', error?.message ?? String(error));
@@ -686,6 +726,46 @@ export class AcpSession {
686
726
  delivery: delivery ?? (queuedBehind > 0 ? 'queued' : 'started'),
687
727
  };
688
728
  }
729
+ /** The turn whose events and result answer this admitted prompt; itself unless a release shared it. */
730
+ sharedTurnId(promptId) {
731
+ return this.sharedTurns.get(promptId);
732
+ }
733
+ /**
734
+ * Queue an admitted prompt behind the current work. Each prompt keeps its
735
+ * own step in the chain, so its place in line is unchanged; what the step
736
+ * does when it is reached is release a whole snapshot, not one prompt.
737
+ */
738
+ enqueue(promptId, text, origin) {
739
+ const entry = {
740
+ promptId, text, origin, ...conversationSource(origin), admittedAt: new Date().toISOString(),
741
+ command: this.advertisedCommands.has(commandName(text) ?? ''),
742
+ };
743
+ this.pendingPrompts.push(entry);
744
+ const run = this.promptTail.then(() => this.release(entry));
745
+ this.promptTail = run.then(() => undefined, () => undefined);
746
+ return run;
747
+ }
748
+ /**
749
+ * The turn boundary. The first waiting prompt to get here takes every
750
+ * pending prompt delivered with it, synchronously, and they share one adapter
751
+ * turn. A prompt admitted after that — while the turn runs, or while a
752
+ * cancellation settles — stays pending for the next boundary. A step whose
753
+ * prompt an earlier boundary already took just reports that turn's result.
754
+ */
755
+ release(entry) {
756
+ if (entry.released)
757
+ return entry.released;
758
+ const members = takeBatch(this.pendingPrompts, entry);
759
+ for (const member of members)
760
+ this.sharedTurns.set(member.promptId, members[0].promptId);
761
+ const run = this.runPrompt(members).finally(() => {
762
+ for (const member of members)
763
+ this.sharedTurns.delete(member.promptId);
764
+ });
765
+ for (const member of members)
766
+ member.released = run;
767
+ return run;
768
+ }
689
769
  /**
690
770
  * Prepare the session for a prompt that asked to pre-empt current work.
691
771
  *
@@ -807,7 +887,7 @@ export class AcpSession {
807
887
  async interruptPrompt(generation, promptId) {
808
888
  if (generation !== this.sessionGeneration)
809
889
  throw new Error('session_changed: Agent session changed.');
810
- if (this.activeTurn?.id !== promptId)
890
+ if (!this.activeTurn?.promptIds.includes(promptId))
811
891
  return { state: 'settled' };
812
892
  return this.interrupt('local-console');
813
893
  }
@@ -1354,8 +1434,9 @@ export class AcpSession {
1354
1434
  }
1355
1435
  }
1356
1436
  /** Keep the original queue slot (including startup) until recovery finishes. */
1357
- async runPrompt(text, turnId = randomUUID(), origin) {
1358
- const result = await this.runSinglePrompt(text, turnId, origin);
1437
+ async runPrompt(members) {
1438
+ const { text, promptId: turnId, origin } = members[0];
1439
+ const result = await this.runSinglePrompt(text, turnId, origin, members);
1359
1440
  const attempt = this.stallAttempt;
1360
1441
  if (!attempt || attempt.turnId !== turnId)
1361
1442
  return result;
@@ -1390,7 +1471,21 @@ export class AcpSession {
1390
1471
  return turnResult(true, 'cancelled', 'diagnostic recovery requires operator attention', undefined, 'stall-watchdog');
1391
1472
  }
1392
1473
  }
1393
- async runSinglePrompt(text, turnId = randomUUID(), origin) {
1474
+ /**
1475
+ * One adapter turn. `members` are the admitted prompts it carries, the first
1476
+ * being the turn's own id; each keeps its own started and completed records
1477
+ * so none is left open and none is reported delivered that was not sent.
1478
+ */
1479
+ async runSinglePrompt(text, turnId = randomUUID(), origin, members = [{ promptId: turnId, text, origin }]) {
1480
+ const closeMembers = (payload) => {
1481
+ for (const member of members)
1482
+ this.conversation.appendSafe({
1483
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
1484
+ acpSessionId: this.sessionId, promptId: member.promptId, turnId, payload,
1485
+ });
1486
+ };
1487
+ // Not handed to the adapter and not closed: the members stay admitted, and
1488
+ // the next session generation dispatches them again.
1394
1489
  if (!this.sessionId || !this.isAlive())
1395
1490
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
1396
1491
  this.readiness = 'running';
@@ -1398,18 +1493,19 @@ export class AcpSession {
1398
1493
  this.retryNativeTurnId = undefined;
1399
1494
  let settle;
1400
1495
  const settled = new Promise(resolve => { settle = resolve; });
1401
- this.activeTurn = { toolEvidence: new Map(), startedAt: Date.now(), toolIds: new Set(), lastProgressAt: 0, progressCount: 0, transportFailures: 0, boundaryUnknown: false, id: turnId, output: '', origin, settled, settle };
1496
+ this.activeTurn = { toolEvidence: new Map(), startedAt: Date.now(), toolIds: new Set(), lastProgressAt: 0, progressCount: 0, transportFailures: 0, boundaryUnknown: false, id: turnId, output: '', origin, settled, settle, promptIds: members.map(member => member.promptId), console: members.some(member => member.origin?.kind === 'owner-admin-console') };
1402
1497
  this.events.emit('state', { turnId, status: 'running', origin });
1403
- this.conversation.appendSafe({
1498
+ members.forEach((member, index) => this.conversation.appendSafe({
1404
1499
  kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
1405
- acpSessionId: this.sessionId, promptId: turnId, turnId,
1406
- source: conversationSource(origin).source, payload: {},
1407
- });
1500
+ acpSessionId: this.sessionId, promptId: member.promptId, turnId,
1501
+ source: conversationSource(member.origin).source,
1502
+ payload: members.length > 1 ? { batch: { turnId, index, size: members.length } } : {},
1503
+ }));
1408
1504
  try {
1409
1505
  const response = await Promise.race([
1410
1506
  this.connection.agent.request(acp.methods.agent.session.prompt, {
1411
1507
  sessionId: this.sessionId,
1412
- prompt: promptContentBlocks(text, origin),
1508
+ prompt: batchContentBlocks(members),
1413
1509
  }),
1414
1510
  this.terminated,
1415
1511
  ]);
@@ -1420,14 +1516,10 @@ export class AcpSession {
1420
1516
  turnId, stopReason: response.stopReason, origin, cancellationSource,
1421
1517
  });
1422
1518
  this.events.emit('state', { status: 'idle' });
1423
- this.conversation.appendSafe({
1424
- kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
1425
- acpSessionId: this.sessionId, promptId: turnId, turnId,
1426
- payload: {
1427
- outcome: classifyStopReason(response.stopReason),
1428
- stopReason: response.stopReason,
1429
- ...(cancellationSource ? { cancellationSource } : {}),
1430
- },
1519
+ closeMembers({
1520
+ outcome: classifyStopReason(response.stopReason),
1521
+ stopReason: response.stopReason,
1522
+ ...(cancellationSource ? { cancellationSource } : {}),
1431
1523
  });
1432
1524
  // The prompt was accepted either way — the agent answered. Whether the
1433
1525
  // turn SUCCEEDED is a separate question, and only `stopReason` answers it.
@@ -1448,11 +1540,7 @@ export class AcpSession {
1448
1540
  });
1449
1541
  if (this.isAlive())
1450
1542
  this.events.emit('state', { status: 'idle' });
1451
- this.conversation.appendSafe({
1452
- kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
1453
- acpSessionId: this.sessionId, promptId: turnId, turnId,
1454
- payload: { outcome: 'failed', stopReason: this.lastError },
1455
- });
1543
+ closeMembers({ outcome: 'failed', stopReason: this.lastError });
1456
1544
  return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
1457
1545
  }
1458
1546
  finally {
@@ -1783,6 +1871,8 @@ export class AcpSession {
1783
1871
  this.recordConversationUpdate(update, scheduled);
1784
1872
  if (update.sessionUpdate === 'config_option_update')
1785
1873
  this.captureRuntimeMetadata(update.configOptions);
1874
+ if (update.sessionUpdate === 'available_commands_update')
1875
+ this.advertisedCommands = new Set((update.availableCommands ?? []).map(command => command.name));
1786
1876
  if (update.sessionUpdate === 'usage_update') {
1787
1877
  // Claude subscription rate-limit snapshot (claude-agent-acp forwards the SDK rate_limit_event).
1788
1878
  const rateLimit = update._meta?.['_claude/rateLimit'];
@@ -1917,6 +2007,7 @@ export class AcpSession {
1917
2007
  readiness: this.isAlive() ? this.readiness : 'offline',
1918
2008
  queueDepth: this.queueDepth,
1919
2009
  pendingPermissionIds: [...this.pendingPermissions.keys()],
2010
+ ...pendingInventory(this.pendingPrompts),
1920
2011
  ...(this.conversation.degraded ? { historyDegraded: true } : {}),
1921
2012
  };
1922
2013
  }
@@ -70,6 +70,7 @@ export declare class RoleTurnArbiter implements AgentSession {
70
70
  }>, beforeQueue?: () => void | Promise<void>): Promise<ScheduledAttempt>;
71
71
  /** Retire only at the same admission boundary used by all prompt producers. */
72
72
  retireIfIdle(expired: (snapshot: SessionSnapshot, queueDepth: number) => boolean): Promise<boolean>;
73
+ sharedTurnId(promptId: string): string | undefined;
73
74
  stopScheduledAdmission(): void;
74
75
  isAlive(): boolean;
75
76
  snapshot(): SessionSnapshot;
@@ -167,6 +167,7 @@ export class RoleTurnArbiter {
167
167
  return true;
168
168
  });
169
169
  }
170
+ sharedTurnId(promptId) { return this.session.sharedTurnId?.(promptId); }
170
171
  stopScheduledAdmission() { this.stopping = true; }
171
172
  isAlive() { return this.session.isAlive(); }
172
173
  snapshot() { return this.session.snapshot(); }
@@ -1,6 +1,7 @@
1
1
  import { type TaskNoticeBinding, type TaskCreatedNotice } from './task-notice.js';
2
2
  import type { CommonPermissions } from '../config.js';
3
3
  import type { ConversationEventV1, ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
4
+ import { type PendingPrompt } from './prompt-batch.js';
4
5
  import type { CodexAppServerTransportFactory } from './codex-app-server-transport.js';
5
6
  import type { AgentSession, ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
6
7
  type JsonObject = Record<string, unknown>;
@@ -30,6 +31,16 @@ export interface CodexAppServerSessionOptions {
30
31
  }
31
32
  /** Server-generated application provenance for authenticated browser prompts only. */
32
33
  export declare function nativePromptAdditionalContext(origin: PromptOrigin | undefined): JsonObject | undefined;
34
+ /**
35
+ * Application context for a turn that carries several admitted prompts.
36
+ *
37
+ * The single-prompt context says "the accompanying user text", which would
38
+ * cover every member. A shared turn therefore carries this one entry instead:
39
+ * each member is its own user text item, in order, and its route and exact
40
+ * bytes are stated here by the server. Text imitating this wording is still
41
+ * only user text; it cannot add an application-context entry.
42
+ */
43
+ export declare function nativeBatchAdditionalContext(members: ReadonlyArray<Pick<PendingPrompt, 'text' | 'origin'>>): JsonObject | undefined;
33
44
  /** Native Codex session implementing Fleet's provider-neutral live-session contract. */
34
45
  export declare class CodexAppServerSession implements AgentSession {
35
46
  private readonly options;
@@ -56,6 +67,10 @@ export declare class CodexAppServerSession implements AgentSession {
56
67
  private exit;
57
68
  private queueDepth;
58
69
  private promptTail;
70
+ /** Admitted prompts not yet handed to the app-server, in admission order. */
71
+ private readonly pendingPrompts;
72
+ /** Admitted prompt → the turn that carries it, while that turn is open. */
73
+ private readonly sharedTurns;
59
74
  private activeTurn?;
60
75
  private controllerCount;
61
76
  private controllerGrace?;
@@ -69,6 +84,17 @@ export declare class CodexAppServerSession implements AgentSession {
69
84
  isAlive(): boolean;
70
85
  snapshot(): SessionSnapshot;
71
86
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
87
+ /** The turn whose events and result answer this admitted prompt; itself unless a release shared it. */
88
+ sharedTurnId(promptId: string): string | undefined;
89
+ /** Each prompt keeps its own step in the chain; the step it reaches releases a whole snapshot. */
90
+ private enqueue;
91
+ /**
92
+ * The turn boundary: the first waiting prompt to get here synchronously
93
+ * takes every pending prompt delivered with it into one turn. A prompt
94
+ * admitted afterwards stays pending for the next boundary, and a step whose
95
+ * prompt was already taken reports that turn's result.
96
+ */
97
+ private release;
72
98
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
73
99
  readonly liveVoiceSupported = true;
74
100
  interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;