@borgee/agents-host 0.2.1 → 0.2.26
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 +184 -21
- package/dist/agents-host-supervisor.d.ts +7 -5
- package/dist/agents-host-supervisor.js +24 -4
- package/dist/agents-host.d.ts +89 -15
- package/dist/agents-host.js +2099 -142
- package/dist/chat/chat-control-plane.d.ts +14 -3
- package/dist/chat/sdk-chat-control-plane.d.ts +16 -4
- package/dist/chat/sdk-chat-control-plane.js +69 -9
- package/dist/cli-args.d.ts +46 -5
- package/dist/cli-args.js +313 -32
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +112 -5
- package/dist/compatibility-gates.d.ts +35 -0
- package/dist/compatibility-gates.js +127 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +23 -5
- package/dist/connections-state-store.d.ts +81 -0
- package/dist/connections-state-store.js +228 -0
- package/dist/context/injection.d.ts +109 -0
- package/dist/context/injection.js +350 -0
- package/dist/context/prompt.d.ts +4 -1
- package/dist/context/prompt.js +170 -1
- package/dist/context/turn-preparation.d.ts +9 -0
- package/dist/context/turn-preparation.js +106 -0
- package/dist/debug.d.ts +44 -0
- package/dist/debug.js +135 -0
- package/dist/gateway/localhost-gateway.d.ts +52 -0
- package/dist/gateway/localhost-gateway.js +857 -0
- package/dist/index.js +7 -5
- package/dist/local-config.d.ts +4 -1
- package/dist/local-config.js +24 -7
- package/dist/managed-daemon-log.d.ts +34 -0
- package/dist/managed-daemon-log.js +261 -0
- package/dist/managed-daemon.d.ts +220 -0
- package/dist/managed-daemon.js +1601 -0
- package/dist/policy/authorization-audit.d.ts +63 -0
- package/dist/policy/authorization-audit.js +94 -0
- package/dist/policy/copilot-permission.d.ts +15 -0
- package/dist/policy/copilot-permission.js +193 -0
- package/dist/policy/gateway-authorization.d.ts +42 -0
- package/dist/policy/gateway-authorization.js +162 -0
- package/dist/providers/awaiting-user.d.ts +12 -0
- package/dist/providers/awaiting-user.js +151 -0
- package/dist/providers/claude/adapter.d.ts +3 -1
- package/dist/providers/claude/adapter.js +8 -12
- package/dist/providers/claude/cli-client.d.ts +12 -5
- package/dist/providers/claude/cli-client.js +184 -37
- package/dist/providers/claude/session-store.d.ts +24 -0
- package/dist/providers/claude/session-store.js +65 -12
- package/dist/providers/codex/adapter.d.ts +11 -0
- package/dist/providers/codex/adapter.js +19 -0
- package/dist/providers/codex/cli-client.d.ts +103 -0
- package/dist/providers/codex/cli-client.js +1133 -0
- package/dist/providers/codex/project-doc.d.ts +3 -0
- package/dist/providers/codex/project-doc.js +66 -0
- package/dist/providers/codex/session-store.d.ts +38 -0
- package/dist/providers/codex/session-store.js +150 -0
- package/dist/providers/copilot/adapter.d.ts +3 -1
- package/dist/providers/copilot/adapter.js +8 -12
- package/dist/providers/copilot/cli-client.d.ts +20 -2
- package/dist/providers/copilot/cli-client.js +251 -71
- package/dist/providers/copilot/session-store.d.ts +24 -0
- package/dist/providers/copilot/session-store.js +65 -12
- package/dist/providers/create-provider.d.ts +11 -2
- package/dist/providers/create-provider.js +131 -12
- package/dist/run.d.ts +1 -0
- package/dist/run.js +5 -2
- package/dist/state-paths.d.ts +13 -1
- package/dist/state-paths.js +84 -3
- package/dist/task-thread-resolution.d.ts +10 -0
- package/dist/task-thread-resolution.js +48 -0
- package/dist/types.d.ts +174 -1
- package/dist/visible-mentions.d.ts +3 -0
- package/dist/visible-mentions.js +15 -0
- package/package.json +19 -17
- package/skills/borgee-agent/SKILL.md +33 -0
- package/skills/borgee-agent/borgee-agent.mjs +473 -0
- package/skills/borgee-agent/borgee-agent.py +409 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { Readable, Writable } from 'node:stream';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import spawn from 'cross-spawn';
|
|
6
|
+
import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
|
|
7
|
+
import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
|
|
8
|
+
import { assertCodexProjectDocumentSize, buildCodexProjectDocument } from './project-doc.js';
|
|
9
|
+
const SESSION_TAINTED_ERRORS = new WeakSet();
|
|
10
|
+
const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
|
|
12
|
+
const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
|
|
13
|
+
const DEFAULT_SESSION_CAPABILITIES = {
|
|
14
|
+
loadSession: false,
|
|
15
|
+
resumeSession: false,
|
|
16
|
+
};
|
|
17
|
+
const DEFAULT_CODEX_COMMAND = 'codex-acp';
|
|
18
|
+
const DEFAULT_CODEX_ARGS = [];
|
|
19
|
+
const CODEX_CONTEXT_ROOT_DIRNAME = 'codex-context';
|
|
20
|
+
const PROJECTED_GATEWAY_AUTH_FILENAME = '.localhost-gateway-auth.json';
|
|
21
|
+
const PROJECTED_GATEWAY_AUTH_PREFIX = '.localhost-gateway-auth.';
|
|
22
|
+
const PROJECTION_UNAVAILABLE_PLACEHOLDER = '[codex-projection-unavailable]';
|
|
23
|
+
const QUEUED_TURN_DROPPED_MESSAGE = 'Codex ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const DEFAULT_RUNTIME = {
|
|
26
|
+
spawn,
|
|
27
|
+
client,
|
|
28
|
+
ndJsonStream,
|
|
29
|
+
methods,
|
|
30
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
31
|
+
cwd: process.cwd(),
|
|
32
|
+
idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
|
|
33
|
+
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
34
|
+
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
35
|
+
readFile: async (path, options) => {
|
|
36
|
+
return fs.readFile(path, options?.encoding ?? 'utf8');
|
|
37
|
+
},
|
|
38
|
+
readdir: async (path) => {
|
|
39
|
+
return fs.readdir(path);
|
|
40
|
+
},
|
|
41
|
+
writeFile: async (path, data, options) => {
|
|
42
|
+
await fs.writeFile(path, data, options);
|
|
43
|
+
},
|
|
44
|
+
mkdir: async (path, options) => {
|
|
45
|
+
await fs.mkdir(path, options);
|
|
46
|
+
},
|
|
47
|
+
unlink: async (path) => {
|
|
48
|
+
await fs.unlink(path);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
function hasVisibleText(value) {
|
|
52
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
53
|
+
}
|
|
54
|
+
function cloneDirectories(directories) {
|
|
55
|
+
return directories ? [...directories] : undefined;
|
|
56
|
+
}
|
|
57
|
+
function directoriesEqual(left, right) {
|
|
58
|
+
if (left === right) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (!left || !right) {
|
|
62
|
+
return !left && !right;
|
|
63
|
+
}
|
|
64
|
+
if (left.length !== right.length) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return left.every((value, index) => value === right[index]);
|
|
68
|
+
}
|
|
69
|
+
function parsePersistedSessionRecord(rawValue) {
|
|
70
|
+
const trimmed = rawValue.trim();
|
|
71
|
+
if (trimmed.startsWith('{')) {
|
|
72
|
+
const parsed = JSON.parse(trimmed);
|
|
73
|
+
if (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0) {
|
|
74
|
+
return {
|
|
75
|
+
sessionId: parsed.sessionId,
|
|
76
|
+
visibilityKey: typeof parsed.visibilityKey === 'string' && parsed.visibilityKey.trim().length > 0
|
|
77
|
+
? parsed.visibilityKey
|
|
78
|
+
: undefined,
|
|
79
|
+
cwd: typeof parsed.cwd === 'string' && parsed.cwd.trim().length > 0
|
|
80
|
+
? parsed.cwd
|
|
81
|
+
: undefined,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { sessionId: rawValue };
|
|
86
|
+
}
|
|
87
|
+
function serializePersistedSessionRecord(record) {
|
|
88
|
+
return JSON.stringify({
|
|
89
|
+
sessionId: record.sessionId,
|
|
90
|
+
...(record.visibilityKey ? { visibilityKey: record.visibilityKey } : {}),
|
|
91
|
+
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function formatToolProgress(title, status) {
|
|
95
|
+
const normalizedTitle = hasVisibleText(title) ? title.trim() : null;
|
|
96
|
+
switch (status) {
|
|
97
|
+
case 'completed':
|
|
98
|
+
return normalizedTitle ? `Completed ${normalizedTitle}` : 'Completed tool call';
|
|
99
|
+
case 'failed':
|
|
100
|
+
return normalizedTitle ? `Failed ${normalizedTitle}` : 'Tool call failed';
|
|
101
|
+
case 'pending':
|
|
102
|
+
case 'in_progress':
|
|
103
|
+
case undefined:
|
|
104
|
+
case null:
|
|
105
|
+
return normalizedTitle ? `Running ${normalizedTitle}…` : 'Running tool…';
|
|
106
|
+
default:
|
|
107
|
+
return normalizedTitle ? `${status} ${normalizedTitle}` : status;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function formatPlanProgress(entries) {
|
|
111
|
+
const current = entries.find((entry) => entry.status === 'in_progress') ??
|
|
112
|
+
entries.find((entry) => entry.status === 'pending') ??
|
|
113
|
+
entries[0];
|
|
114
|
+
return hasVisibleText(current?.content) ? `Plan: ${current.content.trim()}` : null;
|
|
115
|
+
}
|
|
116
|
+
class CodexProgressCollector {
|
|
117
|
+
onProgress;
|
|
118
|
+
publicText = '';
|
|
119
|
+
lastPublished = null;
|
|
120
|
+
toolTitles = new Map();
|
|
121
|
+
constructor(onProgress) {
|
|
122
|
+
this.onProgress = onProgress;
|
|
123
|
+
}
|
|
124
|
+
consume(update) {
|
|
125
|
+
switch (update.update.sessionUpdate) {
|
|
126
|
+
case 'agent_message_chunk':
|
|
127
|
+
if (update.update.content.type !== 'text') {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.publicText += update.update.content.text;
|
|
131
|
+
this.publish(this.publicText);
|
|
132
|
+
return;
|
|
133
|
+
case 'tool_call':
|
|
134
|
+
this.toolTitles.set(update.update.toolCallId, update.update.title);
|
|
135
|
+
this.publishFallback(formatToolProgress(update.update.title, update.update.status));
|
|
136
|
+
return;
|
|
137
|
+
case 'tool_call_update': {
|
|
138
|
+
const nextTitle = update.update.title ?? this.toolTitles.get(update.update.toolCallId);
|
|
139
|
+
if (hasVisibleText(nextTitle)) {
|
|
140
|
+
this.toolTitles.set(update.update.toolCallId, nextTitle);
|
|
141
|
+
}
|
|
142
|
+
this.publishFallback(formatToolProgress(nextTitle, update.update.status));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
case 'plan':
|
|
146
|
+
this.publishFallback(formatPlanProgress(update.update.entries.map((entry) => ({
|
|
147
|
+
content: entry.content,
|
|
148
|
+
status: entry.status,
|
|
149
|
+
}))));
|
|
150
|
+
return;
|
|
151
|
+
default:
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
getFinalText() {
|
|
156
|
+
return this.publicText.trim();
|
|
157
|
+
}
|
|
158
|
+
publishFallback(text) {
|
|
159
|
+
if (hasVisibleText(this.publicText)) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.publish(text);
|
|
163
|
+
}
|
|
164
|
+
publish(text) {
|
|
165
|
+
if (!this.onProgress || !hasVisibleText(text) || text === this.lastPublished) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.lastPublished = text;
|
|
169
|
+
this.onProgress({ text });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function createDeferredTurn(channelId, prompt, promptContext, sessionPersistence, options) {
|
|
173
|
+
let settled = false;
|
|
174
|
+
let resolvePromise;
|
|
175
|
+
let rejectPromise;
|
|
176
|
+
const promise = new Promise((resolve, reject) => {
|
|
177
|
+
resolvePromise = resolve;
|
|
178
|
+
rejectPromise = reject;
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
channelId,
|
|
182
|
+
prompt,
|
|
183
|
+
promptContext,
|
|
184
|
+
sessionPersistence,
|
|
185
|
+
options,
|
|
186
|
+
promise,
|
|
187
|
+
resolve(value) {
|
|
188
|
+
if (settled)
|
|
189
|
+
return;
|
|
190
|
+
settled = true;
|
|
191
|
+
resolvePromise(value);
|
|
192
|
+
},
|
|
193
|
+
reject(error) {
|
|
194
|
+
if (settled)
|
|
195
|
+
return;
|
|
196
|
+
settled = true;
|
|
197
|
+
rejectPromise(normalizeError(error));
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function normalizeError(error) {
|
|
202
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
203
|
+
}
|
|
204
|
+
function resolveSessionRouting(turn) {
|
|
205
|
+
return {
|
|
206
|
+
channelId: turn.channelId,
|
|
207
|
+
key: turn.providerSessionRouting?.key ?? turn.channelId,
|
|
208
|
+
persistence: turn.providerSessionRouting?.persistence ?? 'persistent',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function asObject(value) {
|
|
212
|
+
return typeof value === 'object' && value !== null ? value : null;
|
|
213
|
+
}
|
|
214
|
+
function readSessionCapabilities(response) {
|
|
215
|
+
const agentCapabilities = asObject(asObject(response)?.agentCapabilities);
|
|
216
|
+
const sessionCapabilities = asObject(agentCapabilities?.sessionCapabilities ?? agentCapabilities?.session);
|
|
217
|
+
return {
|
|
218
|
+
loadSession: agentCapabilities?.loadSession === true,
|
|
219
|
+
resumeSession: asObject(sessionCapabilities?.resume) !== null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function isStaleRestoreFailure(error) {
|
|
223
|
+
const normalized = normalizeError(error);
|
|
224
|
+
const code = asObject(error)?.code;
|
|
225
|
+
const dataCode = asObject(asObject(error)?.data)?.code;
|
|
226
|
+
if (code === 'session_not_found' || dataCode === 'session_not_found') {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
const message = normalized.message.toLowerCase();
|
|
230
|
+
return message.includes('session not found')
|
|
231
|
+
|| message.includes('unknown session')
|
|
232
|
+
|| message.includes('cannot resume')
|
|
233
|
+
|| message.includes('not found');
|
|
234
|
+
}
|
|
235
|
+
function markSessionTainted(error) {
|
|
236
|
+
const normalized = normalizeError(error);
|
|
237
|
+
SESSION_TAINTED_ERRORS.add(normalized);
|
|
238
|
+
return normalized;
|
|
239
|
+
}
|
|
240
|
+
function isSessionTainted(error) {
|
|
241
|
+
return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
|
|
242
|
+
}
|
|
243
|
+
function createPermissionResponse(params) {
|
|
244
|
+
const options = Array.isArray(params.options) ? params.options : [];
|
|
245
|
+
const preferredKinds = ['allow_once', 'allow_always', 'reject_once', 'reject_always'];
|
|
246
|
+
for (const kind of preferredKinds) {
|
|
247
|
+
const match = options.find((option) => option.kind === kind);
|
|
248
|
+
if (match) {
|
|
249
|
+
return {
|
|
250
|
+
outcome: {
|
|
251
|
+
outcome: 'selected',
|
|
252
|
+
optionId: match.optionId,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
outcome: {
|
|
259
|
+
outcome: 'cancelled',
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function resolveBundledCodexAdapterPath() {
|
|
264
|
+
return require.resolve('@agentclientprotocol/codex-acp');
|
|
265
|
+
}
|
|
266
|
+
function useBundledCodexAdapter(command, args) {
|
|
267
|
+
return command === DEFAULT_CODEX_COMMAND && args.length === DEFAULT_CODEX_ARGS.length;
|
|
268
|
+
}
|
|
269
|
+
function resolveAdapterLaunch(command, args) {
|
|
270
|
+
if (useBundledCodexAdapter(command, args)) {
|
|
271
|
+
return {
|
|
272
|
+
command: process.execPath,
|
|
273
|
+
args: [resolveBundledCodexAdapterPath()],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
command,
|
|
278
|
+
args: [...args],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
export class CodexCliClient {
|
|
282
|
+
command;
|
|
283
|
+
args;
|
|
284
|
+
sessionStore;
|
|
285
|
+
resolveSessionStoreAgentId;
|
|
286
|
+
logger;
|
|
287
|
+
runtime;
|
|
288
|
+
channels = new Map();
|
|
289
|
+
persistedSessions = new Map();
|
|
290
|
+
closingSessions = new WeakSet();
|
|
291
|
+
pendingSessionStarts = new Set();
|
|
292
|
+
pendingSessionCloses = new Set();
|
|
293
|
+
pendingSessionStoreOperations = new Set();
|
|
294
|
+
fatalPromise;
|
|
295
|
+
rejectFatalPromise;
|
|
296
|
+
child;
|
|
297
|
+
connection;
|
|
298
|
+
startPromise;
|
|
299
|
+
shutdownPromise;
|
|
300
|
+
childExitPromise;
|
|
301
|
+
resolveChildExit;
|
|
302
|
+
fatalError = null;
|
|
303
|
+
disposing = false;
|
|
304
|
+
backendClosed = false;
|
|
305
|
+
loadedSessionStoreAgentId = null;
|
|
306
|
+
sessionStoreLoadPromise = null;
|
|
307
|
+
sessionStoreWriteQueue = Promise.resolve();
|
|
308
|
+
sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
309
|
+
constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
|
|
310
|
+
this.command = command;
|
|
311
|
+
this.args = args;
|
|
312
|
+
this.sessionStore = sessionStore;
|
|
313
|
+
this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
|
|
314
|
+
this.logger = logger;
|
|
315
|
+
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
316
|
+
this.fatalPromise = new Promise((_, reject) => {
|
|
317
|
+
this.rejectFatalPromise = reject;
|
|
318
|
+
});
|
|
319
|
+
void this.fatalPromise.catch(() => { });
|
|
320
|
+
}
|
|
321
|
+
async generateReply(channelIdOrTurn, promptOrOptions, maybeOptions) {
|
|
322
|
+
if (this.fatalError) {
|
|
323
|
+
throw this.fatalError;
|
|
324
|
+
}
|
|
325
|
+
const preparedTurn = typeof channelIdOrTurn === 'string'
|
|
326
|
+
? {
|
|
327
|
+
channelId: channelIdOrTurn,
|
|
328
|
+
prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
|
|
329
|
+
}
|
|
330
|
+
: channelIdOrTurn;
|
|
331
|
+
const options = typeof channelIdOrTurn === 'string'
|
|
332
|
+
? maybeOptions
|
|
333
|
+
: promptOrOptions;
|
|
334
|
+
const sessionRouting = resolveSessionRouting(preparedTurn);
|
|
335
|
+
const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
|
|
336
|
+
this.clearIdleTimer(state);
|
|
337
|
+
const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, preparedTurn.promptContext, sessionRouting.persistence, options);
|
|
338
|
+
state.queue.push(turn);
|
|
339
|
+
this.processChannelQueue(sessionRouting.key, state);
|
|
340
|
+
return turn.promise;
|
|
341
|
+
}
|
|
342
|
+
async dispose() {
|
|
343
|
+
const error = new Error('Codex ACP backend stopped');
|
|
344
|
+
this.disposing = true;
|
|
345
|
+
this.logger?.debug('stopping Codex ACP backend');
|
|
346
|
+
const closed = this.connection?.closed ?? Promise.resolve();
|
|
347
|
+
this.failAll(error);
|
|
348
|
+
let thrown;
|
|
349
|
+
try {
|
|
350
|
+
await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
|
|
351
|
+
}
|
|
352
|
+
catch (disposeError) {
|
|
353
|
+
thrown = disposeError;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
await Promise.allSettled(this.pendingSessionStoreOperations);
|
|
357
|
+
await this.sessionStoreWriteQueue;
|
|
358
|
+
await this.sessionStore?.close?.();
|
|
359
|
+
}
|
|
360
|
+
catch (closeError) {
|
|
361
|
+
thrown ??= closeError;
|
|
362
|
+
}
|
|
363
|
+
if (thrown) {
|
|
364
|
+
throw thrown;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async ensureStarted() {
|
|
368
|
+
if (this.fatalError) {
|
|
369
|
+
throw this.fatalError;
|
|
370
|
+
}
|
|
371
|
+
if (!this.startPromise) {
|
|
372
|
+
this.startPromise = this.startBackend();
|
|
373
|
+
}
|
|
374
|
+
await this.startPromise;
|
|
375
|
+
if (this.fatalError) {
|
|
376
|
+
throw this.fatalError;
|
|
377
|
+
}
|
|
378
|
+
if (!this.connection) {
|
|
379
|
+
throw new Error('Codex ACP connection is not available');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async startBackend() {
|
|
383
|
+
const launch = resolveAdapterLaunch(this.command, this.args);
|
|
384
|
+
this.logger?.debug('starting Codex ACP process', {
|
|
385
|
+
command: launch.command,
|
|
386
|
+
args: launch.args,
|
|
387
|
+
cwd: this.runtime.cwd,
|
|
388
|
+
});
|
|
389
|
+
const child = this.runtime.spawn(launch.command, launch.args, {
|
|
390
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
391
|
+
});
|
|
392
|
+
this.child = child;
|
|
393
|
+
child.stderr.setEncoding('utf8');
|
|
394
|
+
child.stderr.on('data', (chunk) => {
|
|
395
|
+
this.logger?.childStderr('codex stderr', summarizeChildStderr(chunk));
|
|
396
|
+
});
|
|
397
|
+
child.stderr.resume();
|
|
398
|
+
this.logger?.debug('spawned Codex ACP process', { pid: child.pid });
|
|
399
|
+
child.once('error', (error) => {
|
|
400
|
+
this.logger?.debugError('Codex ACP process error', error);
|
|
401
|
+
this.failAll(new Error(`Codex ACP process error: ${normalizeError(error).message}`));
|
|
402
|
+
});
|
|
403
|
+
child.once('exit', (code, signal) => {
|
|
404
|
+
this.resolveChildExit?.();
|
|
405
|
+
if (this.disposing) {
|
|
406
|
+
this.logger?.debug('Codex ACP process exited during shutdown', { code, signal });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.logger?.debugError('Codex ACP process exited unexpectedly', { code, signal });
|
|
410
|
+
const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
|
|
411
|
+
this.failAll(new Error(`Codex ACP process exited unexpectedly (${suffix})`));
|
|
412
|
+
});
|
|
413
|
+
this.childExitPromise = new Promise((resolveChildExit) => {
|
|
414
|
+
this.resolveChildExit = resolveChildExit;
|
|
415
|
+
});
|
|
416
|
+
const output = Writable.toWeb(child.stdin);
|
|
417
|
+
const input = Readable.toWeb(child.stdout);
|
|
418
|
+
const stream = this.runtime.ndJsonStream(output, input);
|
|
419
|
+
const app = this.runtime
|
|
420
|
+
.client({ name: 'borgee-agents-host' })
|
|
421
|
+
.onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => (createPermissionResponse(params)));
|
|
422
|
+
const connection = app.connect(stream);
|
|
423
|
+
this.connection = connection;
|
|
424
|
+
void connection.closed.then(() => {
|
|
425
|
+
if (!this.disposing) {
|
|
426
|
+
this.logger?.debugError('Codex ACP connection closed unexpectedly');
|
|
427
|
+
this.failAll(new Error('Codex ACP connection closed unexpectedly'));
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
try {
|
|
431
|
+
const initializeResponse = await connection.agent.request(this.runtime.methods.agent.initialize, {
|
|
432
|
+
protocolVersion: this.runtime.protocolVersion,
|
|
433
|
+
clientCapabilities: {},
|
|
434
|
+
clientInfo: {
|
|
435
|
+
name: '@borgee/agents-host',
|
|
436
|
+
version: '0.2.24',
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
this.sessionCapabilities = readSessionCapabilities(initializeResponse);
|
|
440
|
+
this.logger?.debug('initialized Codex ACP connection', {
|
|
441
|
+
loadSession: this.sessionCapabilities.loadSession,
|
|
442
|
+
resumeSession: this.sessionCapabilities.resumeSession,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
const normalized = new Error(`Codex ACP initialize failed: ${normalizeError(error).message}`);
|
|
447
|
+
this.logger?.debugError('Codex ACP initialize failed', normalized);
|
|
448
|
+
this.failAll(normalized);
|
|
449
|
+
throw normalized;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
getOrCreateChannelState(channelId, resolvedChannelId, sessionPersistence) {
|
|
453
|
+
let state = this.channels.get(channelId);
|
|
454
|
+
if (!state) {
|
|
455
|
+
state = {
|
|
456
|
+
channelId: resolvedChannelId,
|
|
457
|
+
sessionPersistence,
|
|
458
|
+
idleTimerGeneration: 0,
|
|
459
|
+
processing: false,
|
|
460
|
+
queue: [],
|
|
461
|
+
};
|
|
462
|
+
this.channels.set(channelId, state);
|
|
463
|
+
}
|
|
464
|
+
else if (state.channelId !== resolvedChannelId
|
|
465
|
+
|| state.sessionPersistence !== sessionPersistence) {
|
|
466
|
+
throw new Error(`Codex session routing changed for key "${channelId}"`);
|
|
467
|
+
}
|
|
468
|
+
return state;
|
|
469
|
+
}
|
|
470
|
+
processChannelQueue(channelId, state) {
|
|
471
|
+
if (state.processing) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
state.processing = true;
|
|
475
|
+
void (async () => {
|
|
476
|
+
try {
|
|
477
|
+
while (!this.fatalError) {
|
|
478
|
+
const turn = state.queue.shift();
|
|
479
|
+
if (!turn) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
state.activeTurn = turn;
|
|
483
|
+
try {
|
|
484
|
+
state.cwd = await this.resolveSessionCwd(turn.promptContext);
|
|
485
|
+
const projectedPromptContext = await this.refreshProjectedPromptContextBestEffort(turn.channelId, turn.promptContext);
|
|
486
|
+
state.additionalDirectories = this.resolveSessionAdditionalDirectories(projectedPromptContext);
|
|
487
|
+
state.visibilityKey = this.resolveSessionVisibilityKey(projectedPromptContext);
|
|
488
|
+
await this.ensureStarted();
|
|
489
|
+
await this.recycleSessionIfInjectionScopeChanged(channelId, state);
|
|
490
|
+
const session = await this.getOrCreateSession(channelId, state);
|
|
491
|
+
let reply;
|
|
492
|
+
try {
|
|
493
|
+
reply = await this.runTurn(session, this.rewritePromptContextPaths(turn.prompt, turn.promptContext, projectedPromptContext?.promptContext), turn.options);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
if (isSessionTainted(error)) {
|
|
497
|
+
this.invalidateSession(channelId, state, session);
|
|
498
|
+
this.rejectQueuedTurnsAfterSessionTaint(state, error);
|
|
499
|
+
}
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
turn.resolve(reply);
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
turn.reject(error);
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
state.activeTurn = undefined;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
state.processing = false;
|
|
514
|
+
this.reconcileIdleChannelState(channelId, state);
|
|
515
|
+
}
|
|
516
|
+
})();
|
|
517
|
+
}
|
|
518
|
+
async getOrCreateSession(channelId, state) {
|
|
519
|
+
if (state.session) {
|
|
520
|
+
return state.session;
|
|
521
|
+
}
|
|
522
|
+
if (state.sessionPromise) {
|
|
523
|
+
return state.sessionPromise;
|
|
524
|
+
}
|
|
525
|
+
if (!this.connection) {
|
|
526
|
+
throw new Error('Codex ACP connection is not available');
|
|
527
|
+
}
|
|
528
|
+
const cwd = state.cwd ?? this.runtime.cwd;
|
|
529
|
+
const persistedSessionId = state.sessionPersistence === 'persistent'
|
|
530
|
+
? await this.readPersistedSessionId(channelId, state.visibilityKey, cwd)
|
|
531
|
+
: undefined;
|
|
532
|
+
const additionalDirectories = state.additionalDirectories;
|
|
533
|
+
const sessionPromise = persistedSessionId
|
|
534
|
+
? this.restoreOrCreateSession(channelId, persistedSessionId, cwd, additionalDirectories)
|
|
535
|
+
: this.startFreshSession(channelId, cwd, additionalDirectories);
|
|
536
|
+
this.pendingSessionStarts.add(sessionPromise);
|
|
537
|
+
state.sessionPromise = sessionPromise;
|
|
538
|
+
let sessionAdopted = false;
|
|
539
|
+
void sessionPromise
|
|
540
|
+
.then((session) => {
|
|
541
|
+
if (!sessionAdopted &&
|
|
542
|
+
(this.fatalError !== null || this.disposing || state.sessionPromise !== sessionPromise)) {
|
|
543
|
+
this.closeSession(session);
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
.finally(() => {
|
|
547
|
+
this.pendingSessionStarts.delete(sessionPromise);
|
|
548
|
+
})
|
|
549
|
+
.catch(() => { });
|
|
550
|
+
try {
|
|
551
|
+
const session = await this.raceWithFatal(sessionPromise);
|
|
552
|
+
if (this.fatalError) {
|
|
553
|
+
this.closeSession(session);
|
|
554
|
+
throw this.fatalError;
|
|
555
|
+
}
|
|
556
|
+
sessionAdopted = true;
|
|
557
|
+
state.session = session;
|
|
558
|
+
state.sessionCwd = cwd;
|
|
559
|
+
state.sessionAdditionalDirectories = cloneDirectories(state.additionalDirectories);
|
|
560
|
+
state.sessionVisibilityKey = state.visibilityKey;
|
|
561
|
+
return session;
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
state.session = undefined;
|
|
565
|
+
state.sessionCwd = undefined;
|
|
566
|
+
state.sessionAdditionalDirectories = undefined;
|
|
567
|
+
state.sessionVisibilityKey = undefined;
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
if (state.sessionPromise === sessionPromise) {
|
|
572
|
+
state.sessionPromise = undefined;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async startFreshSession(channelId, cwd, additionalDirectories) {
|
|
577
|
+
if (!this.connection) {
|
|
578
|
+
throw new Error('Codex ACP connection is not available');
|
|
579
|
+
}
|
|
580
|
+
const sessionBuilder = this.connection.agent.buildSession(cwd);
|
|
581
|
+
if (additionalDirectories && additionalDirectories.length > 0) {
|
|
582
|
+
sessionBuilder.withAdditionalDirectories(additionalDirectories);
|
|
583
|
+
}
|
|
584
|
+
const session = await sessionBuilder.start();
|
|
585
|
+
this.logger?.debug('started fresh Codex ACP session', { channelId, cwd });
|
|
586
|
+
const state = this.channels.get(channelId);
|
|
587
|
+
if (state?.sessionPersistence === 'persistent') {
|
|
588
|
+
await this.persistSessionBestEffort(channelId, session.sessionId, state.visibilityKey, cwd);
|
|
589
|
+
}
|
|
590
|
+
return session;
|
|
591
|
+
}
|
|
592
|
+
async restoreOrCreateSession(channelId, sessionId, cwd, additionalDirectories) {
|
|
593
|
+
if (!this.sessionCapabilities.resumeSession && !this.sessionCapabilities.loadSession) {
|
|
594
|
+
this.logger?.debug('Codex ACP restore unsupported; starting fresh session', { channelId });
|
|
595
|
+
return this.startFreshSession(channelId, cwd, additionalDirectories);
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
const session = await this.restoreSession(sessionId, cwd, additionalDirectories);
|
|
599
|
+
this.logger?.debug('restored Codex ACP session', {
|
|
600
|
+
channelId,
|
|
601
|
+
restoreMethod: this.sessionCapabilities.resumeSession ? 'session/resume' : 'session/load',
|
|
602
|
+
});
|
|
603
|
+
return session;
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
if (!isStaleRestoreFailure(error)) {
|
|
607
|
+
throw normalizeError(error);
|
|
608
|
+
}
|
|
609
|
+
this.logger?.debug('discarded stale Codex ACP session and started fresh', { channelId });
|
|
610
|
+
await this.clearPersistedSessionBestEffort(channelId);
|
|
611
|
+
return this.startFreshSession(channelId, cwd, additionalDirectories);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async restoreSession(sessionId, cwd, additionalDirectories) {
|
|
615
|
+
if (!this.connection) {
|
|
616
|
+
throw new Error('Codex ACP connection is not available');
|
|
617
|
+
}
|
|
618
|
+
const agent = this.connection.agent;
|
|
619
|
+
if (typeof agent.attachSession !== 'function') {
|
|
620
|
+
throw new Error('Codex ACP SDK does not expose session attachment helpers');
|
|
621
|
+
}
|
|
622
|
+
const session = agent.attachSession({ sessionId });
|
|
623
|
+
const sessionRequest = additionalDirectories && additionalDirectories.length > 0
|
|
624
|
+
? { sessionId, cwd, additionalDirectories, mcpServers: [] }
|
|
625
|
+
: { sessionId, cwd, mcpServers: [] };
|
|
626
|
+
try {
|
|
627
|
+
if (this.sessionCapabilities.resumeSession) {
|
|
628
|
+
await this.connection.agent.request(this.runtime.methods.agent.session.resume, sessionRequest);
|
|
629
|
+
return session;
|
|
630
|
+
}
|
|
631
|
+
if (this.sessionCapabilities.loadSession) {
|
|
632
|
+
await this.connection.agent.request(this.runtime.methods.agent.session.load, sessionRequest);
|
|
633
|
+
this.clearBufferedSessionReplay(session);
|
|
634
|
+
return session;
|
|
635
|
+
}
|
|
636
|
+
throw new Error('Codex ACP agent does not advertise session restore capabilities');
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
session.dispose();
|
|
640
|
+
throw normalizeError(error);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
clearBufferedSessionReplay(session) {
|
|
644
|
+
const updates = session.updates;
|
|
645
|
+
if (updates && Array.isArray(updates.values)) {
|
|
646
|
+
updates.values = [];
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async resolveSessionCwd(promptContext) {
|
|
650
|
+
return promptContext?.taskWorkspace?.rootPath ?? this.runtime.cwd;
|
|
651
|
+
}
|
|
652
|
+
async recycleSessionIfInjectionScopeChanged(channelId, state) {
|
|
653
|
+
const session = state.session;
|
|
654
|
+
if (!session) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (state.sessionCwd === (state.cwd ?? this.runtime.cwd)
|
|
658
|
+
&& directoriesEqual(state.sessionAdditionalDirectories, state.additionalDirectories)
|
|
659
|
+
&& state.sessionVisibilityKey === state.visibilityKey) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
this.logger?.debug('recycling Codex ACP session after session scope changed', {
|
|
663
|
+
channelId,
|
|
664
|
+
previousCwd: state.sessionCwd,
|
|
665
|
+
nextCwd: state.cwd ?? this.runtime.cwd,
|
|
666
|
+
});
|
|
667
|
+
state.session = undefined;
|
|
668
|
+
state.sessionCwd = undefined;
|
|
669
|
+
state.sessionAdditionalDirectories = undefined;
|
|
670
|
+
state.sessionVisibilityKey = undefined;
|
|
671
|
+
this.clearIdleTimer(state);
|
|
672
|
+
if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
|
|
673
|
+
await this.clearPersistedSessionBestEffort(channelId);
|
|
674
|
+
}
|
|
675
|
+
this.closeSession(session);
|
|
676
|
+
}
|
|
677
|
+
resolveSessionAdditionalDirectories(projectedPromptContext) {
|
|
678
|
+
return projectedPromptContext ? [projectedPromptContext.directoryPath] : undefined;
|
|
679
|
+
}
|
|
680
|
+
resolveSessionVisibilityKey(projectedPromptContext) {
|
|
681
|
+
if (!projectedPromptContext) {
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
return JSON.stringify({
|
|
685
|
+
directoryPath: projectedPromptContext.directoryPath,
|
|
686
|
+
channelContextPayloadPath: projectedPromptContext.promptContext.channelContextPayloadPath,
|
|
687
|
+
gatewayAuthPath: projectedPromptContext.promptContext.gatewayAuthPath ?? null,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
resolveProjectedContextDirectory(payloadPath) {
|
|
691
|
+
const channelContextDirectory = resolve(dirname(payloadPath));
|
|
692
|
+
return join(dirname(dirname(channelContextDirectory)), CODEX_CONTEXT_ROOT_DIRNAME, basename(channelContextDirectory));
|
|
693
|
+
}
|
|
694
|
+
buildProjectedPromptContext(promptContext, projectedDirectoryPath) {
|
|
695
|
+
const payloadPath = promptContext.channelContextPayloadPath?.trim();
|
|
696
|
+
if (!payloadPath) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
...promptContext,
|
|
701
|
+
channelContextPayloadPath: join(projectedDirectoryPath, basename(payloadPath)),
|
|
702
|
+
gatewayAuthPath: promptContext.gatewayAuthPath
|
|
703
|
+
? join(projectedDirectoryPath, basename(promptContext.gatewayAuthPath))
|
|
704
|
+
: undefined,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
async copyProjectedFile(sourcePath, targetPath) {
|
|
708
|
+
const content = await this.runtime.readFile(sourcePath, { encoding: 'utf8' });
|
|
709
|
+
await this.runtime.writeFile(targetPath, content, {
|
|
710
|
+
encoding: 'utf8',
|
|
711
|
+
mode: 0o600,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
async pruneProjectedGatewayAuthFiles(directoryPath, keepPath) {
|
|
715
|
+
const keepFilename = keepPath ? basename(keepPath) : null;
|
|
716
|
+
const entries = await this.runtime.readdir(directoryPath);
|
|
717
|
+
await Promise.all(entries
|
|
718
|
+
.filter((entry) => entry === PROJECTED_GATEWAY_AUTH_FILENAME
|
|
719
|
+
|| (entry.startsWith(PROJECTED_GATEWAY_AUTH_PREFIX) && entry.endsWith('.json')))
|
|
720
|
+
.filter((entry) => entry !== keepFilename)
|
|
721
|
+
.map(async (entry) => {
|
|
722
|
+
await this.runtime.unlink(join(directoryPath, entry)).catch((error) => {
|
|
723
|
+
if (error.code !== 'ENOENT') {
|
|
724
|
+
throw error;
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}));
|
|
728
|
+
}
|
|
729
|
+
async refreshProjectedPromptContextBestEffort(channelId, promptContext) {
|
|
730
|
+
const resolvedPromptContext = promptContext;
|
|
731
|
+
const payloadPath = resolvedPromptContext?.channelContextPayloadPath?.trim();
|
|
732
|
+
if (!resolvedPromptContext || !payloadPath) {
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
const projectedDirectoryPath = this.resolveProjectedContextDirectory(payloadPath);
|
|
736
|
+
const projectedPromptContext = this.buildProjectedPromptContext(resolvedPromptContext, projectedDirectoryPath);
|
|
737
|
+
if (!projectedPromptContext?.channelContextPayloadPath) {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
await this.runtime.mkdir(projectedDirectoryPath, { recursive: true, mode: 0o700 });
|
|
742
|
+
await this.pruneProjectedGatewayAuthFiles(projectedDirectoryPath, projectedPromptContext.gatewayAuthPath);
|
|
743
|
+
await this.copyProjectedFile(payloadPath, projectedPromptContext.channelContextPayloadPath);
|
|
744
|
+
if (resolvedPromptContext.gatewayAuthPath && projectedPromptContext.gatewayAuthPath) {
|
|
745
|
+
await this.copyProjectedFile(resolvedPromptContext.gatewayAuthPath, projectedPromptContext.gatewayAuthPath);
|
|
746
|
+
}
|
|
747
|
+
const content = buildCodexProjectDocument(projectedPromptContext);
|
|
748
|
+
assertCodexProjectDocumentSize(content);
|
|
749
|
+
await this.runtime.writeFile(join(projectedDirectoryPath, 'AGENTS.md'), content, {
|
|
750
|
+
encoding: 'utf8',
|
|
751
|
+
mode: 0o600,
|
|
752
|
+
});
|
|
753
|
+
return {
|
|
754
|
+
directoryPath: projectedDirectoryPath,
|
|
755
|
+
promptContext: projectedPromptContext,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
this.logger.error('failed to refresh Codex visible context projection; keeping prompt delivery', {
|
|
760
|
+
channelId,
|
|
761
|
+
cwd: projectedDirectoryPath,
|
|
762
|
+
error: summarizeError(error),
|
|
763
|
+
});
|
|
764
|
+
return undefined;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
rewritePromptContextPaths(prompt, sourceContext, targetContext) {
|
|
768
|
+
if (!sourceContext) {
|
|
769
|
+
return prompt;
|
|
770
|
+
}
|
|
771
|
+
let rewritten = prompt;
|
|
772
|
+
const replacements = [
|
|
773
|
+
[sourceContext.channelContextPayloadPath, targetContext?.channelContextPayloadPath],
|
|
774
|
+
[sourceContext.gatewayAuthPath, targetContext?.gatewayAuthPath],
|
|
775
|
+
];
|
|
776
|
+
for (const [sourcePath, targetPath] of replacements) {
|
|
777
|
+
if (!sourcePath || sourcePath === targetPath) {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
rewritten = rewritten.split(sourcePath).join(targetPath ?? PROJECTION_UNAVAILABLE_PLACEHOLDER);
|
|
781
|
+
}
|
|
782
|
+
return rewritten;
|
|
783
|
+
}
|
|
784
|
+
async runTurn(session, prompt, options) {
|
|
785
|
+
const promptPromise = this.raceWithFatal(session.prompt(prompt));
|
|
786
|
+
const promptFailure = new Promise((_, reject) => {
|
|
787
|
+
void promptPromise.catch((error) => reject(markSessionTainted(error)));
|
|
788
|
+
});
|
|
789
|
+
const collector = new CodexProgressCollector(options?.onProgress);
|
|
790
|
+
for (;;) {
|
|
791
|
+
let update;
|
|
792
|
+
try {
|
|
793
|
+
update = await this.raceWithFatal(Promise.race([session.nextUpdate(), promptFailure]));
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
if (this.fatalError && error === this.fatalError) {
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
throw markSessionTainted(error);
|
|
800
|
+
}
|
|
801
|
+
if (update.kind === 'stop') {
|
|
802
|
+
let response;
|
|
803
|
+
try {
|
|
804
|
+
response = await promptPromise;
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
throw markSessionTainted(error);
|
|
808
|
+
}
|
|
809
|
+
if (response.stopReason !== 'end_turn') {
|
|
810
|
+
throw new Error(`Codex ACP turn stopped with stopReason "${response.stopReason}"`);
|
|
811
|
+
}
|
|
812
|
+
const output = collector.getFinalText();
|
|
813
|
+
if (!output) {
|
|
814
|
+
throw new Error('Codex ACP returned empty output');
|
|
815
|
+
}
|
|
816
|
+
return output;
|
|
817
|
+
}
|
|
818
|
+
collector.consume(update);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
async raceWithFatal(promise) {
|
|
822
|
+
if (this.fatalError) {
|
|
823
|
+
throw this.fatalError;
|
|
824
|
+
}
|
|
825
|
+
return Promise.race([promise, this.fatalPromise]);
|
|
826
|
+
}
|
|
827
|
+
invalidateSession(channelId, state, session) {
|
|
828
|
+
this.clearIdleTimer(state);
|
|
829
|
+
if (state.session === session) {
|
|
830
|
+
state.session = undefined;
|
|
831
|
+
state.sessionCwd = undefined;
|
|
832
|
+
state.sessionAdditionalDirectories = undefined;
|
|
833
|
+
state.sessionVisibilityKey = undefined;
|
|
834
|
+
}
|
|
835
|
+
this.logger?.debug('discarding tainted Codex ACP session', { channelId });
|
|
836
|
+
this.closeSession(session);
|
|
837
|
+
if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
|
|
838
|
+
void this.clearPersistedSessionBestEffort(channelId);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
rejectQueuedTurnsAfterSessionTaint(state, error) {
|
|
842
|
+
if (state.queue.length === 0) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const rejection = new Error(QUEUED_TURN_DROPPED_MESSAGE, {
|
|
846
|
+
cause: normalizeError(error),
|
|
847
|
+
});
|
|
848
|
+
for (const turn of state.queue) {
|
|
849
|
+
turn.reject(rejection);
|
|
850
|
+
}
|
|
851
|
+
state.queue.length = 0;
|
|
852
|
+
}
|
|
853
|
+
failAll(error) {
|
|
854
|
+
if (this.fatalError) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
this.fatalError = error;
|
|
858
|
+
this.rejectFatalPromise(error);
|
|
859
|
+
for (const [channelId, state] of this.channels.entries()) {
|
|
860
|
+
this.clearIdleTimer(state);
|
|
861
|
+
state.activeTurn?.reject(error);
|
|
862
|
+
for (const turn of state.queue) {
|
|
863
|
+
turn.reject(error);
|
|
864
|
+
}
|
|
865
|
+
state.queue.length = 0;
|
|
866
|
+
if (state.session) {
|
|
867
|
+
this.closeSession(state.session);
|
|
868
|
+
}
|
|
869
|
+
state.session = undefined;
|
|
870
|
+
state.sessionCwd = undefined;
|
|
871
|
+
state.sessionAdditionalDirectories = undefined;
|
|
872
|
+
state.sessionVisibilityKey = undefined;
|
|
873
|
+
state.sessionPromise = undefined;
|
|
874
|
+
state.activeTurn = undefined;
|
|
875
|
+
if (!state.processing) {
|
|
876
|
+
this.channels.delete(channelId);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
this.channels.clear();
|
|
880
|
+
this.shutdownPromise = this.shutdownBackend(error);
|
|
881
|
+
}
|
|
882
|
+
closeSession(session) {
|
|
883
|
+
if (this.closingSessions.has(session)) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
this.closingSessions.add(session);
|
|
887
|
+
const agent = this.connection?.agent;
|
|
888
|
+
const closeMethod = this.runtime.methods.agent.session?.close;
|
|
889
|
+
const closePromise = typeof agent?.closeSession === 'function'
|
|
890
|
+
? agent.closeSession({ sessionId: session.sessionId })
|
|
891
|
+
: closeMethod
|
|
892
|
+
? this.connection?.agent.request(closeMethod, { sessionId: session.sessionId })
|
|
893
|
+
: undefined;
|
|
894
|
+
session.dispose();
|
|
895
|
+
if (!closePromise) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
let trackedClosePromise;
|
|
899
|
+
trackedClosePromise = Promise.resolve(closePromise)
|
|
900
|
+
.then(() => undefined)
|
|
901
|
+
.catch(() => { })
|
|
902
|
+
.finally(() => {
|
|
903
|
+
this.pendingSessionCloses.delete(trackedClosePromise);
|
|
904
|
+
});
|
|
905
|
+
this.pendingSessionCloses.add(trackedClosePromise);
|
|
906
|
+
}
|
|
907
|
+
clearIdleTimer(state) {
|
|
908
|
+
state.idleTimerGeneration += 1;
|
|
909
|
+
if (!state.idleTimer) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
clearTimeout(state.idleTimer);
|
|
913
|
+
state.idleTimer = undefined;
|
|
914
|
+
}
|
|
915
|
+
reconcileIdleChannelState(channelId, state) {
|
|
916
|
+
if (this.channels.get(channelId) !== state) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (this.disposing || this.fatalError || this.backendClosed) {
|
|
920
|
+
this.clearIdleTimer(state);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
924
|
+
this.clearIdleTimer(state);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (!state.session) {
|
|
928
|
+
this.clearIdleTimer(state);
|
|
929
|
+
this.channels.delete(channelId);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (state.sessionPersistence === 'ephemeral') {
|
|
933
|
+
this.clearIdleTimer(state);
|
|
934
|
+
const session = state.session;
|
|
935
|
+
state.session = undefined;
|
|
936
|
+
state.sessionCwd = undefined;
|
|
937
|
+
state.sessionAdditionalDirectories = undefined;
|
|
938
|
+
state.sessionVisibilityKey = undefined;
|
|
939
|
+
this.closeSession(session);
|
|
940
|
+
this.channels.delete(channelId);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
this.clearIdleTimer(state);
|
|
944
|
+
const generation = state.idleTimerGeneration;
|
|
945
|
+
state.idleTimer = setTimeout(() => {
|
|
946
|
+
this.evictIdleChannel(channelId, state, generation);
|
|
947
|
+
}, this.runtime.idleSessionTtlMs);
|
|
948
|
+
}
|
|
949
|
+
async ensureSessionStoreLoaded() {
|
|
950
|
+
if (!this.sessionStore) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const agentId = this.resolveSessionStoreAgentId()?.trim();
|
|
954
|
+
if (!agentId) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (this.loadedSessionStoreAgentId === agentId) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (!this.sessionStoreLoadPromise) {
|
|
961
|
+
this.sessionStoreLoadPromise = (async () => {
|
|
962
|
+
const loaded = await this.sessionStore.load(agentId);
|
|
963
|
+
this.persistedSessions.clear();
|
|
964
|
+
for (const [channelId, sessionValue] of Object.entries(loaded)) {
|
|
965
|
+
this.persistedSessions.set(channelId, parsePersistedSessionRecord(sessionValue));
|
|
966
|
+
}
|
|
967
|
+
this.loadedSessionStoreAgentId = agentId;
|
|
968
|
+
})().finally(() => {
|
|
969
|
+
this.sessionStoreLoadPromise = null;
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
await this.sessionStoreLoadPromise;
|
|
973
|
+
}
|
|
974
|
+
async readPersistedSessionId(channelId, visibilityKey, cwd) {
|
|
975
|
+
await this.ensureSessionStoreLoaded();
|
|
976
|
+
const record = this.persistedSessions.get(channelId);
|
|
977
|
+
if (!record) {
|
|
978
|
+
return undefined;
|
|
979
|
+
}
|
|
980
|
+
if (record.visibilityKey !== visibilityKey
|
|
981
|
+
|| !record.cwd
|
|
982
|
+
|| record.cwd !== cwd) {
|
|
983
|
+
await this.clearPersistedSessionBestEffort(channelId);
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
986
|
+
return record.sessionId;
|
|
987
|
+
}
|
|
988
|
+
async persistSession(channelId, sessionId, visibilityKey, cwd) {
|
|
989
|
+
await this.ensureSessionStoreLoaded();
|
|
990
|
+
this.persistedSessions.set(channelId, { sessionId, visibilityKey, cwd });
|
|
991
|
+
await this.flushSessionStore();
|
|
992
|
+
}
|
|
993
|
+
async persistSessionBestEffort(channelId, sessionId, visibilityKey, cwd) {
|
|
994
|
+
await this.trackSessionStoreOperation((async () => {
|
|
995
|
+
try {
|
|
996
|
+
await this.persistSession(channelId, sessionId, visibilityKey, cwd);
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
this.logger.error('failed to persist Codex session map; keeping reply delivery', {
|
|
1000
|
+
agentId: this.loadedSessionStoreAgentId,
|
|
1001
|
+
channelId,
|
|
1002
|
+
sessionId,
|
|
1003
|
+
error: summarizeError(error),
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
})());
|
|
1007
|
+
}
|
|
1008
|
+
async clearPersistedSession(channelId) {
|
|
1009
|
+
await this.ensureSessionStoreLoaded();
|
|
1010
|
+
if (!this.persistedSessions.delete(channelId)) {
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
await this.flushSessionStore();
|
|
1014
|
+
}
|
|
1015
|
+
async clearPersistedSessionBestEffort(channelId) {
|
|
1016
|
+
await this.trackSessionStoreOperation((async () => {
|
|
1017
|
+
try {
|
|
1018
|
+
await this.clearPersistedSession(channelId);
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
this.logger.error('failed to clear Codex session map; retrying in-memory only', {
|
|
1022
|
+
agentId: this.loadedSessionStoreAgentId,
|
|
1023
|
+
channelId,
|
|
1024
|
+
error: summarizeError(error),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
})());
|
|
1028
|
+
}
|
|
1029
|
+
async flushSessionStore() {
|
|
1030
|
+
if (!this.sessionStore || !this.loadedSessionStoreAgentId) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const agentId = this.loadedSessionStoreAgentId;
|
|
1034
|
+
const snapshot = Object.fromEntries([...this.persistedSessions.entries()].map(([channelId, record]) => [
|
|
1035
|
+
channelId,
|
|
1036
|
+
serializePersistedSessionRecord(record),
|
|
1037
|
+
]));
|
|
1038
|
+
const write = this.sessionStoreWriteQueue.then(() => this.sessionStore.save(agentId, snapshot));
|
|
1039
|
+
this.sessionStoreWriteQueue = write.catch(() => { });
|
|
1040
|
+
await write;
|
|
1041
|
+
}
|
|
1042
|
+
async trackSessionStoreOperation(operation) {
|
|
1043
|
+
const tracked = operation.finally(() => {
|
|
1044
|
+
this.pendingSessionStoreOperations.delete(tracked);
|
|
1045
|
+
});
|
|
1046
|
+
this.pendingSessionStoreOperations.add(tracked);
|
|
1047
|
+
return tracked;
|
|
1048
|
+
}
|
|
1049
|
+
evictIdleChannel(channelId, state, generation) {
|
|
1050
|
+
if (this.disposing || this.fatalError || this.backendClosed) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (this.channels.get(channelId) !== state || state.idleTimerGeneration !== generation) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
state.idleTimer = undefined;
|
|
1057
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const session = state.session;
|
|
1061
|
+
state.session = undefined;
|
|
1062
|
+
state.sessionCwd = undefined;
|
|
1063
|
+
state.sessionAdditionalDirectories = undefined;
|
|
1064
|
+
state.sessionVisibilityKey = undefined;
|
|
1065
|
+
if (session) {
|
|
1066
|
+
this.logger?.debug('evicting idle Codex ACP session', { channelId });
|
|
1067
|
+
this.closeSession(session);
|
|
1068
|
+
}
|
|
1069
|
+
this.channels.delete(channelId);
|
|
1070
|
+
}
|
|
1071
|
+
async shutdownBackend(error) {
|
|
1072
|
+
if (this.backendClosed) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
this.backendClosed = true;
|
|
1076
|
+
const connection = this.connection;
|
|
1077
|
+
const child = this.child;
|
|
1078
|
+
await this.waitForPendingSessionStarts();
|
|
1079
|
+
await this.waitForPendingSessionCloses();
|
|
1080
|
+
connection?.close(error);
|
|
1081
|
+
this.connection = undefined;
|
|
1082
|
+
if (!child) {
|
|
1083
|
+
this.child = undefined;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const childExitPromise = this.childExitPromise ?? Promise.resolve();
|
|
1087
|
+
this.logger?.debug('sending SIGTERM to Codex ACP process');
|
|
1088
|
+
child.kill('SIGTERM');
|
|
1089
|
+
const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
|
|
1090
|
+
if (exitedAfterTerm) {
|
|
1091
|
+
this.logger?.debug('Codex ACP process exited after SIGTERM');
|
|
1092
|
+
this.child = undefined;
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
this.logger?.debug('sending SIGKILL to Codex ACP process after SIGTERM grace timeout');
|
|
1096
|
+
child.kill('SIGKILL');
|
|
1097
|
+
await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
|
|
1098
|
+
this.child = undefined;
|
|
1099
|
+
}
|
|
1100
|
+
async waitForPendingSessionStarts() {
|
|
1101
|
+
if (this.pendingSessionStarts.size === 0) {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
await Promise.race([
|
|
1105
|
+
Promise.allSettled([...this.pendingSessionStarts]).then(() => undefined),
|
|
1106
|
+
new Promise((resolveDelay) => {
|
|
1107
|
+
setTimeout(resolveDelay, this.runtime.shutdownGracePeriodMs);
|
|
1108
|
+
}),
|
|
1109
|
+
]);
|
|
1110
|
+
}
|
|
1111
|
+
async waitForPendingSessionCloses() {
|
|
1112
|
+
if (this.pendingSessionCloses.size === 0) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
await Promise.race([
|
|
1116
|
+
Promise.allSettled([...this.pendingSessionCloses]).then(() => undefined),
|
|
1117
|
+
new Promise((resolveDelay) => {
|
|
1118
|
+
setTimeout(resolveDelay, this.runtime.shutdownGracePeriodMs);
|
|
1119
|
+
}),
|
|
1120
|
+
]);
|
|
1121
|
+
}
|
|
1122
|
+
async waitForChildExit(childExitPromise, timeoutMs) {
|
|
1123
|
+
if (timeoutMs <= 0) {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
return Promise.race([
|
|
1127
|
+
childExitPromise.then(() => true),
|
|
1128
|
+
new Promise((resolveDelay) => {
|
|
1129
|
+
setTimeout(() => resolveDelay(false), timeoutMs);
|
|
1130
|
+
}),
|
|
1131
|
+
]);
|
|
1132
|
+
}
|
|
1133
|
+
}
|