@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.
- package/README.md +1 -1
- package/dist/agent-ours/bridge.js +21 -11
- package/dist/agent-ours/file-rpc.d.ts +6 -2
- package/dist/agent-ours/file-rpc.js +11 -7
- package/dist/agent-ours/harness.js +2 -46
- package/dist/agent-ours/mcp-endpoint.d.ts +0 -1
- package/dist/agent-ours/mcp-endpoint.js +3 -2
- package/dist/agent-ours/service.d.ts +15 -0
- package/dist/agent-ours/service.js +41 -35
- package/dist/briefing.js +10 -0
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +10 -4
- package/dist/config.js +8 -3
- package/dist/file-delivery/reader.d.ts +10 -12
- package/dist/file-delivery/reader.js +33 -64
- package/dist/harness/codex-app-server-proxy.js +2 -6
- package/dist/harness/codex.js +1 -2
- package/dist/owner-channel/channel.js +7 -4
- package/dist/session/acp.d.ts +39 -0
- package/dist/session/acp.js +120 -29
- package/dist/session/arbiter.d.ts +1 -0
- package/dist/session/arbiter.js +1 -0
- package/dist/session/codex-app-server.d.ts +26 -0
- package/dist/session/codex-app-server.js +98 -22
- package/dist/session/conversation-types.d.ts +19 -0
- package/dist/session/prompt-batch.d.ts +63 -0
- package/dist/session/prompt-batch.js +86 -0
- package/dist/session/types.d.ts +5 -0
- package/docs/acp-files.md +99 -55
- package/package.json +1 -1
- package/dist/harness/codex-file-inventory.d.ts +0 -15
- package/dist/harness/codex-file-inventory.js +0 -113
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import { isAbsolute,
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
await
|
|
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
|
-
|
|
495
|
-
writeToChild(rewritten);
|
|
491
|
+
writeToChild(rewritten);
|
|
496
492
|
});
|
|
497
493
|
output.on('line', line => {
|
|
498
|
-
if (!finished &&
|
|
494
|
+
if (!finished && recovery.observeServerLine(line))
|
|
499
495
|
writeToClient(line);
|
|
500
496
|
});
|
|
501
497
|
input.once('close', () => {
|
package/dist/harness/codex.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2116
|
+
.filter(item => inTurn(item.turnId));
|
|
2114
2117
|
if (!observed.length)
|
|
2115
2118
|
return;
|
|
2116
2119
|
startedAt = Date.parse(observed[0].at) || Date.now();
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -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;
|
package/dist/session/acp.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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?.
|
|
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(
|
|
1358
|
-
const
|
|
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
|
-
|
|
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:
|
|
1406
|
-
source: conversationSource(origin).source,
|
|
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:
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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.
|
|
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;
|
package/dist/session/arbiter.js
CHANGED
|
@@ -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>;
|