@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. 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