@bridge4dev/runner 0.11.0
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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/adapters/claude.d.ts +19 -0
- package/dist/adapters/claude.js +631 -0
- package/dist/adapters/codex-home.d.ts +61 -0
- package/dist/adapters/codex-home.js +234 -0
- package/dist/adapters/codex-protocol.d.ts +59 -0
- package/dist/adapters/codex-protocol.js +204 -0
- package/dist/adapters/codex.d.ts +61 -0
- package/dist/adapters/codex.js +1406 -0
- package/dist/adapters/types.d.ts +183 -0
- package/dist/adapters/types.js +5 -0
- package/dist/async-queue.d.ts +11 -0
- package/dist/async-queue.js +50 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +149 -0
- package/dist/auth-relay.d.ts +57 -0
- package/dist/auth-relay.js +289 -0
- package/dist/config.d.ts +96 -0
- package/dist/config.js +73 -0
- package/dist/fsview.d.ts +20 -0
- package/dist/fsview.js +122 -0
- package/dist/git.d.ts +54 -0
- package/dist/git.js +168 -0
- package/dist/gitops.d.ts +136 -0
- package/dist/gitops.js +596 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +352 -0
- package/dist/journal.d.ts +118 -0
- package/dist/journal.js +300 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +19 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +33 -0
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +272 -0
- package/dist/protocol.d.ts +754 -0
- package/dist/protocol.js +154 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +221 -0
- package/dist/status-file.d.ts +14 -0
- package/dist/status-file.js +29 -0
- package/dist/supervisor.d.ts +216 -0
- package/dist/supervisor.js +1648 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/ws-client.d.ts +30 -0
- package/dist/ws-client.js +171 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
import { AsyncQueue } from '../async-queue.js';
|
|
2
|
+
import { log } from '../log.js';
|
|
3
|
+
import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
|
|
4
|
+
import { RUNNER_VERSION } from '../version.js';
|
|
5
|
+
import { repairCodexAuth } from './codex-home.js';
|
|
6
|
+
import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
|
|
7
|
+
import { truncate } from './claude.js';
|
|
8
|
+
import { AGENT_MODES, } from './types.js';
|
|
9
|
+
// Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
|
|
10
|
+
// contract is unchanged, so the dashboard renders Codex sessions with the same
|
|
11
|
+
// components it already uses for Claude.
|
|
12
|
+
//
|
|
13
|
+
// Everything below is anchored to a live probe of codex-cli 0.135.0
|
|
14
|
+
// (2026-07-25). The generated bindings alone are not enough: `collaborationMode`
|
|
15
|
+
// (plan mode) is missing from them entirely, `ModeKind` understates its own
|
|
16
|
+
// variants, and several response fields are undocumented.
|
|
17
|
+
const CODEX_BIN = 'codex';
|
|
18
|
+
/** Server-side event suppression — the cheapest defence against delta floods. */
|
|
19
|
+
const OPT_OUT_NOTIFICATIONS = [
|
|
20
|
+
'item/reasoning/textDelta',
|
|
21
|
+
'item/reasoning/summaryTextDelta',
|
|
22
|
+
'item/agentMessage/delta',
|
|
23
|
+
'item/plan/delta',
|
|
24
|
+
'item/commandExecution/outputDelta',
|
|
25
|
+
'account/rateLimits/updated',
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* `on-request` is the only policy that lets the agent ASK.
|
|
29
|
+
*
|
|
30
|
+
* The alternatives were tried live and all fail us:
|
|
31
|
+
* - `untrusted` produces no approvals at all and hard-rejects escalation;
|
|
32
|
+
* - `{granular:{…}}` looks like finer-grained control, and its post-failure
|
|
33
|
+
* "retry without sandbox?" prompt does work — but a *proactive* escalation
|
|
34
|
+
* request is rejected outright by codex itself:
|
|
35
|
+
* "reject command — you cannot ask for escalated permissions if the approval
|
|
36
|
+
* policy is Granular(…)". A Codex session in that mode could edit files but
|
|
37
|
+
* never commit, and told the user its escalation was "declined
|
|
38
|
+
* automatically" with nothing on screen to approve;
|
|
39
|
+
* - `never` skips approvals entirely, which also bypasses the layer-1 policy —
|
|
40
|
+
* acceptable only for the explicitly unrestricted mode.
|
|
41
|
+
*/
|
|
42
|
+
const ASK_POLICY = 'on-request';
|
|
43
|
+
/**
|
|
44
|
+
* Normalized mode → Codex policy. Codex splits "do I ask?" across two axes: the
|
|
45
|
+
* approval policy and the sandbox. A write inside a `workspace-write` sandbox
|
|
46
|
+
* never escalates (verified live), so `ask` deliberately runs read-only — that
|
|
47
|
+
* is what makes every mutation produce a card, matching Claude's `default`.
|
|
48
|
+
*
|
|
49
|
+
* Plan mode is prompt-level, not sandbox-level, so it is paired with read-only
|
|
50
|
+
* to make "propose, don't touch" an actual guarantee.
|
|
51
|
+
*/
|
|
52
|
+
const MODE_POLICY = {
|
|
53
|
+
ask: { approvalPolicy: ASK_POLICY, sandbox: 'read-only', plan: false },
|
|
54
|
+
plan: { approvalPolicy: ASK_POLICY, sandbox: 'read-only', plan: true },
|
|
55
|
+
auto: { approvalPolicy: ASK_POLICY, sandbox: 'workspace-write', plan: false },
|
|
56
|
+
// The owner's explicit call (2026-07-24): "full" restricts nothing. Codex
|
|
57
|
+
// sends no approvals at all in this mode, so layer 1 cannot gate tools —
|
|
58
|
+
// exactly like Claude's bypassPermissions. The dashboard says so.
|
|
59
|
+
full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
|
|
60
|
+
};
|
|
61
|
+
const SYSTEM_APPEND = [
|
|
62
|
+
'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
|
|
63
|
+
'Rules:',
|
|
64
|
+
'- Work ONLY inside the current working directory (a dedicated git worktree on a session branch).',
|
|
65
|
+
'- Commit your work in the current branch with clear messages. NEVER push to main/master and never force-push.',
|
|
66
|
+
'- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
|
|
67
|
+
'- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
|
|
68
|
+
'- Never print secrets (tokens, API keys, private keys) in your output.',
|
|
69
|
+
].join('\n');
|
|
70
|
+
// Allowlist, not denylist: whatever secrets live in the daemon's environment
|
|
71
|
+
// must not reach the agent process. OPENAI_API_KEY is absent by design — it
|
|
72
|
+
// would override the subscription login the same way ANTHROPIC_API_KEY does for
|
|
73
|
+
// Claude.
|
|
74
|
+
const ENV_ALLOWLIST = [
|
|
75
|
+
'PATH',
|
|
76
|
+
'HOME',
|
|
77
|
+
'USER',
|
|
78
|
+
'LOGNAME',
|
|
79
|
+
'SHELL',
|
|
80
|
+
'LANG',
|
|
81
|
+
'LANGUAGE',
|
|
82
|
+
'LC_ALL',
|
|
83
|
+
'LC_CTYPE',
|
|
84
|
+
'TERM',
|
|
85
|
+
'TMPDIR',
|
|
86
|
+
'TZ',
|
|
87
|
+
'COLORTERM',
|
|
88
|
+
'XDG_RUNTIME_DIR',
|
|
89
|
+
'HTTP_PROXY',
|
|
90
|
+
'HTTPS_PROXY',
|
|
91
|
+
'NO_PROXY',
|
|
92
|
+
'http_proxy',
|
|
93
|
+
'https_proxy',
|
|
94
|
+
'no_proxy',
|
|
95
|
+
'SSL_CERT_FILE',
|
|
96
|
+
'SSL_CERT_DIR',
|
|
97
|
+
'NODE_EXTRA_CA_CERTS',
|
|
98
|
+
];
|
|
99
|
+
function scrubbedEnv(codexHome) {
|
|
100
|
+
const env = {};
|
|
101
|
+
for (const key of ENV_ALLOWLIST) {
|
|
102
|
+
const value = process.env[key];
|
|
103
|
+
if (value !== undefined)
|
|
104
|
+
env[key] = value;
|
|
105
|
+
}
|
|
106
|
+
env['CODEX_HOME'] = codexHome;
|
|
107
|
+
return env;
|
|
108
|
+
}
|
|
109
|
+
/** Approval kinds we answer, mapped to the policy tool they resemble. */
|
|
110
|
+
const APPROVAL_METHODS = new Set([
|
|
111
|
+
'item/commandExecution/requestApproval',
|
|
112
|
+
'item/fileChange/requestApproval',
|
|
113
|
+
'item/permissions/requestApproval',
|
|
114
|
+
'mcpServer/elicitation/request',
|
|
115
|
+
]);
|
|
116
|
+
/** An unanswered agent question must not hold the turn forever. */
|
|
117
|
+
const QUESTION_TIMEOUT_MS = 30 * 60_000;
|
|
118
|
+
class CodexSession {
|
|
119
|
+
spec;
|
|
120
|
+
home;
|
|
121
|
+
output = new AsyncQueue();
|
|
122
|
+
client;
|
|
123
|
+
approvals = new Map();
|
|
124
|
+
/** Last known state of each item — approval params alone are too thin. */
|
|
125
|
+
items = new Map();
|
|
126
|
+
seenNotices = new Set();
|
|
127
|
+
queuedInput = [];
|
|
128
|
+
/** Null when the home was injected — an injected home is the whole truth. */
|
|
129
|
+
repairHome;
|
|
130
|
+
question = null;
|
|
131
|
+
threadId = null;
|
|
132
|
+
threadModel = null;
|
|
133
|
+
activeTurnId = null;
|
|
134
|
+
/** Plan card waiting for the user; approving it starts the real work. */
|
|
135
|
+
heldPlan = null;
|
|
136
|
+
lastCollabMode = null;
|
|
137
|
+
mode;
|
|
138
|
+
model;
|
|
139
|
+
effort;
|
|
140
|
+
/** Last model catalogue from `model/list` — effort sets differ per model. */
|
|
141
|
+
knownModels = [];
|
|
142
|
+
stopped = false;
|
|
143
|
+
ready = false;
|
|
144
|
+
capabilitiesInFlight = false;
|
|
145
|
+
events = this.output;
|
|
146
|
+
constructor(spec, home, deps) {
|
|
147
|
+
this.spec = spec;
|
|
148
|
+
this.home = home;
|
|
149
|
+
this.mode = spec.mode ?? 'ask';
|
|
150
|
+
this.model = spec.model;
|
|
151
|
+
this.effort = spec.effort;
|
|
152
|
+
this.repairHome = deps.repairHome ?? (deps.codexHome ? null : repairCodexAuth);
|
|
153
|
+
const wiring = {
|
|
154
|
+
env: scrubbedEnv(home.path),
|
|
155
|
+
onNotification: (method, params) => this.onNotification(method, params),
|
|
156
|
+
onServerRequest: (request) => this.onServerRequest(request),
|
|
157
|
+
onExit: (info) => this.onExit(info),
|
|
158
|
+
onStderr: (text) => this.onStderr(text),
|
|
159
|
+
};
|
|
160
|
+
this.client = deps.spawnClient
|
|
161
|
+
? deps.spawnClient(wiring)
|
|
162
|
+
: new AppServerClient({
|
|
163
|
+
command: CODEX_BIN,
|
|
164
|
+
args: ['app-server'],
|
|
165
|
+
cwd: spec.cwd,
|
|
166
|
+
...wiring,
|
|
167
|
+
});
|
|
168
|
+
void this.boot();
|
|
169
|
+
}
|
|
170
|
+
// ─── Boot ──────────────────────────────────────────────────────────
|
|
171
|
+
async boot() {
|
|
172
|
+
try {
|
|
173
|
+
const init = asRecord(await this.client.request('initialize', {
|
|
174
|
+
clientInfo: {
|
|
175
|
+
name: 'devbridge-runner',
|
|
176
|
+
title: 'DevBridge',
|
|
177
|
+
version: RUNNER_VERSION,
|
|
178
|
+
},
|
|
179
|
+
// experimentalApi gates plan mode AND granular approvals — without it
|
|
180
|
+
// both come back as -32600. The initialize result does not echo
|
|
181
|
+
// capabilities, so there is no way to confirm the opt-in except by
|
|
182
|
+
// using a gated field.
|
|
183
|
+
capabilities: {
|
|
184
|
+
experimentalApi: true,
|
|
185
|
+
requestAttestation: false,
|
|
186
|
+
optOutNotificationMethods: OPT_OUT_NOTIFICATIONS,
|
|
187
|
+
},
|
|
188
|
+
}));
|
|
189
|
+
const reportedHome = str(init['codexHome']);
|
|
190
|
+
if (reportedHome !== this.home.path) {
|
|
191
|
+
// Refuse rather than run against the host user's config: theirs can
|
|
192
|
+
// carry approval_policy="never", danger-full-access and their own MCP
|
|
193
|
+
// key. This is a hard stop, not a warning — and it is fail-CLOSED: a
|
|
194
|
+
// build that stops reporting the field must not silently disable the
|
|
195
|
+
// check (QA-100 MINOR-1).
|
|
196
|
+
throw new Error(`codex is using an unexpected CODEX_HOME (${reportedHome ?? 'not reported'}) — refusing to start the session`);
|
|
197
|
+
}
|
|
198
|
+
this.client.notify('initialized', {});
|
|
199
|
+
if (this.home.auth === 'missing') {
|
|
200
|
+
// One repair attempt before telling the user their login is broken: the
|
|
201
|
+
// credential link can be removed under a running daemon, and putting it
|
|
202
|
+
// back is cheaper — and far less confusing — than a sign-in prompt for
|
|
203
|
+
// an account that never expired.
|
|
204
|
+
const repaired = this.repairHome?.() ?? this.home;
|
|
205
|
+
if (repaired.auth === 'missing') {
|
|
206
|
+
this.emit({
|
|
207
|
+
type: 'error',
|
|
208
|
+
message: 'Codex is not signed in on this server — sign in from the Server panel',
|
|
209
|
+
// `missing`, not `expired`: nothing expired, there is simply no login.
|
|
210
|
+
code: 'auth_missing',
|
|
211
|
+
});
|
|
212
|
+
this.finish();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
this.home = repaired;
|
|
216
|
+
log.info('codex: credential was missing and has been restored', {
|
|
217
|
+
sessionId: this.spec.sessionId,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const thread = await this.openThread();
|
|
221
|
+
if (this.stopped)
|
|
222
|
+
return;
|
|
223
|
+
this.threadId = thread.id;
|
|
224
|
+
this.threadModel = thread.model;
|
|
225
|
+
this.ready = true;
|
|
226
|
+
this.emit({
|
|
227
|
+
type: 'provider_session',
|
|
228
|
+
providerSessionId: thread.id,
|
|
229
|
+
...(thread.model ? { model: thread.model } : {}),
|
|
230
|
+
});
|
|
231
|
+
this.refreshCapabilities();
|
|
232
|
+
const initial = this.spec.prompt?.trim();
|
|
233
|
+
if (initial) {
|
|
234
|
+
this.startTurn(initial);
|
|
235
|
+
}
|
|
236
|
+
this.flushQueued();
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
// Before finish(): it ends the output queue, so emitting after it would
|
|
240
|
+
// drop the error entirely and the session would just stop with no reason.
|
|
241
|
+
this.classifyAndEmitFailure(error);
|
|
242
|
+
this.finish();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* How long to wait before the second `thread/resume` attempt.
|
|
247
|
+
*
|
|
248
|
+
* QA-101 left this open: resuming the very same thread with the very same
|
|
249
|
+
* parameters succeeds in isolation, so the rejection seen after a fast
|
|
250
|
+
* stop→continue is transient — the previous agent process is still holding
|
|
251
|
+
* the rollout file when the new one asks for it. One short retry costs a
|
|
252
|
+
* second; losing the conversation costs the whole context.
|
|
253
|
+
*/
|
|
254
|
+
static RESUME_RETRY_DELAY_MS = 1_500;
|
|
255
|
+
async resumeThread(resumeId) {
|
|
256
|
+
// Resume takes the SAME overrides as start, and it must: the per-thread MCP
|
|
257
|
+
// overlay lives only in memory, so resuming with just a thread id brings the
|
|
258
|
+
// conversation back without DevBridge access — the agent then hunts for
|
|
259
|
+
// tickets it can no longer reach (found in the live check).
|
|
260
|
+
const result = asRecord(await this.client.request('thread/resume', {
|
|
261
|
+
threadId: resumeId,
|
|
262
|
+
...this.threadParams(),
|
|
263
|
+
}));
|
|
264
|
+
const thread = asRecord(result['thread']);
|
|
265
|
+
const id = str(thread['id']);
|
|
266
|
+
if (!id)
|
|
267
|
+
throw new Error('thread/resume returned no thread id');
|
|
268
|
+
return { id, model: str(result['model']) ?? null };
|
|
269
|
+
}
|
|
270
|
+
async openThread() {
|
|
271
|
+
const resumeId = this.spec.resumeProviderSessionId;
|
|
272
|
+
if (resumeId) {
|
|
273
|
+
try {
|
|
274
|
+
return await this.resumeThread(resumeId);
|
|
275
|
+
}
|
|
276
|
+
catch (firstError) {
|
|
277
|
+
let error = firstError;
|
|
278
|
+
// Matched on the message rather than the error class: the same failure
|
|
279
|
+
// can arrive as an RPC error or as a transport error, and a missed match
|
|
280
|
+
// would fail the session instead of retrying it.
|
|
281
|
+
if (isMissingRollout(error)) {
|
|
282
|
+
log.warn('codex: thread/resume rejected — retrying once', {
|
|
283
|
+
sessionId: this.spec.sessionId,
|
|
284
|
+
threadId: resumeId,
|
|
285
|
+
error: maskString(describe(error)).slice(0, 300),
|
|
286
|
+
});
|
|
287
|
+
await delay(CodexSession.RESUME_RETRY_DELAY_MS);
|
|
288
|
+
try {
|
|
289
|
+
const resumed = await this.resumeThread(resumeId);
|
|
290
|
+
log.info('codex: thread/resume succeeded on retry', {
|
|
291
|
+
sessionId: this.spec.sessionId,
|
|
292
|
+
});
|
|
293
|
+
return resumed;
|
|
294
|
+
}
|
|
295
|
+
catch (retryError) {
|
|
296
|
+
error = retryError;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (isMissingRollout(error)) {
|
|
300
|
+
// Losing the conversation is expensive, so record WHY. Verified live
|
|
301
|
+
// (2026-07-25): resuming this exact thread with these exact params
|
|
302
|
+
// succeeds in isolation, so a failure here is transient — most likely
|
|
303
|
+
// the previous agent process still holding the thread during a rapid
|
|
304
|
+
// stop→continue cycle. Without this line the only trace is a feed
|
|
305
|
+
// notice that says the context is gone and nothing about the cause.
|
|
306
|
+
log.warn('codex: thread/resume rejected — falling back to a fresh thread', {
|
|
307
|
+
sessionId: this.spec.sessionId,
|
|
308
|
+
threadId: resumeId,
|
|
309
|
+
error: maskString(describe(error)).slice(0, 300),
|
|
310
|
+
});
|
|
311
|
+
// The supervisor relaunches once without a resume id; the git worktree
|
|
312
|
+
// still holds every change the previous process made.
|
|
313
|
+
throw new ResumeFailed(describe(error));
|
|
314
|
+
}
|
|
315
|
+
log.warn('codex: thread/resume failed', {
|
|
316
|
+
sessionId: this.spec.sessionId,
|
|
317
|
+
error: maskString(describe(error)).slice(0, 300),
|
|
318
|
+
});
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const params = this.threadParams();
|
|
323
|
+
try {
|
|
324
|
+
return this.readThread(await this.client.request('thread/start', params));
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
// Future-proofing: if the experimental capability is ever renamed, fall
|
|
328
|
+
// back to the plain policy rather than failing the session outright.
|
|
329
|
+
if (/experimentalApi/i.test(describe(error))) {
|
|
330
|
+
this.notice('warn', 'This codex build rejected granular approvals — using on-request.');
|
|
331
|
+
return this.readThread(await this.client.request('thread/start', { ...params, approvalPolicy: 'on-request' }));
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Everything a thread needs, whether it is being started or resumed. */
|
|
337
|
+
threadParams() {
|
|
338
|
+
const policy = MODE_POLICY[this.mode];
|
|
339
|
+
return {
|
|
340
|
+
cwd: this.spec.cwd,
|
|
341
|
+
approvalPolicy: policy.approvalPolicy,
|
|
342
|
+
sandbox: policy.sandbox,
|
|
343
|
+
developerInstructions: SYSTEM_APPEND,
|
|
344
|
+
...(this.model ? { model: this.model } : {}),
|
|
345
|
+
...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
readThread(raw) {
|
|
349
|
+
const result = asRecord(raw);
|
|
350
|
+
const thread = asRecord(result['thread']);
|
|
351
|
+
const id = str(thread['id']);
|
|
352
|
+
if (!id)
|
|
353
|
+
throw new Error('thread/start returned no thread id');
|
|
354
|
+
return { id, model: str(result['model']) ?? null };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The DevBridge MCP server, injected per thread and never written to disk.
|
|
358
|
+
* The key travels in an Authorization header rather than the URL query string
|
|
359
|
+
* so it does not land in the MCP gateway's access logs.
|
|
360
|
+
*/
|
|
361
|
+
mcpOverlay() {
|
|
362
|
+
const mcp = this.spec.mcp;
|
|
363
|
+
if (!mcp)
|
|
364
|
+
return {};
|
|
365
|
+
return {
|
|
366
|
+
mcp_servers: {
|
|
367
|
+
devbridge: {
|
|
368
|
+
url: mcp.url,
|
|
369
|
+
http_headers: { Authorization: `Bearer ${mcp.token}` },
|
|
370
|
+
// Reading and updating tickets is what the session is FOR — the same
|
|
371
|
+
// call is unconditionally allowed by the layer-1 policy, so asking
|
|
372
|
+
// per call would be noise (and the live check confirmed it is).
|
|
373
|
+
default_tools_approval_mode: 'approve',
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// ─── Turns ─────────────────────────────────────────────────────────
|
|
379
|
+
startTurn(text) {
|
|
380
|
+
if (!this.threadId || this.stopped)
|
|
381
|
+
return;
|
|
382
|
+
const policy = MODE_POLICY[this.mode];
|
|
383
|
+
const wantCollab = policy.plan ? 'plan' : 'default';
|
|
384
|
+
const params = {
|
|
385
|
+
threadId: this.threadId,
|
|
386
|
+
// text_elements is required by the bindings but defaulted on the wire;
|
|
387
|
+
// [] is accepted, null is rejected outright.
|
|
388
|
+
input: [{ type: 'text', text, text_elements: [] }],
|
|
389
|
+
approvalPolicy: policy.approvalPolicy,
|
|
390
|
+
sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec.cwd),
|
|
391
|
+
...(this.model ? { model: this.model } : {}),
|
|
392
|
+
// "Override the reasoning effort for this turn and subsequent turns" —
|
|
393
|
+
// the same sticky-override channel the model uses (there is still no
|
|
394
|
+
// settings/update in this protocol version).
|
|
395
|
+
...(this.effort ? { effort: this.effort } : {}),
|
|
396
|
+
};
|
|
397
|
+
// Collaboration mode is turn-scoped but sticky, and its `settings` REPLACE
|
|
398
|
+
// the thread's developer instructions — so only send it on a real change,
|
|
399
|
+
// and restore our own rules when leaving plan mode.
|
|
400
|
+
if (this.lastCollabMode !== wantCollab) {
|
|
401
|
+
params['collaborationMode'] = {
|
|
402
|
+
mode: wantCollab,
|
|
403
|
+
settings: {
|
|
404
|
+
model: this.model ?? this.threadModel ?? 'gpt-5.5',
|
|
405
|
+
reasoning_effort: null,
|
|
406
|
+
developer_instructions: wantCollab === 'plan' ? null : SYSTEM_APPEND,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
this.lastCollabMode = wantCollab;
|
|
410
|
+
}
|
|
411
|
+
void this.client
|
|
412
|
+
.request('turn/start', params)
|
|
413
|
+
.then((raw) => {
|
|
414
|
+
// turn/start is non-blocking and returns the turn id immediately —
|
|
415
|
+
// that id is what turn/interrupt needs.
|
|
416
|
+
const turn = asRecord(asRecord(raw)['turn']);
|
|
417
|
+
this.activeTurnId = str(turn['id']) ?? this.activeTurnId;
|
|
418
|
+
})
|
|
419
|
+
.catch((error) => {
|
|
420
|
+
this.emit({ type: 'turn_end', ok: false, errorMessage: describe(error) });
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
flushQueued() {
|
|
424
|
+
const queued = this.queuedInput.splice(0);
|
|
425
|
+
if (queued.length === 0)
|
|
426
|
+
return;
|
|
427
|
+
this.startTurn(queued.join('\n\n'));
|
|
428
|
+
}
|
|
429
|
+
send(text) {
|
|
430
|
+
if (this.stopped) {
|
|
431
|
+
log.warn('codex: send after session ended — message dropped', {
|
|
432
|
+
sessionId: this.spec.sessionId,
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// A parked elicitation takes priority: the agent is blocked on it, and
|
|
437
|
+
// starting a turn would deadlock behind the open request.
|
|
438
|
+
if (this.question) {
|
|
439
|
+
this.answerQuestion(text);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// Answering a plan card by typing instead of clicking is normal ("do it
|
|
443
|
+
// differently"). Without this the held plan stayed forever and
|
|
444
|
+
// onTurnCompleted swallowed every later turn_end — the session hung in
|
|
445
|
+
// RUNNING with no way out but Stop (QA-100 MAJOR-2).
|
|
446
|
+
if (this.heldPlan) {
|
|
447
|
+
const { requestId } = this.heldPlan;
|
|
448
|
+
this.heldPlan = null;
|
|
449
|
+
this.emit({
|
|
450
|
+
type: 'permission_resolved',
|
|
451
|
+
requestId,
|
|
452
|
+
allow: false,
|
|
453
|
+
source: 'user',
|
|
454
|
+
reason: 'Answered with a message instead of approving the plan',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// Approving the plan is the moment the agent may actually work.
|
|
458
|
+
if (!this.ready) {
|
|
459
|
+
this.queuedInput.push(text);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (this.activeTurnId) {
|
|
463
|
+
void this.steer(text);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.startTurn(text);
|
|
467
|
+
}
|
|
468
|
+
async steer(text) {
|
|
469
|
+
if (!this.threadId || !this.activeTurnId)
|
|
470
|
+
return this.startTurn(text);
|
|
471
|
+
try {
|
|
472
|
+
await this.client.request('turn/steer', {
|
|
473
|
+
threadId: this.threadId,
|
|
474
|
+
input: [{ type: 'text', text, text_elements: [] }],
|
|
475
|
+
expectedTurnId: this.activeTurnId,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// The steerable window is narrower than "a turn exists"; fall back to a
|
|
480
|
+
// fresh turn once the current one settles.
|
|
481
|
+
this.queuedInput.push(text);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async setModel(model) {
|
|
485
|
+
this.model = model;
|
|
486
|
+
// No context-meter refresh here, unlike Claude — and that is not an
|
|
487
|
+
// oversight. The app-server pushes token usage (`thread/tokenUsage/updated`)
|
|
488
|
+
// and offers no way to ask for it, and every model codex currently lists
|
|
489
|
+
// reports the same 258 400-token effective window (272 000 × 95 %), so a
|
|
490
|
+
// switch cannot change the denominator anyway.
|
|
491
|
+
//
|
|
492
|
+
// There is no settings/update in this protocol version: the model is a
|
|
493
|
+
// turn/start override that sticks for subsequent turns.
|
|
494
|
+
this.emit({ type: 'settings', model });
|
|
495
|
+
// Models advertise different effort sets (Sol/Terra add max/ultra) — an
|
|
496
|
+
// effort the new model does not support would be rejected on turn/start,
|
|
497
|
+
// so drop it and let the model's own default take over.
|
|
498
|
+
if (this.effort) {
|
|
499
|
+
const supported = this.knownModels.find((m) => m.id === model)?.efforts;
|
|
500
|
+
if (supported && !supported.some((option) => option.id === this.effort)) {
|
|
501
|
+
delete this.effort;
|
|
502
|
+
// Tell everyone, not just ourselves: the API stores the pinned level
|
|
503
|
+
// and would otherwise hand the dead one back on the next relaunch,
|
|
504
|
+
// where codex rejects it and the turn fails (QA-100 MAJOR-5).
|
|
505
|
+
this.emit({ type: 'settings', effort: null });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// The refreshed capabilities carry currentEffort (the model's default when
|
|
509
|
+
// we just dropped an unsupported pick), so the UI dial stays truthful.
|
|
510
|
+
this.refreshCapabilities();
|
|
511
|
+
}
|
|
512
|
+
async setEffort(effort) {
|
|
513
|
+
if (effort)
|
|
514
|
+
this.effort = effort;
|
|
515
|
+
else
|
|
516
|
+
delete this.effort;
|
|
517
|
+
this.emit({ type: 'settings', effort });
|
|
518
|
+
this.refreshCapabilities();
|
|
519
|
+
}
|
|
520
|
+
async setMode(mode) {
|
|
521
|
+
this.mode = mode;
|
|
522
|
+
this.emit({ type: 'settings', mode });
|
|
523
|
+
}
|
|
524
|
+
async interrupt() {
|
|
525
|
+
if (!this.threadId || !this.activeTurnId)
|
|
526
|
+
return;
|
|
527
|
+
try {
|
|
528
|
+
await this.client.request('turn/interrupt', {
|
|
529
|
+
threadId: this.threadId,
|
|
530
|
+
turnId: this.activeTurnId,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (/no active turn/i.test(describe(error)))
|
|
535
|
+
return;
|
|
536
|
+
log.warn('codex: interrupt failed', { error: describe(error) });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
stop() {
|
|
540
|
+
if (this.stopped)
|
|
541
|
+
return;
|
|
542
|
+
this.stopped = true;
|
|
543
|
+
// Release anything the agent is blocked on so the child can exit cleanly.
|
|
544
|
+
for (const [requestId, pending] of [...this.approvals]) {
|
|
545
|
+
this.approvals.delete(requestId);
|
|
546
|
+
this.client.respond(pending.rpcId, { decision: 'cancel' });
|
|
547
|
+
}
|
|
548
|
+
if (this.question) {
|
|
549
|
+
if (this.question.timer)
|
|
550
|
+
clearTimeout(this.question.timer);
|
|
551
|
+
this.client.respond(this.question.rpcId, { answers: {} });
|
|
552
|
+
this.question = null;
|
|
553
|
+
}
|
|
554
|
+
this.client.kill();
|
|
555
|
+
}
|
|
556
|
+
// ─── Server requests (approvals, questions) ────────────────────────
|
|
557
|
+
onServerRequest(request) {
|
|
558
|
+
if (request.method === 'item/tool/requestUserInput') {
|
|
559
|
+
this.onQuestion(request);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (APPROVAL_METHODS.has(request.method)) {
|
|
563
|
+
this.onApproval(request);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// Never leave a server request hanging: an unanswered one blocks the turn
|
|
567
|
+
// forever (there is no server-side timeout).
|
|
568
|
+
log.warn('codex: unhandled server request', { method: request.method });
|
|
569
|
+
this.client.respondError(request.id, -32601, 'unsupported by DevBridge runner');
|
|
570
|
+
}
|
|
571
|
+
onApproval(request) {
|
|
572
|
+
const requestId = String(request.id);
|
|
573
|
+
const enriched = this.describeApproval(request);
|
|
574
|
+
const verdict = enriched.forceAsk
|
|
575
|
+
? { decision: 'ask', reason: 'details unavailable' }
|
|
576
|
+
: evaluateToolUse(enriched.policyTool, enriched.policyInput, {
|
|
577
|
+
trustMode: this.spec.trustMode,
|
|
578
|
+
worktreePath: this.spec.cwd,
|
|
579
|
+
});
|
|
580
|
+
if (verdict.decision === 'allow') {
|
|
581
|
+
this.client.respond(request.id, { decision: 'accept' });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (verdict.decision === 'deny') {
|
|
585
|
+
this.client.respond(request.id, { decision: 'decline' });
|
|
586
|
+
this.emit({
|
|
587
|
+
type: 'permission_resolved',
|
|
588
|
+
requestId,
|
|
589
|
+
allow: false,
|
|
590
|
+
source: 'policy',
|
|
591
|
+
reason: verdict.reason,
|
|
592
|
+
});
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
this.approvals.set(requestId, { rpcId: request.id, toolName: enriched.toolName });
|
|
596
|
+
this.emit({
|
|
597
|
+
type: 'permission',
|
|
598
|
+
requestId,
|
|
599
|
+
toolName: enriched.toolName,
|
|
600
|
+
title: enriched.title,
|
|
601
|
+
...(enriched.description ? { description: enriched.description } : {}),
|
|
602
|
+
input: enriched.input,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Approval params are anaemic — `item/fileChange/requestApproval` arrives as
|
|
607
|
+
* `{threadId, turnId, itemId, startedAtMs, reason, grantRoot}` with no paths
|
|
608
|
+
* and no diff. The card is only usable because we keep the last `item/started`
|
|
609
|
+
* payload for that itemId.
|
|
610
|
+
*/
|
|
611
|
+
describeApproval(request) {
|
|
612
|
+
const params = request.params;
|
|
613
|
+
const itemId = str(params['itemId']);
|
|
614
|
+
const cached = itemId ? asRecord(this.items.get(itemId)) : {};
|
|
615
|
+
const reason = str(params['reason']);
|
|
616
|
+
if (request.method === 'item/commandExecution/requestApproval') {
|
|
617
|
+
const command = str(params['command']) ?? str(cached['command']) ?? '';
|
|
618
|
+
const cwd = str(params['cwd']) ?? str(cached['cwd']);
|
|
619
|
+
return {
|
|
620
|
+
toolName: 'Bash',
|
|
621
|
+
title: 'Run a command?',
|
|
622
|
+
...(reason ? { description: reason } : {}),
|
|
623
|
+
input: { command: truncate(command, 2_000), ...(cwd ? { cwd } : {}) },
|
|
624
|
+
policyTool: 'Bash',
|
|
625
|
+
policyInput: { command },
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
if (request.method === 'item/fileChange/requestApproval') {
|
|
629
|
+
const changes = Array.isArray(cached['changes']) ? cached['changes'] : [];
|
|
630
|
+
const files = changes
|
|
631
|
+
.map((change) => str(asRecord(change)['path']))
|
|
632
|
+
.filter((file) => Boolean(file));
|
|
633
|
+
const diff = changes
|
|
634
|
+
.map((change) => str(asRecord(change)['diff']) ?? '')
|
|
635
|
+
.filter(Boolean)
|
|
636
|
+
.join('\n');
|
|
637
|
+
return {
|
|
638
|
+
toolName: 'Edit',
|
|
639
|
+
title: files.length === 1
|
|
640
|
+
? `Edit ${basename(files[0])}?`
|
|
641
|
+
: files.length
|
|
642
|
+
? `Edit ${files.length} files?`
|
|
643
|
+
: 'Codex asks to edit files (details unavailable)',
|
|
644
|
+
...(reason ? { description: reason } : {}),
|
|
645
|
+
input: {
|
|
646
|
+
...(files.length ? { files } : {}),
|
|
647
|
+
...(diff ? { diff: truncate(diff, 4_000) } : {}),
|
|
648
|
+
},
|
|
649
|
+
policyTool: 'Edit',
|
|
650
|
+
// Evaluate the first path; multi-file patches outside the worktree are
|
|
651
|
+
// caught because every path shares one root in practice. With NO path
|
|
652
|
+
// (item cache miss) we must NOT fall back to the worktree root: that
|
|
653
|
+
// reads as "an edit inside the worktree" and auto-allows the very
|
|
654
|
+
// write this approval exists to gate (QA-100 MAJOR-4). No path => ask.
|
|
655
|
+
policyInput: files.length ? { file_path: files[0] } : {},
|
|
656
|
+
...(files.length ? {} : { forceAsk: true }),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
if (request.method === 'mcpServer/elicitation/request') {
|
|
660
|
+
// MCP elicitations are mostly "may this server run tool X?". The raw
|
|
661
|
+
// params are unreadable — they carry the whole tool description — so the
|
|
662
|
+
// card shows the server's own message plus the call arguments, and the
|
|
663
|
+
// policy sees a proper `mcp__<server>__<tool>` name (which is what makes
|
|
664
|
+
// DevBridge's own tools pass without bothering the user).
|
|
665
|
+
const server = str(params['serverName']) ?? 'mcp';
|
|
666
|
+
const message = str(params['message']);
|
|
667
|
+
const meta = asRecord(params['_meta']);
|
|
668
|
+
const tool = str(meta['tool_name']) ?? toolNameFromMessage(message) ?? 'tool';
|
|
669
|
+
const args = asRecord(meta['tool_params']);
|
|
670
|
+
return {
|
|
671
|
+
toolName: `mcp__${server}__${tool}`,
|
|
672
|
+
title: message ?? `Allow the ${server} MCP server to run ${tool}?`,
|
|
673
|
+
input: Object.keys(args).length ? truncateRecord(args) : {},
|
|
674
|
+
policyTool: `mcp__${server}__${tool}`,
|
|
675
|
+
policyInput: {},
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
toolName: request.method,
|
|
680
|
+
title: 'Codex needs permission',
|
|
681
|
+
...(reason ? { description: reason } : {}),
|
|
682
|
+
input: truncateRecord(params),
|
|
683
|
+
policyTool: request.method,
|
|
684
|
+
policyInput: {},
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
answerPermission(requestId, allow, note) {
|
|
688
|
+
if (this.heldPlan && this.heldPlan.requestId === requestId) {
|
|
689
|
+
const plan = this.heldPlan;
|
|
690
|
+
this.heldPlan = null;
|
|
691
|
+
this.emit({
|
|
692
|
+
type: 'permission_resolved',
|
|
693
|
+
requestId,
|
|
694
|
+
allow,
|
|
695
|
+
source: 'user',
|
|
696
|
+
...(note ? { reason: note } : {}),
|
|
697
|
+
});
|
|
698
|
+
if (allow) {
|
|
699
|
+
// Leave plan mode and let the agent execute what it proposed — in
|
|
700
|
+
// "ask", never "auto": approving a PLAN must not also switch off the
|
|
701
|
+
// permission cards for every later edit (QA-100 MAJOR-3; Claude keeps
|
|
702
|
+
// its mode too). Unattended edits stay an explicit, separate choice.
|
|
703
|
+
this.mode = this.mode === 'plan' ? 'ask' : this.mode;
|
|
704
|
+
this.emit({ type: 'settings', mode: this.mode });
|
|
705
|
+
this.startTurn(note?.trim()
|
|
706
|
+
? `The plan is approved with this note: ${note}\n\nImplement it now.`
|
|
707
|
+
: 'The plan is approved. Implement it now.');
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
this.notice('info', 'Plan rejected — tell the agent what to change.');
|
|
711
|
+
void plan;
|
|
712
|
+
}
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const pending = this.approvals.get(requestId);
|
|
716
|
+
if (!pending) {
|
|
717
|
+
log.warn('codex: permission answer for unknown request', { requestId });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
this.approvals.delete(requestId);
|
|
721
|
+
this.emit({
|
|
722
|
+
type: 'permission_resolved',
|
|
723
|
+
requestId,
|
|
724
|
+
allow,
|
|
725
|
+
source: 'user',
|
|
726
|
+
...(note ? { reason: note } : {}),
|
|
727
|
+
});
|
|
728
|
+
this.client.respond(pending.rpcId, { decision: allow ? 'accept' : 'decline' });
|
|
729
|
+
}
|
|
730
|
+
onQuestion(request) {
|
|
731
|
+
const questions = Array.isArray(request.params['questions']) ? request.params['questions'] : [];
|
|
732
|
+
const first = asRecord(questions[0]);
|
|
733
|
+
const questionId = str(first['id']);
|
|
734
|
+
const text = str(first['question']) ?? str(first['header']);
|
|
735
|
+
if (!questionId || !text) {
|
|
736
|
+
// Nothing renderable — answer empty so the turn continues instead of
|
|
737
|
+
// blocking forever.
|
|
738
|
+
this.client.respond(request.id, { answers: {} });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const rawOptions = Array.isArray(first['options']) ? first['options'] : [];
|
|
742
|
+
const options = rawOptions
|
|
743
|
+
.map((option) => {
|
|
744
|
+
const row = asRecord(option);
|
|
745
|
+
const label = str(row['label']);
|
|
746
|
+
if (!label)
|
|
747
|
+
return null;
|
|
748
|
+
const description = str(row['description']);
|
|
749
|
+
return truncate(description ? `${label} — ${description}` : label, 300);
|
|
750
|
+
})
|
|
751
|
+
.filter((option) => Boolean(option))
|
|
752
|
+
.slice(0, 8);
|
|
753
|
+
// There is no server-side timeout for elicitations: an unanswered question
|
|
754
|
+
// blocks the turn forever and the only way out is Stop (QA-100 MINOR-4).
|
|
755
|
+
const timer = setTimeout(() => {
|
|
756
|
+
if (this.question?.rpcId !== request.id)
|
|
757
|
+
return;
|
|
758
|
+
this.question = null;
|
|
759
|
+
this.client.respond(request.id, { answers: {} });
|
|
760
|
+
this.notice('warn', 'The agent question went unanswered for 30 minutes — it was skipped.');
|
|
761
|
+
}, QUESTION_TIMEOUT_MS);
|
|
762
|
+
timer.unref();
|
|
763
|
+
this.question = { rpcId: request.id, questionId, timer };
|
|
764
|
+
this.emit({
|
|
765
|
+
type: 'question',
|
|
766
|
+
text: truncate(text, 2_000),
|
|
767
|
+
...(options.length ? { options } : {}),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* The user's next message answers the open elicitation. The wire wants the
|
|
772
|
+
* option label verbatim, and free text is legal whenever `isOther` is set —
|
|
773
|
+
* so we forward whatever was typed, and the option list the dashboard shows
|
|
774
|
+
* is made of those same labels.
|
|
775
|
+
*/
|
|
776
|
+
answerQuestion(text) {
|
|
777
|
+
const pending = this.question;
|
|
778
|
+
if (!pending)
|
|
779
|
+
return;
|
|
780
|
+
if (pending.timer)
|
|
781
|
+
clearTimeout(pending.timer);
|
|
782
|
+
this.question = null;
|
|
783
|
+
const answer = stripOptionHint(text);
|
|
784
|
+
this.client.respond(pending.rpcId, {
|
|
785
|
+
answers: { [pending.questionId]: { answers: [answer] } },
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
// ─── Notifications ─────────────────────────────────────────────────
|
|
789
|
+
onNotification(method, params) {
|
|
790
|
+
switch (method) {
|
|
791
|
+
case 'item/started':
|
|
792
|
+
case 'item/updated':
|
|
793
|
+
case 'item/completed': {
|
|
794
|
+
this.onItem(method, params);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
case 'turn/started': {
|
|
798
|
+
this.activeTurnId = str(params['turnId']) ?? str(asRecord(params['turn'])['id']) ?? null;
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
case 'turn/completed': {
|
|
802
|
+
this.onTurnCompleted(params);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
case 'thread/tokenUsage/updated': {
|
|
806
|
+
const usage = asRecord(params['tokenUsage']);
|
|
807
|
+
// `last` is the occupancy of the most recent model request; `total` is a
|
|
808
|
+
// lifetime billing counter that never drops (live: 87k "total" vs 17k
|
|
809
|
+
// real in the same turn) and would ship a meter that only goes up.
|
|
810
|
+
const used = num(asRecord(usage['last'])['totalTokens']);
|
|
811
|
+
const max = num(usage['modelContextWindow']);
|
|
812
|
+
if (used !== undefined && max !== undefined && max > 0) {
|
|
813
|
+
this.emit({ type: 'context_usage', usedTokens: used, maxTokens: max });
|
|
814
|
+
}
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
case 'thread/settings/updated': {
|
|
818
|
+
const collab = asRecord(params['collaborationMode']);
|
|
819
|
+
const model = str(params['model']);
|
|
820
|
+
const effort = str(params['effort']);
|
|
821
|
+
const modeKind = str(collab['mode']);
|
|
822
|
+
if (model && model !== this.model) {
|
|
823
|
+
this.model = model;
|
|
824
|
+
this.emit({ type: 'settings', model });
|
|
825
|
+
}
|
|
826
|
+
// Codex reports the effort it settled on (a `/model` command inside the
|
|
827
|
+
// session, or its own default) — mirror it instead of arguing.
|
|
828
|
+
if (effort && effort !== this.effort) {
|
|
829
|
+
this.effort = effort;
|
|
830
|
+
this.emit({ type: 'settings', effort });
|
|
831
|
+
}
|
|
832
|
+
if (modeKind === 'plan' && this.mode !== 'plan') {
|
|
833
|
+
this.mode = 'plan';
|
|
834
|
+
this.emit({ type: 'settings', mode: 'plan' });
|
|
835
|
+
}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
case 'serverRequest/resolved': {
|
|
839
|
+
// Resolved elsewhere (another client) — drop our pending entry. Report
|
|
840
|
+
// what actually happened when codex says so; claiming "allowed" for an
|
|
841
|
+
// unknown outcome would put a false approval in the audit trail
|
|
842
|
+
// (QA-100 MINOR-3).
|
|
843
|
+
const resolved = String(params['requestId'] ?? '');
|
|
844
|
+
if (this.approvals.delete(resolved)) {
|
|
845
|
+
const decision = str(params['decision']) ?? str(asRecord(params['response'])['decision']);
|
|
846
|
+
const allow = decision === 'accept' || decision === 'approved';
|
|
847
|
+
this.emit({
|
|
848
|
+
type: 'permission_resolved',
|
|
849
|
+
requestId: resolved,
|
|
850
|
+
allow,
|
|
851
|
+
source: 'user',
|
|
852
|
+
reason: decision
|
|
853
|
+
? `Answered elsewhere (${decision})`
|
|
854
|
+
: 'Answered elsewhere — outcome not reported by codex',
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
case 'error': {
|
|
860
|
+
const willRetry = params['willRetry'] === true;
|
|
861
|
+
if (willRetry)
|
|
862
|
+
return;
|
|
863
|
+
const detail = asRecord(params['error']);
|
|
864
|
+
this.classifyAndEmitFailure(new Error(str(detail['message']) ?? 'Codex reported an error'));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
case 'configWarning':
|
|
868
|
+
case 'warning':
|
|
869
|
+
case 'guardianWarning':
|
|
870
|
+
case 'deprecationNotice': {
|
|
871
|
+
// Each of these carries its text under a different key: `message` for
|
|
872
|
+
// warnings, `summary`/`details` for config and deprecation notices.
|
|
873
|
+
// Falling back to the method name printed a bare "configWarning" in the
|
|
874
|
+
// feed, which told the user nothing (found in the live check).
|
|
875
|
+
const summary = str(params['message']) ?? str(params['summary']);
|
|
876
|
+
const details = str(params['details']);
|
|
877
|
+
if (!summary && !details)
|
|
878
|
+
return;
|
|
879
|
+
this.notice('warn', [summary, details].filter(Boolean).join(' — '));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
case 'model/rerouted': {
|
|
883
|
+
const to = str(params['toModel']);
|
|
884
|
+
this.notice('info', `Codex rerouted this turn to ${to ?? 'another model'}`);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
case 'mcpServer/startupStatus/updated': {
|
|
888
|
+
const name = str(params['name']) ?? 'MCP server';
|
|
889
|
+
const status = str(params['status']);
|
|
890
|
+
if (status === 'failed') {
|
|
891
|
+
this.notice('warn', `MCP server ${name} failed to start — the agent cannot reach tickets`);
|
|
892
|
+
}
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
default:
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
onItem(method, params) {
|
|
900
|
+
const item = asRecord(params['item']);
|
|
901
|
+
const type = str(item['type']);
|
|
902
|
+
const id = str(item['id']);
|
|
903
|
+
if (!type)
|
|
904
|
+
return;
|
|
905
|
+
if (id) {
|
|
906
|
+
this.items.set(id, item);
|
|
907
|
+
// Bound the cache: a long session would otherwise hold every item.
|
|
908
|
+
if (this.items.size > 400) {
|
|
909
|
+
const oldest = this.items.keys().next().value;
|
|
910
|
+
if (oldest !== undefined)
|
|
911
|
+
this.items.delete(oldest);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const done = method === 'item/completed';
|
|
915
|
+
switch (type) {
|
|
916
|
+
case 'agentMessage': {
|
|
917
|
+
if (!done)
|
|
918
|
+
return;
|
|
919
|
+
const text = str(item['text']);
|
|
920
|
+
if (text)
|
|
921
|
+
this.emit({ type: 'message', role: 'assistant', text: truncate(text) });
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
case 'reasoning': {
|
|
925
|
+
if (!done)
|
|
926
|
+
return;
|
|
927
|
+
const parts = [
|
|
928
|
+
...(Array.isArray(item['summary']) ? item['summary'] : []),
|
|
929
|
+
...(Array.isArray(item['content']) ? item['content'] : []),
|
|
930
|
+
]
|
|
931
|
+
.map((part) => str(part))
|
|
932
|
+
.filter((part) => Boolean(part));
|
|
933
|
+
if (parts.length)
|
|
934
|
+
this.emit({ type: 'thinking', text: truncate(parts.join('\n'), 8_000) });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
case 'plan': {
|
|
938
|
+
if (!done)
|
|
939
|
+
return;
|
|
940
|
+
const text = str(item['text']);
|
|
941
|
+
if (!text)
|
|
942
|
+
return;
|
|
943
|
+
// Claude signals a plan with a blocking ExitPlanMode permission; Codex
|
|
944
|
+
// just emits an item. Normalize into the shape the dashboard already
|
|
945
|
+
// renders as a plan card, and hold the session until it is approved.
|
|
946
|
+
const requestId = `plan:${this.activeTurnId ?? id ?? 'turn'}`;
|
|
947
|
+
this.heldPlan = { requestId, text };
|
|
948
|
+
this.emit({
|
|
949
|
+
type: 'permission',
|
|
950
|
+
requestId,
|
|
951
|
+
toolName: 'ExitPlanMode',
|
|
952
|
+
title: 'The agent proposes a plan',
|
|
953
|
+
input: {},
|
|
954
|
+
plan: truncate(text, 12_000),
|
|
955
|
+
});
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
case 'commandExecution': {
|
|
959
|
+
const command = str(item['command']) ?? '';
|
|
960
|
+
if (!done) {
|
|
961
|
+
this.emit({
|
|
962
|
+
type: 'tool',
|
|
963
|
+
phase: 'use',
|
|
964
|
+
name: 'Bash',
|
|
965
|
+
...(id ? { toolUseId: id } : {}),
|
|
966
|
+
detail: { command: truncate(command, 2_000) },
|
|
967
|
+
});
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const output = str(item['aggregatedOutput']) ?? '';
|
|
971
|
+
const exitCode = num(item['exitCode']);
|
|
972
|
+
this.emit({
|
|
973
|
+
type: 'tool',
|
|
974
|
+
phase: 'result',
|
|
975
|
+
name: 'Bash',
|
|
976
|
+
...(id ? { toolUseId: id } : {}),
|
|
977
|
+
text: truncate(exitCode !== undefined ? `exit ${exitCode}\n${output}` : output, 4_000),
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
case 'fileChange': {
|
|
982
|
+
const changes = Array.isArray(item['changes']) ? item['changes'] : [];
|
|
983
|
+
const files = changes
|
|
984
|
+
.map((change) => str(asRecord(change)['path']))
|
|
985
|
+
.filter((file) => Boolean(file));
|
|
986
|
+
this.emit({
|
|
987
|
+
type: 'tool',
|
|
988
|
+
phase: done ? 'result' : 'use',
|
|
989
|
+
name: 'Edit',
|
|
990
|
+
...(id ? { toolUseId: id } : {}),
|
|
991
|
+
...(done
|
|
992
|
+
? {
|
|
993
|
+
text: `${files.map((file) => basename(file)).join(', ')} (${str(item['status']) ?? 'done'})`,
|
|
994
|
+
}
|
|
995
|
+
: { detail: { files } }),
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
case 'mcpToolCall': {
|
|
1000
|
+
const tool = `${str(item['server']) ?? 'mcp'}__${str(item['tool']) ?? 'tool'}`;
|
|
1001
|
+
if (!done) {
|
|
1002
|
+
this.emit({
|
|
1003
|
+
type: 'tool',
|
|
1004
|
+
phase: 'use',
|
|
1005
|
+
name: tool,
|
|
1006
|
+
...(id ? { toolUseId: id } : {}),
|
|
1007
|
+
detail: truncateRecord(asRecord(item['arguments'])),
|
|
1008
|
+
});
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
this.emit({
|
|
1012
|
+
type: 'tool',
|
|
1013
|
+
phase: 'result',
|
|
1014
|
+
name: tool,
|
|
1015
|
+
...(id ? { toolUseId: id } : {}),
|
|
1016
|
+
text: truncate(stringifyMcpResult(item), 4_000),
|
|
1017
|
+
});
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
case 'contextCompaction': {
|
|
1021
|
+
if (done)
|
|
1022
|
+
this.notice('info', 'Conversation compacted');
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
case 'webSearch':
|
|
1026
|
+
case 'dynamicToolCall': {
|
|
1027
|
+
if (!done) {
|
|
1028
|
+
this.emit({
|
|
1029
|
+
type: 'tool',
|
|
1030
|
+
phase: 'use',
|
|
1031
|
+
name: type,
|
|
1032
|
+
...(id ? { toolUseId: id } : {}),
|
|
1033
|
+
detail: truncateRecord(item),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
default:
|
|
1039
|
+
// Unknown item types are ignored on purpose: 0.145 adds several, and an
|
|
1040
|
+
// unknown kind must never take the session down.
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
onTurnCompleted(params) {
|
|
1045
|
+
const turn = asRecord(params['turn']);
|
|
1046
|
+
const status = str(turn['status']);
|
|
1047
|
+
this.activeTurnId = null;
|
|
1048
|
+
// A held plan means the turn ended by proposing, not by finishing the work.
|
|
1049
|
+
if (this.heldPlan)
|
|
1050
|
+
return;
|
|
1051
|
+
if (status === 'failed') {
|
|
1052
|
+
const error = asRecord(turn['error']);
|
|
1053
|
+
this.emit({
|
|
1054
|
+
type: 'turn_end',
|
|
1055
|
+
ok: false,
|
|
1056
|
+
errorMessage: maskString(str(error['message']) ?? 'The Codex turn failed').slice(0, 500),
|
|
1057
|
+
});
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
// `interrupted` is a user-initiated Stop: the session stays usable.
|
|
1061
|
+
this.emit({ type: 'turn_end', ok: true });
|
|
1062
|
+
this.flushQueued();
|
|
1063
|
+
}
|
|
1064
|
+
onStderr(text) {
|
|
1065
|
+
const trimmed = text.trim();
|
|
1066
|
+
if (!trimmed)
|
|
1067
|
+
return;
|
|
1068
|
+
// codex is chatty on stderr (bubblewrap notices, MCP transport spam). Log
|
|
1069
|
+
// locally, never turn it into session events.
|
|
1070
|
+
log.warn('codex: stderr', { text: maskString(trimmed).slice(0, 500) });
|
|
1071
|
+
}
|
|
1072
|
+
onExit(info) {
|
|
1073
|
+
if (!this.stopped && !this.ready) {
|
|
1074
|
+
this.emit({
|
|
1075
|
+
type: 'error',
|
|
1076
|
+
message: `codex app-server exited before the session was ready (code ${info.code ?? 'null'})`,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
this.finish();
|
|
1080
|
+
}
|
|
1081
|
+
// ─── Capabilities ──────────────────────────────────────────────────
|
|
1082
|
+
refreshCapabilities() {
|
|
1083
|
+
if (this.capabilitiesInFlight || this.stopped)
|
|
1084
|
+
return;
|
|
1085
|
+
this.capabilitiesInFlight = true;
|
|
1086
|
+
void this.publishCapabilities()
|
|
1087
|
+
.catch((error) => log.warn('codex: capabilities probe failed', { error: describe(error) }))
|
|
1088
|
+
.finally(() => {
|
|
1089
|
+
this.capabilitiesInFlight = false;
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
async publishCapabilities() {
|
|
1093
|
+
// Every probe carries its own catch: an unknown method on a newer or older
|
|
1094
|
+
// codex must degrade a picker, not kill the session.
|
|
1095
|
+
const [models, commands, account, mcpServers] = await Promise.all([
|
|
1096
|
+
this.listModels().catch(() => []),
|
|
1097
|
+
this.listCommands().catch(() => []),
|
|
1098
|
+
this.readAccount().catch(() => null),
|
|
1099
|
+
this.listMcpServers().catch(() => []),
|
|
1100
|
+
]);
|
|
1101
|
+
this.knownModels = models;
|
|
1102
|
+
const currentModel = this.model ?? this.threadModel ?? undefined;
|
|
1103
|
+
// Effort in force: what the user picked, else what the current model
|
|
1104
|
+
// defaults to — so the picker never shows an empty dial.
|
|
1105
|
+
const currentEffort = this.effort ?? models.find((m) => m.id === currentModel)?.defaultEffort ?? undefined;
|
|
1106
|
+
const capabilities = {
|
|
1107
|
+
models,
|
|
1108
|
+
modes: [...AGENT_MODES],
|
|
1109
|
+
commands,
|
|
1110
|
+
currentMode: this.mode,
|
|
1111
|
+
...(currentModel ? { currentModel } : {}),
|
|
1112
|
+
...(currentEffort ? { currentEffort } : {}),
|
|
1113
|
+
...(account ? { account } : {}),
|
|
1114
|
+
mcpServers,
|
|
1115
|
+
interrupt: true,
|
|
1116
|
+
};
|
|
1117
|
+
this.emit({ type: 'capabilities', capabilities });
|
|
1118
|
+
}
|
|
1119
|
+
async listModels() {
|
|
1120
|
+
const result = asRecord(await this.client.request('model/list', { limit: 50 }, 15_000));
|
|
1121
|
+
const data = Array.isArray(result['data']) ? result['data'] : [];
|
|
1122
|
+
const options = [];
|
|
1123
|
+
for (const entry of data) {
|
|
1124
|
+
const row = asRecord(entry);
|
|
1125
|
+
if (row['hidden'] === true)
|
|
1126
|
+
continue;
|
|
1127
|
+
const id = str(row['id']) ?? str(row['model']);
|
|
1128
|
+
if (!id)
|
|
1129
|
+
continue;
|
|
1130
|
+
const description = str(row['description']);
|
|
1131
|
+
const efforts = readEfforts(row['supportedReasoningEfforts']);
|
|
1132
|
+
const defaultEffort = str(row['defaultReasoningEffort']);
|
|
1133
|
+
options.push({
|
|
1134
|
+
id,
|
|
1135
|
+
label: str(row['displayName']) ?? id,
|
|
1136
|
+
...(description ? { description: truncate(description, 160) } : {}),
|
|
1137
|
+
...(row['isDefault'] === true ? { isDefault: true } : {}),
|
|
1138
|
+
...(efforts.length ? { efforts } : {}),
|
|
1139
|
+
...(defaultEffort ? { defaultEffort } : {}),
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return options;
|
|
1143
|
+
}
|
|
1144
|
+
async listCommands() {
|
|
1145
|
+
const result = asRecord(await this.client.request('skills/list', { cwds: [this.spec.cwd] }, 15_000));
|
|
1146
|
+
const entries = Array.isArray(result['data']) ? result['data'] : [];
|
|
1147
|
+
const options = [];
|
|
1148
|
+
for (const entry of entries) {
|
|
1149
|
+
const skills = Array.isArray(asRecord(entry)['skills']) ? asRecord(entry)['skills'] : [];
|
|
1150
|
+
for (const skill of skills) {
|
|
1151
|
+
const row = asRecord(skill);
|
|
1152
|
+
const name = str(row['name']);
|
|
1153
|
+
if (!name)
|
|
1154
|
+
continue;
|
|
1155
|
+
const description = str(row['description']);
|
|
1156
|
+
options.push({
|
|
1157
|
+
name,
|
|
1158
|
+
...(description ? { description: truncate(description, 160) } : {}),
|
|
1159
|
+
});
|
|
1160
|
+
if (options.length >= 150)
|
|
1161
|
+
return options;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return options;
|
|
1165
|
+
}
|
|
1166
|
+
async readAccount() {
|
|
1167
|
+
const result = asRecord(await this.client.request('account/read', {}, 15_000));
|
|
1168
|
+
const account = asRecord(result['account']);
|
|
1169
|
+
const email = str(account['email']);
|
|
1170
|
+
const plan = str(account['planType']);
|
|
1171
|
+
if (!email && !plan)
|
|
1172
|
+
return null;
|
|
1173
|
+
return {
|
|
1174
|
+
...(email ? { email } : {}),
|
|
1175
|
+
...(plan ? { plan: `ChatGPT ${plan}` } : {}),
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
async listMcpServers() {
|
|
1179
|
+
if (!this.threadId)
|
|
1180
|
+
return [];
|
|
1181
|
+
// Without threadId this call does NOT see per-thread servers — it would
|
|
1182
|
+
// report our injected DevBridge server as absent.
|
|
1183
|
+
const result = asRecord(await this.client.request('mcpServerStatus/list', { threadId: this.threadId }, 15_000));
|
|
1184
|
+
const data = Array.isArray(result['data']) ? result['data'] : [];
|
|
1185
|
+
return data.map((entry) => {
|
|
1186
|
+
const row = asRecord(entry);
|
|
1187
|
+
const tools = asRecord(row['tools']);
|
|
1188
|
+
const count = Object.keys(tools).length;
|
|
1189
|
+
return {
|
|
1190
|
+
name: str(row['name']) ?? 'mcp',
|
|
1191
|
+
status: count > 0 ? `ready · ${count} tools` : 'no tools',
|
|
1192
|
+
};
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
// ─── Emission helpers ──────────────────────────────────────────────
|
|
1196
|
+
emit(event) {
|
|
1197
|
+
this.output.push(maskSecrets(event));
|
|
1198
|
+
}
|
|
1199
|
+
notice(level, text) {
|
|
1200
|
+
const key = `${level}:${text}`;
|
|
1201
|
+
// codex repeats the same warning on every start and on every failed MCP
|
|
1202
|
+
// handshake; surface each distinct one once per session.
|
|
1203
|
+
if (this.seenNotices.has(key))
|
|
1204
|
+
return;
|
|
1205
|
+
this.seenNotices.add(key);
|
|
1206
|
+
this.emit({ type: 'notice', level, text: truncate(text, 500) });
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Classify a failure and emit it.
|
|
1210
|
+
*
|
|
1211
|
+
* Renamed from `emitFailure` because it now does more than emit: before we
|
|
1212
|
+
* accuse a user's sign-in of being broken it re-checks the credential. The
|
|
1213
|
+
* old code turned any message containing "401" — including a 401 from an MCP
|
|
1214
|
+
* server — into "your Codex login expired".
|
|
1215
|
+
*
|
|
1216
|
+
* Stays synchronous on purpose: it is called from a notification handler and
|
|
1217
|
+
* from a catch that ends the output queue on the next line, and an awaited
|
|
1218
|
+
* probe in either place would either float a promise or drop the event.
|
|
1219
|
+
*/
|
|
1220
|
+
classifyAndEmitFailure(error) {
|
|
1221
|
+
const message = maskString(describe(error));
|
|
1222
|
+
if (error instanceof ResumeFailed) {
|
|
1223
|
+
this.emit({
|
|
1224
|
+
type: 'error',
|
|
1225
|
+
message: 'The previous Codex thread could not be resumed — starting a fresh one',
|
|
1226
|
+
code: 'resume_failed',
|
|
1227
|
+
});
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (isAuthError(message)) {
|
|
1231
|
+
const probe = this.probeAuth();
|
|
1232
|
+
this.emit({
|
|
1233
|
+
type: 'error',
|
|
1234
|
+
message: probe === 'missing'
|
|
1235
|
+
? 'Codex is not signed in on this server — sign in from the Server panel'
|
|
1236
|
+
: 'Codex authentication was rejected — sign in again from the Server panel',
|
|
1237
|
+
code: probe === 'missing' ? 'auth_missing' : 'auth_expired',
|
|
1238
|
+
});
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
this.emit({ type: 'error', message: `Codex session error: ${message.slice(0, 1_000)}` });
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Second opinion on the sign-in.
|
|
1245
|
+
*
|
|
1246
|
+
* Deliberately does NOT shell out to `codex login status`: that probe is
|
|
1247
|
+
* purely local (verified — it answers with no network at all), so it can tell
|
|
1248
|
+
* us nothing the filesystem cannot, while adding a 10-second subprocess to a
|
|
1249
|
+
* failure path. Re-asserting the home is a couple of lstat calls and answers
|
|
1250
|
+
* the only question that matters here — is there a credential at all?
|
|
1251
|
+
*/
|
|
1252
|
+
probeAuth() {
|
|
1253
|
+
const home = this.repairHome?.() ?? this.home;
|
|
1254
|
+
if (home.auth !== 'missing')
|
|
1255
|
+
this.home = home;
|
|
1256
|
+
return home.auth === 'missing' ? 'missing' : 'expired';
|
|
1257
|
+
}
|
|
1258
|
+
finish() {
|
|
1259
|
+
this.stopped = true;
|
|
1260
|
+
this.output.end();
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
class ResumeFailed extends Error {
|
|
1264
|
+
}
|
|
1265
|
+
function sandboxPolicyFor(mode, cwd) {
|
|
1266
|
+
// turn/start takes the structured SandboxPolicy, while thread/start takes the
|
|
1267
|
+
// CLI-style SandboxMode string. Same intent, two shapes.
|
|
1268
|
+
switch (mode) {
|
|
1269
|
+
case 'read-only':
|
|
1270
|
+
return { type: 'readOnly', networkAccess: false };
|
|
1271
|
+
case 'danger-full-access':
|
|
1272
|
+
return { type: 'dangerFullAccess' };
|
|
1273
|
+
default:
|
|
1274
|
+
return {
|
|
1275
|
+
type: 'workspaceWrite',
|
|
1276
|
+
writableRoots: [cwd],
|
|
1277
|
+
networkAccess: false,
|
|
1278
|
+
excludeTmpdirEnvVar: false,
|
|
1279
|
+
excludeSlashTmp: false,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Codex's own wording for "the sign-in did not work".
|
|
1285
|
+
*
|
|
1286
|
+
* A bare `401`/`unauthorized` is deliberately NOT here: those appear in MCP
|
|
1287
|
+
* server errors and proxy failures too, and matching them turned unrelated
|
|
1288
|
+
* problems into "your Codex login expired". A 401 only counts when the same
|
|
1289
|
+
* message also talks about tokens or signing in — and never when it names MCP.
|
|
1290
|
+
*/
|
|
1291
|
+
export function isAuthError(message) {
|
|
1292
|
+
if (/\bmcp\b/i.test(message))
|
|
1293
|
+
return false;
|
|
1294
|
+
if (/no codex credentials were found|run codex login|not logged in|stored credentials are incomplete/i.test(message)) {
|
|
1295
|
+
return true;
|
|
1296
|
+
}
|
|
1297
|
+
if (/refresh token (has expired|was already used|was revoked)|access token could not be refreshed|log out and sign in again/i.test(message)) {
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
// The corroborating word must be a real one: `\bauth\b` and not a bare
|
|
1301
|
+
// `auth` substring, because "unauthorized" contains "auth" and would happily
|
|
1302
|
+
// vouch for itself.
|
|
1303
|
+
return (/\b401\b|unauthorized/i.test(message) &&
|
|
1304
|
+
/\btoken\b|\bauth\b|\bauthentication\b|\blogin\b|\bsign ?in\b|\bcredentials?\b/i.test(message));
|
|
1305
|
+
}
|
|
1306
|
+
function describe(error) {
|
|
1307
|
+
return String(error instanceof Error ? error.message : error);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* "The thread you asked for is not there." Matched on the message and not on an
|
|
1311
|
+
* error class, because the same condition arrives as an RPC error or as a
|
|
1312
|
+
* transport error depending on where the app-server gave up.
|
|
1313
|
+
*/
|
|
1314
|
+
function isMissingRollout(error) {
|
|
1315
|
+
return /no rollout found|not found/i.test(describe(error));
|
|
1316
|
+
}
|
|
1317
|
+
function delay(ms) {
|
|
1318
|
+
return new Promise((resolve) => {
|
|
1319
|
+
const timer = setTimeout(resolve, ms);
|
|
1320
|
+
timer.unref?.();
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
function basename(file) {
|
|
1324
|
+
if (!file)
|
|
1325
|
+
return 'file';
|
|
1326
|
+
const parts = file.split('/');
|
|
1327
|
+
return parts[parts.length - 1] || file;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* MCP tool-call elicitations name the tool only inside their human message:
|
|
1331
|
+
* `Allow the devbridge MCP server to run tool "devbridge_list_tickets"?`
|
|
1332
|
+
*/
|
|
1333
|
+
function toolNameFromMessage(message) {
|
|
1334
|
+
return message?.match(/tool\s+"([^"]+)"/)?.[1];
|
|
1335
|
+
}
|
|
1336
|
+
/** The dashboard shows "label — description"; the wire wants the label. */
|
|
1337
|
+
export function stripOptionHint(text) {
|
|
1338
|
+
const separator = text.indexOf(' — ');
|
|
1339
|
+
return separator === -1 ? text : text.slice(0, separator);
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* `supportedReasoningEfforts` from `model/list`: a list of
|
|
1343
|
+
* `{reasoningEffort, description}`. Codex 0.145 ships six levels on the Sol
|
|
1344
|
+
* line (low…ultra) and four on the older models, so the labels are derived,
|
|
1345
|
+
* never enumerated on our side.
|
|
1346
|
+
*/
|
|
1347
|
+
export function readEfforts(value) {
|
|
1348
|
+
if (!Array.isArray(value))
|
|
1349
|
+
return [];
|
|
1350
|
+
const options = [];
|
|
1351
|
+
for (const entry of value) {
|
|
1352
|
+
const row = asRecord(entry);
|
|
1353
|
+
const id = str(row['reasoningEffort']) ?? str(row['effort']) ?? str(row['id']);
|
|
1354
|
+
if (!id)
|
|
1355
|
+
continue;
|
|
1356
|
+
const description = str(row['description']);
|
|
1357
|
+
options.push({
|
|
1358
|
+
id,
|
|
1359
|
+
label: id.charAt(0).toUpperCase() + id.slice(1),
|
|
1360
|
+
...(description ? { description: truncate(description, 160) } : {}),
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
return options;
|
|
1364
|
+
}
|
|
1365
|
+
function truncateRecord(value) {
|
|
1366
|
+
const out = {};
|
|
1367
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1368
|
+
out[key] = typeof entry === 'string' ? truncate(entry, 2_000) : entry;
|
|
1369
|
+
}
|
|
1370
|
+
return out;
|
|
1371
|
+
}
|
|
1372
|
+
function stringifyMcpResult(item) {
|
|
1373
|
+
const errorMessage = str(asRecord(item['error'])['message']);
|
|
1374
|
+
if (errorMessage)
|
|
1375
|
+
return `error: ${errorMessage}`;
|
|
1376
|
+
const result = asRecord(item['result']);
|
|
1377
|
+
const content = Array.isArray(result['content']) ? result['content'] : [];
|
|
1378
|
+
const text = content
|
|
1379
|
+
.map((part) => str(asRecord(part)['text']))
|
|
1380
|
+
.filter((part) => Boolean(part))
|
|
1381
|
+
.join('\n');
|
|
1382
|
+
return text || (str(item['status']) ?? 'done');
|
|
1383
|
+
}
|
|
1384
|
+
export class CodexAdapter {
|
|
1385
|
+
deps;
|
|
1386
|
+
id = 'codex';
|
|
1387
|
+
constructor(deps = {}) {
|
|
1388
|
+
this.deps = deps;
|
|
1389
|
+
}
|
|
1390
|
+
startSession(spec) {
|
|
1391
|
+
// Re-assert rather than reuse: `repairCodexAuth` is a couple of lstat calls
|
|
1392
|
+
// and it puts a credential link back if something removed it since boot.
|
|
1393
|
+
//
|
|
1394
|
+
// The configured mode has to travel with it. Repairing with the default
|
|
1395
|
+
// would silently re-link the host user's credential into a home the owner
|
|
1396
|
+
// explicitly asked to keep isolated — turning off a security control by
|
|
1397
|
+
// accident.
|
|
1398
|
+
const repair = () => repairCodexAuth({ auth: this.deps.authMode ?? 'link' });
|
|
1399
|
+
const home = this.deps.codexHome ?? repair();
|
|
1400
|
+
return new CodexSession(spec, home, {
|
|
1401
|
+
...this.deps,
|
|
1402
|
+
repairHome: this.deps.repairHome ?? (this.deps.codexHome ? undefined : repair),
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
//# sourceMappingURL=codex.js.map
|