@moxxy/plugin-provider-codex-agent 0.0.0-stage → 0.45.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/account-limit.d.ts +11 -0
  3. package/dist/account-limit.d.ts.map +1 -0
  4. package/dist/account-limit.js +24 -0
  5. package/dist/account-limit.js.map +1 -0
  6. package/dist/agent-provider.d.ts +62 -0
  7. package/dist/agent-provider.d.ts.map +1 -0
  8. package/dist/agent-provider.js +209 -0
  9. package/dist/agent-provider.js.map +1 -0
  10. package/dist/codex-session.d.ts +220 -0
  11. package/dist/codex-session.d.ts.map +1 -0
  12. package/dist/codex-session.js +1113 -0
  13. package/dist/codex-session.js.map +1 -0
  14. package/dist/constants.d.ts +15 -0
  15. package/dist/constants.d.ts.map +1 -0
  16. package/dist/constants.js +15 -0
  17. package/dist/constants.js.map +1 -0
  18. package/dist/executable.d.ts +16 -0
  19. package/dist/executable.d.ts.map +1 -0
  20. package/dist/executable.js +43 -0
  21. package/dist/executable.js.map +1 -0
  22. package/dist/index.d.ts +9 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +79 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/login.d.ts +37 -0
  27. package/dist/login.d.ts.map +1 -0
  28. package/dist/login.js +127 -0
  29. package/dist/login.js.map +1 -0
  30. package/dist/offered-models.d.ts +12 -0
  31. package/dist/offered-models.d.ts.map +1 -0
  32. package/dist/offered-models.js +53 -0
  33. package/dist/offered-models.js.map +1 -0
  34. package/dist/own-settings.d.ts +9 -0
  35. package/dist/own-settings.d.ts.map +1 -0
  36. package/dist/own-settings.js +34 -0
  37. package/dist/own-settings.js.map +1 -0
  38. package/dist/protocol.d.ts +279 -0
  39. package/dist/protocol.d.ts.map +1 -0
  40. package/dist/protocol.js +30 -0
  41. package/dist/protocol.js.map +1 -0
  42. package/dist/subagents.d.ts +20 -0
  43. package/dist/subagents.d.ts.map +1 -0
  44. package/dist/subagents.js +90 -0
  45. package/dist/subagents.js.map +1 -0
  46. package/dist/translate.d.ts +87 -0
  47. package/dist/translate.d.ts.map +1 -0
  48. package/dist/translate.js +334 -0
  49. package/dist/translate.js.map +1 -0
  50. package/dist/view-image.d.ts +4 -0
  51. package/dist/view-image.d.ts.map +1 -0
  52. package/dist/view-image.js +47 -0
  53. package/dist/view-image.js.map +1 -0
  54. package/package.json +82 -3
  55. package/src/account-limit.ts +25 -0
  56. package/src/agent-cli.test.ts +76 -0
  57. package/src/agent-provider.test.ts +1556 -0
  58. package/src/agent-provider.ts +234 -0
  59. package/src/codex-session.ts +1258 -0
  60. package/src/constants.ts +18 -0
  61. package/src/event-coverage.test.ts +200 -0
  62. package/src/executable.test.ts +53 -0
  63. package/src/executable.ts +49 -0
  64. package/src/fake-codex.testkit.ts +576 -0
  65. package/src/fixtures/codex-app-server-0.155.1.json +122 -0
  66. package/src/fixtures/codex-app-server-0.163.0.json +125 -0
  67. package/src/fixtures/codex-model-list-0.163.0.json +436 -0
  68. package/src/index.test.ts +45 -0
  69. package/src/index.ts +99 -0
  70. package/src/login.test.ts +93 -0
  71. package/src/login.ts +167 -0
  72. package/src/offered-models.test.ts +87 -0
  73. package/src/offered-models.ts +65 -0
  74. package/src/own-settings.test.ts +45 -0
  75. package/src/own-settings.ts +39 -0
  76. package/src/protocol.ts +256 -0
  77. package/src/subagents.test.ts +47 -0
  78. package/src/subagents.ts +98 -0
  79. package/src/translate.test.ts +160 -0
  80. package/src/translate.ts +362 -0
  81. package/src/view-image.test.ts +29 -0
  82. package/src/view-image.ts +47 -0
  83. package/README.md +0 -3
@@ -0,0 +1,1113 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { QUESTION_CLOSED_AGENT_WENT_ON, QUESTION_CLOSED_NEW_MESSAGE, isContextWindowSize } from '@moxxy/sdk';
4
+ import { Inbox, JsonRpcError, JsonRpcLineClient, agentModeFor, earlierConversationNote, idleToolsHandler, moxxyToolsHandler, resumeAgentSession, sameSkills, sameToolNames, skillsForAgent, startMoxxyMcpServer, untilStopped, withoutOwnMcpServers, } from '@moxxy/agent-connection-kit';
5
+ import { MOXXY_MCP_SERVER } from './constants.js';
6
+ import { TurnTranslator, answersMessage, fromAsyncQuestions, fromUserInputQuestions, promptText, shownCommand, toCodexInput, toUserInputAnswers, } from './translate.js';
7
+ import { accountLimitFrom, failedOnUsageLimit } from './account-limit.js';
8
+ import { CodexSubagents, labelOf } from './subagents.js';
9
+ import { readImageFile } from './view-image.js';
10
+ /** Moxxy's auto-approve, as Codex runs it: no approvals, full access. */
11
+ const AUTO = { approvalPolicy: 'never', sandbox: { type: 'dangerFullAccess' } };
12
+ /** Only reading: what plan mode and the model's own asking run in. */
13
+ const READ_ONLY = { type: 'readOnly', networkAccess: false };
14
+ /**
15
+ * Without auto-approve on a Codex set up for it: under `untrusted` Codex holds
16
+ * every command but a known-safe read, and every edit, for the person, whatever
17
+ * the sandbox. Under `on-request` it was the model's call to ask, and it mostly
18
+ * tried the write, was blocked and gave up. The sandbox is the workspace, not
19
+ * read-only: told it may only read, the model at times declined a change
20
+ * without trying, so Codex never got to ask. Codex reports this sandbox back
21
+ * with every field, so it is set the same way.
22
+ */
23
+ const ASKING = {
24
+ approvalPolicy: 'untrusted',
25
+ sandbox: { type: 'workspaceWrite', writableRoots: [], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false },
26
+ };
27
+ /**
28
+ * Asking on a Codex that no longer takes `untrusted`: the model asks for each
29
+ * write. Read-only, as `on-request` would let a write in the workspace run unasked.
30
+ */
31
+ const MODEL_ASKS = { approvalPolicy: 'on-request', sandbox: READ_ONLY };
32
+ /**
33
+ * Moxxy's tools reach Codex as a catalog of three small tools, kept out of
34
+ * Codex's tool search: lent one by one, their full descriptions went with
35
+ * every request (tens of thousands of tokens with the person's MCP servers).
36
+ */
37
+ const LENT_AS = { catalog: true };
38
+ /** Codex's fast mode (`/fast`), its `priority` service tier. */
39
+ const FAST_TIER = 'priority';
40
+ /** Codex's own name for its standard tier, which `null` restores. */
41
+ const STANDARD_TIER = 'default';
42
+ /** Asked of the model when the person's config leaves reasoning summaries to it. */
43
+ const REASONING_SUMMARY = 'auto';
44
+ /** Plan mode only reads: nothing is written and nobody is asked. */
45
+ const PLANNING = { approvalPolicy: 'never', sandbox: READ_ONLY };
46
+ export function isAuto(policy) {
47
+ return policy.approvalPolicy === 'never' && policy.sandbox.type === 'dangerFullAccess';
48
+ }
49
+ /** How much each approval policy asks; a granular one is ranked only against itself. */
50
+ const APPROVAL_RANK = { never: 0, 'on-request': 1, untrusted: 2 };
51
+ /** How much each sandbox holds back; an external one is ranked only against itself. */
52
+ const SANDBOX_RANK = { dangerFullAccess: 0, workspaceWrite: 1, readOnly: 2 };
53
+ /**
54
+ * Whether `now` holds Codex back at least as much as `was` did: it asks at
55
+ * least as often, in a sandbox at least as tight, and still asks the person
56
+ * where they were asked. Anything not plainly so counts as looser.
57
+ */
58
+ function holdsBackAsMuch(now, was) {
59
+ const atLeast = (a, b, same) => same || (a !== undefined && b !== undefined && a > b);
60
+ const approvals = atLeast(rankOf(APPROVAL_RANK, now.approvalPolicy), rankOf(APPROVAL_RANK, was.approvalPolicy), same(now.approvalPolicy, was.approvalPolicy));
61
+ const sandbox = atLeast(SANDBOX_RANK[now.sandbox.type], SANDBOX_RANK[was.sandbox.type], same(now.sandbox, was.sandbox));
62
+ // Under `never` nobody is asked, so who would answer does not count.
63
+ const person = now.approvalPolicy === 'never' || was.approvalPolicy === 'never' || now.reviewer === 'user' || was.reviewer !== 'user';
64
+ return approvals && sandbox && person;
65
+ }
66
+ function rankOf(ranks, policy) {
67
+ return typeof policy === 'string' ? ranks[policy] : undefined;
68
+ }
69
+ /** The sandbox a config's `sandbox_mode` gives a thread. */
70
+ function sandboxOf(mode, workspace) {
71
+ if (mode === 'danger-full-access')
72
+ return { type: 'dangerFullAccess' };
73
+ if (mode === 'read-only')
74
+ return { type: 'readOnly', networkAccess: false };
75
+ return {
76
+ type: 'workspaceWrite',
77
+ writableRoots: workspace ? workspace.writable_roots : [],
78
+ networkAccess: workspace ? workspace.network_access : false,
79
+ excludeTmpdirEnvVar: workspace ? workspace.exclude_tmpdir_env_var : false,
80
+ excludeSlashTmp: workspace ? workspace.exclude_slash_tmp : false,
81
+ };
82
+ }
83
+ /**
84
+ * Moxxy tools Codex already has its own of, so they are not lent over MCP:
85
+ * its file and shell tools, web search, background jobs, skills, memory, its
86
+ * own plan and goal. `ask_user` is lent: outside plan mode Codex has no
87
+ * question of its own. `recall` is lent: it searches Moxxy's record of the
88
+ * whole conversation, which outlives Codex's own thread. The person's Moxxy
89
+ * skills come as a `load_skill` of their own, listing them (see agent-skills).
90
+ */
91
+ const CODEX_HAS_ITS_OWN = new Set([
92
+ 'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob', 'Sleep', 'Wait', 'StopJob',
93
+ 'web_search', 'web_fetch', 'session_recall',
94
+ 'load_skill', 'reload_skills', 'synthesize_skill', 'load_tool',
95
+ 'plan_complete', 'goal_complete', 'goal_abandon',
96
+ ]);
97
+ export function toolsLentToCodex(tools) {
98
+ return tools.filter((tool) => !CODEX_HAS_ITS_OWN.has(tool.name) && !tool.name.startsWith('memory_'));
99
+ }
100
+ const MAX_STDERR = 2048;
101
+ const DISMISSED = { kind: 'dismissed', reason: 'cancelled' };
102
+ const STOPPED = { allow: false, reason: 'The person stopped the turn.' };
103
+ const NOBODY_TO_ASK = 'Nobody is there to answer questions right now. Pick the sensible choice yourself and say which one you picked.';
104
+ const CLIENT_VERSION = createRequire(import.meta.url)('../package.json').version;
105
+ /**
106
+ * One running `codex app-server` with one thread for one Moxxy conversation.
107
+ * Every message the person sends is the next turn of the same thread, so
108
+ * Codex keeps its context, tools and goal between turns. Codex's
109
+ * notifications and its requests to Moxxy (approvals, questions) and Moxxy
110
+ * tool calls over MCP are queued together and handled in order, so the log
111
+ * shows a call before the decision about it.
112
+ */
113
+ export class CodexAgentSession {
114
+ settings;
115
+ mcp;
116
+ connection;
117
+ inbox = new Inbox();
118
+ client;
119
+ alive = true;
120
+ closed = false;
121
+ inTurn = false;
122
+ /**
123
+ * The conversation has not been told how Codex runs yet. Only a thread
124
+ * started for it does that: a conversation going on keeps the auto-approve
125
+ * the person chose, which a new process resuming the thread knows nothing of.
126
+ */
127
+ fresh;
128
+ threadId = '';
129
+ /** How the person's Codex config runs the thread; restored when Moxxy no longer overrides it. */
130
+ original = AUTO;
131
+ originalTier = null;
132
+ originalReviewer = 'user';
133
+ /** `model_reasoning_summary` from the person's config: `null` unset, `undefined` unread. */
134
+ ownSummary;
135
+ current = { ...AUTO, model: '', serviceTier: null, collaboration: 'default', reviewer: 'user', summary: null };
136
+ /** Why the person declined, for Codex's next message when its turn could not take it. */
137
+ pendingNotes = [];
138
+ /** The question Codex asked while it works, open until answered, closed, or its turn ends. */
139
+ asked;
140
+ /** The thread is new while the conversation may already have run: its first message carries the conversation so far. */
141
+ newThread;
142
+ goal;
143
+ /**
144
+ * How Codex asks while auto-approve is off: ASKING on a Codex set up to run
145
+ * without asking, else the person's own setup, or tighter if something
146
+ * outside Moxxy tightened it since (Moxxy never loosens that back by itself).
147
+ */
148
+ asking = ASKING;
149
+ /** Something outside Moxxy made Codex ask while auto-approve was on; the switch shows it on the next turn. */
150
+ turnedOffElsewhere = false;
151
+ /** Moxxy set back a change made outside it between turns; the next turn says so. */
152
+ restoredElsewhere = false;
153
+ /** Codex turns that belong to the Moxxy turn running now. */
154
+ myTurns = new Set();
155
+ /** The Codex turn each command still running started in, by item id: what Stop ends. */
156
+ commandTurns = new Map();
157
+ /** Subagents Codex started; they work in threads of their own and may outlive a turn. */
158
+ subagents = new CodexSubagents();
159
+ /** Calls Codex left running when its turn ended, by item id; their end is shown when it comes. */
160
+ leftRunning = new Map();
161
+ /** What that work did after its turn, for the turn opened to show it (or the person's next turn). */
162
+ late = [];
163
+ /** A turn was asked of the conversation to show what finished after Codex's turn. */
164
+ agentTurnPending = false;
165
+ /** The background work last reported, to report it again only when it changes. */
166
+ shownWork = [];
167
+ /** Background work the person stopped: its end is recorded, but asks no turn of the conversation. */
168
+ stopping = new Set();
169
+ /** The Moxxy tools Codex listed as the thread opened, the same for every turn. */
170
+ listed;
171
+ /** Names of Codex's own skills, read as the thread opens. */
172
+ ownSkills = [];
173
+ /** Codex's own MCP servers that are on: Moxxy's server of the same name is left to Codex's. */
174
+ ownMcpServers = [];
175
+ /** The Moxxy skills Codex listed with the tools; their instructions are read fresh at each call. */
176
+ offered = [];
177
+ activeTurn;
178
+ lastError;
179
+ lastErrorInfo;
180
+ /** The account limit Codex last reported; its updates leave out what has not changed. */
181
+ limit;
182
+ stderr = '';
183
+ static async start(settings) {
184
+ const mcp = await startMoxxyMcpServer({ name: MOXXY_MCP_SERVER });
185
+ let session;
186
+ try {
187
+ session = new CodexAgentSession(settings, mcp, await settings.start({ cwd: settings.cwd }));
188
+ await session.open();
189
+ return session;
190
+ }
191
+ catch (err) {
192
+ if (session)
193
+ await session.close();
194
+ else
195
+ await mcp.close();
196
+ throw err;
197
+ }
198
+ }
199
+ constructor(settings, mcp, connection) {
200
+ this.settings = settings;
201
+ this.mcp = mcp;
202
+ this.connection = connection;
203
+ this.fresh = !settings.resume;
204
+ this.listed = toolsLentToCodex(settings.tools ?? []);
205
+ this.client = new JsonRpcLineClient({ readable: connection.readable, writable: connection.writable });
206
+ connection.stderr?.on('data', (data) => {
207
+ this.stderr = (this.stderr + String(data)).slice(-MAX_STDERR);
208
+ });
209
+ const ended = () => this.ended();
210
+ connection.readable.once('end', ended);
211
+ connection.readable.once('close', ended);
212
+ this.client.onNotification((method, params) => this.onNotification(method, params));
213
+ this.client.onRequest((method, params) => this.inTurn ? new Promise((resolve, reject) => this.inbox.push({ kind: 'request', method, params, resolve, reject })) : refusal(method));
214
+ }
215
+ get isAlive() {
216
+ return this.alive;
217
+ }
218
+ /**
219
+ * Whether this Codex was started where the turn runs (a running thread
220
+ * cannot move) and listed the tools the conversation has now: Codex reads
221
+ * them only as a thread opens, so other tools mean resuming it in a new Codex.
222
+ */
223
+ fits(settings) {
224
+ return (settings.cwd === this.settings.cwd &&
225
+ sameToolNames(this.lent(settings.tools), this.listed) &&
226
+ sameSkills(this.skillsFor(settings.skills), this.offered));
227
+ }
228
+ lent(tools) {
229
+ return withoutOwnMcpServers(toolsLentToCodex(tools ?? []), this.ownMcpServers);
230
+ }
231
+ skillsFor(skills) {
232
+ return skillsForAgent(skills ?? [], this.listed.map((tool) => tool.name), this.ownSkills);
233
+ }
234
+ async *turn(req) {
235
+ // A turn opened to show what Codex's background work did: Codex is sent nothing.
236
+ if (req.continuesAgentTurn) {
237
+ if (!this.agentTurnPending)
238
+ return;
239
+ this.agentTurnPending = false;
240
+ yield { type: 'session', sessionId: this.threadId };
241
+ yield* this.takeLate();
242
+ return;
243
+ }
244
+ const mode = agentModeFor(req.mode);
245
+ this.inTurn = true;
246
+ this.myTurns.clear();
247
+ this.activeTurn = undefined;
248
+ this.lastError = undefined;
249
+ this.lastErrorInfo = undefined;
250
+ const onAbort = () => {
251
+ this.inbox.push({ kind: 'stop' });
252
+ void this.interrupt();
253
+ };
254
+ req.signal.addEventListener('abort', onAbort, { once: true });
255
+ // The same skills Codex listed (`fits`), with their instructions as they are now.
256
+ this.offered = this.skillsFor(req.skills);
257
+ this.mcp.setHandler(moxxyToolsHandler(this.listed, { runTool: (call) => new Promise((resolve) => this.inbox.push({ kind: 'moxxy-tool', call, resolve })) }, this.offered, LENT_AS));
258
+ try {
259
+ yield { type: 'session', sessionId: this.threadId };
260
+ // What finished while the person was writing shows first, in their turn.
261
+ this.agentTurnPending = false;
262
+ yield* this.takeLate();
263
+ let autoApprove = req.autoApprove;
264
+ // A new conversation's thread shows it how the person's Codex really runs
265
+ // (one state), unless the conversation recorded a switch: that one wins.
266
+ // In goal mode Codex runs with full access for the goal, not because the
267
+ // person chose auto-approve, so that waits for the next turn.
268
+ if (this.fresh && mode !== 'goal') {
269
+ this.fresh = false;
270
+ if (!req.autoApproveRecorded) {
271
+ yield { type: 'auto_approve', enabled: isAuto(this.original) };
272
+ if (!autoApprove)
273
+ autoApprove = isAuto(this.original);
274
+ }
275
+ }
276
+ if (this.turnedOffElsewhere && mode !== 'goal' && mode !== 'plan') {
277
+ yield { type: 'auto_approve', enabled: false, elsewhere: true };
278
+ autoApprove = false;
279
+ this.turnedOffElsewhere = false;
280
+ }
281
+ if (this.restoredElsewhere) {
282
+ yield { type: 'auto_approve_restored' };
283
+ this.restoredElsewhere = false;
284
+ }
285
+ await this.apply(this.wanted(req, mode, autoApprove));
286
+ // A new session of the agent's own gets the whole conversation; one it goes on in, the turns it was away for.
287
+ const note = this.newThread ? earlierConversationNote(this.newThread, req.earlierConversation?.()) : req.missedTurns?.();
288
+ this.newThread = undefined;
289
+ if (note)
290
+ this.pendingNotes.unshift(note);
291
+ await this.begin(mode, req);
292
+ yield* this.read(req, mode, new TurnTranslator(this.settings.cwd, (path) => readImageFile(path, this.settings.cwd)));
293
+ }
294
+ catch (err) {
295
+ if (!req.signal.aborted)
296
+ yield { type: 'error', message: this.alive ? messageOf(err) : this.endedMessage(), retryable: false };
297
+ }
298
+ finally {
299
+ req.signal.removeEventListener('abort', onAbort);
300
+ this.mcp.setHandler(idleToolsHandler(this.listed, this.offered, LENT_AS));
301
+ this.inTurn = false;
302
+ // The turn is over: nothing is left to take an answer.
303
+ this.asked?.close.abort();
304
+ this.asked = undefined;
305
+ this.dismissQueued();
306
+ // What outlives the turn now runs in the background.
307
+ this.reportWork();
308
+ }
309
+ }
310
+ /** Stop one work Codex runs in the background (`AgentBackgroundWork.id`); `false` when Codex runs no such work. */
311
+ async stopWork(workId) {
312
+ if (this.leftRunning.has(workId)) {
313
+ this.stopping.add(workId);
314
+ const stopped = await this.terminateCommand(workId);
315
+ if (!stopped)
316
+ this.stopping.delete(workId);
317
+ return stopped;
318
+ }
319
+ const turn = this.subagents.turnOf(workId);
320
+ if (turn === undefined)
321
+ return false;
322
+ this.stopping.add(workId);
323
+ await this.client.request('turn/interrupt', { threadId: workId, turnId: turn }).catch(() => undefined);
324
+ return true;
325
+ }
326
+ async close() {
327
+ if (this.closed)
328
+ return;
329
+ this.closed = true;
330
+ this.alive = false;
331
+ // Codex's background work ends with its app-server.
332
+ this.leftRunning.clear();
333
+ this.reportWork([]);
334
+ this.client.close();
335
+ this.connection.close();
336
+ this.dismissQueued();
337
+ await this.mcp.close();
338
+ }
339
+ /** Codex's own skills that are switched on: a Moxxy skill of the same name is left to Codex's. */
340
+ async ownSkillNames(cwd) {
341
+ const listed = await this.client.request('skills/list', { cwds: [cwd] }).catch(() => undefined);
342
+ return (listed?.data ?? []).flatMap((entry) => entry.skills.filter((skill) => skill.enabled).map((skill) => skill.name));
343
+ }
344
+ async open() {
345
+ await this.client.request('initialize', {
346
+ clientInfo: { name: 'moxxy', title: 'Moxxy', version: CLIENT_VERSION },
347
+ capabilities: { experimentalApi: true },
348
+ });
349
+ this.client.notify('initialized');
350
+ const { cwd, resume, model } = this.settings;
351
+ const read = await this.client.request('config/read', { cwd }).catch(() => undefined);
352
+ // Codex hides MCP tools behind its tool search, where the model does not
353
+ // look for Moxxy's, so the skill list and the three catalog tools sit in its
354
+ // first list; the tools behind the catalog stay out of every request.
355
+ const config = {
356
+ mcp_servers: {
357
+ [MOXXY_MCP_SERVER]: { url: this.mcp.url, http_headers: { Authorization: this.mcp.authorization }, omit_tools_from: ['deferred'] },
358
+ },
359
+ ...checklistFor(read),
360
+ };
361
+ this.ownSummary = read ? (read.config.model_reasoning_summary ?? null) : undefined;
362
+ this.ownMcpServers = Object.entries(read?.config.mcp_servers ?? {})
363
+ .filter(([name, server]) => name !== MOXXY_MCP_SERVER && server.enabled !== false)
364
+ .map(([name]) => name);
365
+ this.listed = this.lent(this.settings.tools);
366
+ // Codex lists Moxxy's tools when the thread opens and keeps that list for the thread.
367
+ this.ownSkills = await this.ownSkillNames(cwd);
368
+ this.offered = this.skillsFor(this.settings.skills);
369
+ this.mcp.setHandler(idleToolsHandler(this.listed, this.offered, LENT_AS));
370
+ let thread;
371
+ let resumed = false;
372
+ if (resume) {
373
+ const loaded = await resumeAgentSession({
374
+ label: 'Codex',
375
+ load: () => this.client.request('thread/resume', { threadId: resume, cwd, config, ...(model ? { model } : {}) }),
376
+ gone: threadGone,
377
+ });
378
+ if (loaded.kind === 'loaded') {
379
+ thread = loaded.session;
380
+ resumed = true;
381
+ const found = await this.client.request('thread/goal/get', { threadId: thread.thread.id }).catch(() => undefined);
382
+ this.goal = found?.goal ?? undefined;
383
+ }
384
+ else {
385
+ this.newThread = 'lost';
386
+ }
387
+ }
388
+ else {
389
+ this.newThread = 'new';
390
+ }
391
+ thread ??= await this.client.request('thread/start', { cwd, config, ...(model ? { model } : {}) });
392
+ this.threadId = thread.thread.id;
393
+ // A new thread runs as the person set Codex up. A resumed one keeps the approval
394
+ // policy and reviewer Moxxy last set on it, so their own setup comes from their config.
395
+ const own = resumed && read ? read.config : undefined;
396
+ const running = { approvalPolicy: thread.approvalPolicy, sandbox: thread.sandbox };
397
+ this.original = {
398
+ approvalPolicy: (own && own.approval_policy) ?? running.approvalPolicy,
399
+ sandbox: own && own.sandbox_mode ? sandboxOf(own.sandbox_mode, own.sandbox_workspace_write) : running.sandbox,
400
+ };
401
+ this.originalTier = thread.serviceTier && thread.serviceTier !== STANDARD_TIER ? thread.serviceTier : null;
402
+ this.originalReviewer = (own && own.approvals_reviewer) ?? thread.approvalsReviewer ?? 'user';
403
+ this.asking = isAuto(this.original) ? ASKING : this.original;
404
+ this.current = {
405
+ ...running,
406
+ model: thread.model,
407
+ ...(thread.reasoningEffort ? { effort: thread.reasoningEffort } : {}),
408
+ serviceTier: this.originalTier,
409
+ collaboration: 'default',
410
+ reviewer: thread.approvalsReviewer ?? 'user',
411
+ summary: this.ownSummary ?? null,
412
+ };
413
+ }
414
+ /** What the thread should run with for this turn's mode, model and auto-approve. */
415
+ wanted(req, mode, autoApprove) {
416
+ const effort = typeof req.reasoning === 'object' ? req.reasoning.effort : undefined;
417
+ const ownAuto = isAuto(this.original);
418
+ const policy = mode === 'plan' ? PLANNING
419
+ : mode === 'goal' || autoApprove ? (ownAuto ? this.original : AUTO)
420
+ : this.asking;
421
+ return {
422
+ ...policy,
423
+ model: req.model || this.current.model,
424
+ ...(effort ? { effort } : {}),
425
+ // Fast mode off hands the tier back to how the person set Codex up.
426
+ serviceTier: req.fast ? FAST_TIER : this.originalTier,
427
+ collaboration: mode === 'plan' ? 'plan' : 'default',
428
+ // While Moxxy does not answer for the person, the person answers: never Codex's own reviewer.
429
+ reviewer: mode !== 'plan' && mode !== 'goal' && !autoApprove ? 'user' : this.originalReviewer,
430
+ // Codex streams no summary of its thinking unless asked; a summary the person set stays theirs.
431
+ summary: this.ownSummary === null ? REASONING_SUMMARY : this.current.summary,
432
+ };
433
+ }
434
+ /** Change on the thread only what differs; nothing is written to the person's Codex config. */
435
+ async apply(wanted) {
436
+ const current = this.current;
437
+ const effort = wanted.effort ?? current.effort;
438
+ const update = {};
439
+ if (!same(wanted.approvalPolicy, current.approvalPolicy))
440
+ update.approvalPolicy = wanted.approvalPolicy;
441
+ if (!same(wanted.sandbox, current.sandbox))
442
+ update.sandboxPolicy = wanted.sandbox;
443
+ if (wanted.model !== current.model)
444
+ update.model = wanted.model;
445
+ if (wanted.effort && wanted.effort !== current.effort)
446
+ update.effort = wanted.effort;
447
+ if (wanted.serviceTier !== current.serviceTier)
448
+ update.serviceTier = wanted.serviceTier;
449
+ if (wanted.reviewer !== current.reviewer)
450
+ update.approvalsReviewer = wanted.reviewer;
451
+ if (wanted.summary !== null && wanted.summary !== current.summary)
452
+ update.summary = wanted.summary;
453
+ if (wanted.collaboration !== current.collaboration) {
454
+ update.collaborationMode = {
455
+ mode: wanted.collaboration,
456
+ settings: { model: wanted.model, reasoning_effort: effort ?? null, developer_instructions: null },
457
+ };
458
+ }
459
+ if (Object.keys(update).length === 0)
460
+ return;
461
+ try {
462
+ await this.client.request('thread/settings/update', { threadId: this.threadId, ...update });
463
+ }
464
+ catch (err) {
465
+ if (update.approvalPolicy !== ASKING.approvalPolicy || this.asking !== ASKING)
466
+ throw err;
467
+ this.asking = MODEL_ASKS;
468
+ return this.apply({ ...wanted, ...MODEL_ASKS });
469
+ }
470
+ this.current = { ...wanted, ...(effort ? { effort } : {}) };
471
+ }
472
+ /** Start the turn: a goal Codex works on by itself, or the person's message. */
473
+ async begin(mode, req) {
474
+ const input = toCodexInput(req.prompt);
475
+ if (mode === 'goal' && this.goal?.status === 'active')
476
+ return this.startTurn(input);
477
+ // A goal left behind (met, or stopped before leaving goal mode) must not keep Codex going.
478
+ if (this.goal)
479
+ await this.clearGoal();
480
+ if (mode !== 'goal')
481
+ return this.startTurn(input);
482
+ const set = await this.client.request('thread/goal/set', { threadId: this.threadId, objective: promptText(req.prompt) });
483
+ this.goal = set.goal;
484
+ }
485
+ async startTurn(input) {
486
+ const notes = this.pendingNotes.map((text) => ({ type: 'text', text, text_elements: [] }));
487
+ this.pendingNotes = [];
488
+ const started = await this.client.request('turn/start', { threadId: this.threadId, input: [...notes, ...input] });
489
+ this.myTurns.add(started.turn.id);
490
+ this.activeTurn = started.turn.id;
491
+ }
492
+ async clearGoal() {
493
+ await this.client.request('thread/goal/clear', { threadId: this.threadId }).catch(() => undefined);
494
+ this.goal = undefined;
495
+ }
496
+ /** Read Codex until the Moxxy turn is over: its turn ended, or its goal stopped running. */
497
+ async *read(req, mode, translator) {
498
+ const finished = new Set();
499
+ for (;;) {
500
+ const item = await this.inbox.next();
501
+ if (item.kind === 'ended') {
502
+ yield { type: 'error', message: this.endedMessage(), retryable: false };
503
+ return;
504
+ }
505
+ if (item.kind === 'stop') {
506
+ if (this.activeTurn === undefined)
507
+ return;
508
+ continue;
509
+ }
510
+ if (item.kind === 'moxxy-tool') {
511
+ item.resolve(await req.host.runTool(item.call));
512
+ continue;
513
+ }
514
+ if (item.kind === 'request') {
515
+ yield* this.answer(item, req, translator);
516
+ continue;
517
+ }
518
+ if (item.kind === 'answered') {
519
+ await this.steerAnswer(translator);
520
+ continue;
521
+ }
522
+ const params = (item.params ?? {});
523
+ this.noteGoal(item.method, params);
524
+ const change = this.noteSettings(item.method, params);
525
+ if (change === 'turned-off') {
526
+ // Goal and plan mode run Codex their own way; the switch shows the change on the next turn outside them.
527
+ if (mode === 'goal' || mode === 'plan')
528
+ this.turnedOffElsewhere = true;
529
+ else
530
+ yield { type: 'auto_approve', enabled: false, elsewhere: true };
531
+ continue;
532
+ }
533
+ if (change === 'loosened') {
534
+ await this.restore();
535
+ yield { type: 'auto_approve_restored' };
536
+ continue;
537
+ }
538
+ if (change)
539
+ continue;
540
+ if (item.method === 'account/rateLimits/updated') {
541
+ const limit = accountLimitFrom((params.rateLimits ?? {}));
542
+ if (limit) {
543
+ this.limit = limit;
544
+ yield { type: 'account_limit', limit };
545
+ }
546
+ continue;
547
+ }
548
+ // A subagent works in a thread of its own; its turns are not the conversation's,
549
+ // though the subagents keep them for Stop.
550
+ if (isTurnEvent(item.method) && params.threadId !== this.threadId) {
551
+ yield* this.subagents.fromThread(item.method, params);
552
+ continue;
553
+ }
554
+ if (item.method === 'turn/started') {
555
+ const turn = params.turn;
556
+ if (!turn)
557
+ continue;
558
+ this.myTurns.add(turn.id);
559
+ this.activeTurn = turn.id;
560
+ if (req.signal.aborted)
561
+ void this.interrupt();
562
+ // A goal turn Codex started by itself takes what waited for its next message.
563
+ else if (this.pendingNotes.length > 0)
564
+ await this.steerPending(turn.id);
565
+ continue;
566
+ }
567
+ if (item.method === 'turn/completed') {
568
+ const turn = params.turn;
569
+ if (!turn || !this.myTurns.has(turn.id) || finished.has(turn.id))
570
+ continue;
571
+ finished.add(turn.id);
572
+ if (this.activeTurn === turn.id)
573
+ this.activeTurn = undefined;
574
+ if (turn.status === 'failed') {
575
+ if (failedOnUsageLimit(turn.error?.codexErrorInfo ?? this.lastErrorInfo)) {
576
+ this.limit = this.limit ? { ...this.limit, state: 'reached' } : { state: 'reached' };
577
+ yield { type: 'account_limit', limit: this.limit };
578
+ }
579
+ yield { type: 'error', message: `Codex: ${turn.error?.message ?? this.lastError ?? 'the turn failed'}`, retryable: false };
580
+ return;
581
+ }
582
+ if (turn.status === 'interrupted' || req.signal.aborted)
583
+ return;
584
+ if (this.asked) {
585
+ const asked = this.asked;
586
+ this.asked = undefined;
587
+ // Codex ended on the question (plan mode): it waits for the answer as its next message.
588
+ // It said more since: it went on without one.
589
+ const waits = translator.lastSaid(asked.itemId);
590
+ if (!waits)
591
+ asked.close.abort(QUESTION_CLOSED_AGENT_WENT_ON);
592
+ const reply = replyTo(asked.items, await untilStopped(asked.outcome, req.signal, DISMISSED), waits);
593
+ if (reply !== undefined) {
594
+ await this.startTurn([{ type: 'text', text: reply, text_elements: [] }]);
595
+ continue;
596
+ }
597
+ if (waits)
598
+ return;
599
+ }
600
+ if (mode === 'goal') {
601
+ // An open goal: Codex starts its next turn by itself.
602
+ if (this.goal?.status === 'active')
603
+ continue;
604
+ yield { type: 'goal', status: this.goal?.status === 'complete' ? 'complete' : 'active' };
605
+ }
606
+ const left = translator.leftRunning();
607
+ for (const [id, call] of left.calls)
608
+ this.leftRunning.set(id, call);
609
+ yield* left.events;
610
+ return;
611
+ }
612
+ if (item.method === 'error') {
613
+ const error = params.error;
614
+ if (params.willRetry !== true && error && typeof error.message === 'string') {
615
+ this.lastError = error.message;
616
+ this.lastErrorInfo = error.codexErrorInfo;
617
+ }
618
+ continue;
619
+ }
620
+ if (typeof params.threadId === 'string' && params.threadId !== this.threadId) {
621
+ yield* this.subagents.fromThread(item.method, params);
622
+ continue;
623
+ }
624
+ const ended = this.endOfLeftRunning(item.method, params);
625
+ if (ended) {
626
+ yield* ended.events;
627
+ continue;
628
+ }
629
+ if (!this.belongsHere(params))
630
+ continue;
631
+ if (isSubagentActivity(item.method, params)) {
632
+ yield* this.subagents.activity(params.item);
633
+ continue;
634
+ }
635
+ yield* contextWindowOf(item.method, params, req.model || this.current.model);
636
+ yield* translator.translate(item.method, params);
637
+ const question = translator.takeQuestions();
638
+ if (question)
639
+ this.askWhileWorking(question, req);
640
+ await this.steerAnswer(translator);
641
+ }
642
+ }
643
+ /**
644
+ * Show Codex's question at once and let Codex work on: Codex asks in a
645
+ * message and does not wait, so the answer goes into its running turn, or
646
+ * waits for its turn to end when it ended on the question.
647
+ */
648
+ askWhileWorking(question, req) {
649
+ // A newer question takes the place of one Codex went on past.
650
+ this.asked?.close.abort(QUESTION_CLOSED_AGENT_WENT_ON);
651
+ const items = fromAsyncQuestions(question.questions);
652
+ const close = new AbortController();
653
+ const asked = { itemId: question.itemId, items, close, outcome: req.host.ask(items, { signal: close.signal }) };
654
+ this.asked = asked;
655
+ void asked.outcome.then((outcome) => {
656
+ asked.done = true;
657
+ if (outcome.kind === 'answered')
658
+ asked.answer = answersMessage(items, outcome.answers);
659
+ this.inbox.push({ kind: 'answered', asked });
660
+ }, () => {
661
+ asked.done = true;
662
+ });
663
+ }
664
+ /**
665
+ * The person's answer goes into Codex's running turn once Codex has gone on
666
+ * from the question; until then Codex may still end its turn on it, and the
667
+ * answer is its next message. A question closed unanswered is let go.
668
+ */
669
+ async steerAnswer(translator) {
670
+ const asked = this.asked;
671
+ if (!asked?.done || asked.refused || this.activeTurn === undefined || !translator.movedOnFrom(asked.itemId))
672
+ return;
673
+ if (asked.answer === undefined) {
674
+ this.asked = undefined;
675
+ return;
676
+ }
677
+ const turnId = this.activeTurn;
678
+ const input = [{ type: 'text', text: asked.answer, text_elements: [] }];
679
+ const steered = await this.client.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input }).then(() => true, () => false);
680
+ if (steered) {
681
+ if (this.asked === asked)
682
+ this.asked = undefined;
683
+ }
684
+ else
685
+ asked.refused = true;
686
+ }
687
+ /** What Codex asked of Moxxy, answered with the turn's host. */
688
+ async *answer(item, req, translator) {
689
+ const p = (item.params ?? {});
690
+ try {
691
+ switch (item.method) {
692
+ case 'item/commandExecution/requestApproval':
693
+ case 'item/fileChange/requestApproval': {
694
+ const callId = String(p.itemId);
695
+ const call = translator.callOf(callId) ?? approvalCall(item.method, p);
696
+ yield* translator.announce(callId, call);
697
+ const decision = await this.decide(req, callId, call, p.reason);
698
+ item.resolve({ decision: approvalDecision(decision, p.availableDecisions) });
699
+ await this.explainDecline(req, call, decision);
700
+ return;
701
+ }
702
+ case 'item/permissions/requestApproval': {
703
+ const callId = String(p.itemId);
704
+ const call = { name: 'request_permissions', input: p.permissions };
705
+ yield* translator.announce(callId, call);
706
+ const decision = await this.decide(req, callId, call, p.reason);
707
+ yield { type: 'tool_result', callId, ok: decision.allow, ...(decision.allow ? { output: 'granted' } : { error: decision.reason }) };
708
+ item.resolve(decision.allow ? { permissions: withoutNulls(p.permissions), scope: decision.always ? 'session' : 'turn' } : { permissions: {}, scope: 'turn' });
709
+ await this.explainDecline(req, call, decision);
710
+ return;
711
+ }
712
+ case 'item/tool/requestUserInput': {
713
+ const items = fromUserInputQuestions(Array.isArray(p.questions) ? p.questions : []);
714
+ const outcome = await untilStopped(req.host.ask(items), req.signal, DISMISSED);
715
+ // An empty answer lets Codex keep waiting. The new message needs its turn to release the session first.
716
+ if (outcome.kind === 'dismissed' && outcome.note === QUESTION_CLOSED_NEW_MESSAGE)
717
+ await this.interrupt();
718
+ item.resolve({ answers: outcome.kind === 'answered' ? toUserInputAnswers(items, outcome.answers) : {} });
719
+ return;
720
+ }
721
+ case 'mcpServer/elicitation/request': {
722
+ // Moxxy asks about its own tools when it runs them.
723
+ if (p.serverName === MOXXY_MCP_SERVER) {
724
+ item.resolve({ action: 'accept', content: {}, _meta: null });
725
+ return;
726
+ }
727
+ const message = typeof p.message === 'string' ? p.message : `${String(p.serverName)} asks to go ahead.`;
728
+ const callId = `elicitation_${randomUUID()}`;
729
+ const call = { name: `mcp__${String(p.serverName)}`, input: { message } };
730
+ yield* translator.announce(callId, call);
731
+ const decision = await this.decide(req, callId, call, message);
732
+ yield { type: 'tool_result', callId, ok: decision.allow, ...(decision.allow ? { output: 'accepted' } : { error: decision.reason }) };
733
+ item.resolve(decision.allow ? { action: 'accept', content: {}, _meta: null } : { action: 'decline', content: null, _meta: null });
734
+ await this.explainDecline(req, call, decision);
735
+ return;
736
+ }
737
+ default:
738
+ item.reject(new JsonRpcError(-32601, `Moxxy does not handle ${item.method}.`));
739
+ }
740
+ }
741
+ catch (err) {
742
+ item.reject(err instanceof Error ? err : new Error(String(err)));
743
+ }
744
+ }
745
+ decide(req, callId, call, reason) {
746
+ const description = typeof reason === 'string' && reason ? { description: reason } : {};
747
+ return untilStopped(req.host.approve({ callId, name: call.name, input: call.input, ...description }), req.signal, STOPPED);
748
+ }
749
+ /**
750
+ * Codex's answer to an approval is a bare `decline`, so the person's reason
751
+ * goes into its running turn (`turn/steer`), or its next message when the
752
+ * turn can no longer take input.
753
+ */
754
+ async explainDecline(req, call, decision) {
755
+ if (decision.allow || req.signal.aborted)
756
+ return;
757
+ const text = declineNote(call, decision.reason);
758
+ const turnId = this.activeTurn;
759
+ const steered = turnId !== undefined &&
760
+ (await this.client.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: [{ type: 'text', text, text_elements: [] }] }).then(() => true, () => false));
761
+ if (!steered)
762
+ this.pendingNotes.push(text);
763
+ }
764
+ async steerPending(turnId) {
765
+ const notes = this.pendingNotes;
766
+ this.pendingNotes = [];
767
+ const input = notes.map((text) => ({ type: 'text', text, text_elements: [] }));
768
+ const steered = await this.client.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input }).then(() => true, () => false);
769
+ if (!steered)
770
+ this.pendingNotes.unshift(...notes);
771
+ }
772
+ async interrupt() {
773
+ const turnId = this.activeTurn;
774
+ if (turnId === undefined)
775
+ return;
776
+ const stopped = new Set(this.myTurns);
777
+ await this.client.request('turn/interrupt', { threadId: this.threadId, turnId }).catch(() => undefined);
778
+ await this.endCommandsOf(stopped);
779
+ }
780
+ /**
781
+ * Codex leaves an interrupted turn's commands running, so Stop ends the ones
782
+ * these turns started. What an earlier turn left running (a dev server) is
783
+ * not this turn's to stop.
784
+ */
785
+ async endCommandsOf(turns) {
786
+ await this.terminateWhere((itemId) => {
787
+ const turn = this.commandTurns.get(itemId);
788
+ return turn !== undefined && turns.has(turn);
789
+ });
790
+ }
791
+ /** End the command a call left running, found by the item that started it; `false` when no such command runs. */
792
+ async terminateCommand(itemId) {
793
+ return (await this.terminateWhere((id) => id === itemId)) > 0;
794
+ }
795
+ /** End every background command of the thread whose item `pick` chooses; how many it ended. */
796
+ async terminateWhere(pick) {
797
+ let ended = 0;
798
+ let cursor = null;
799
+ do {
800
+ const page = await this.client
801
+ .request('thread/backgroundTerminals/list', { threadId: this.threadId, ...(cursor ? { cursor } : {}) })
802
+ .catch(() => undefined);
803
+ if (!page)
804
+ return ended;
805
+ for (const terminal of page.data) {
806
+ if (!pick(terminal.itemId))
807
+ continue;
808
+ await this.client.request('thread/backgroundTerminals/terminate', { threadId: this.threadId, processId: terminal.processId }).catch(() => undefined);
809
+ ended += 1;
810
+ }
811
+ cursor = page.nextCursor;
812
+ } while (cursor);
813
+ return ended;
814
+ }
815
+ /** Which Codex turn started each command, noted as Codex reports it, ahead of the turn reading it. */
816
+ noteCommand(method, params) {
817
+ if (method !== 'item/started' && method !== 'item/completed')
818
+ return;
819
+ const note = params;
820
+ if (note?.threadId !== this.threadId || note.item?.type !== 'commandExecution' || typeof note.item.id !== 'string')
821
+ return;
822
+ if (method === 'item/completed')
823
+ this.commandTurns.delete(note.item.id);
824
+ else if (typeof note.turnId === 'string')
825
+ this.commandTurns.set(note.item.id, note.turnId);
826
+ }
827
+ belongsHere(params) {
828
+ if (typeof params.threadId === 'string' && params.threadId !== this.threadId)
829
+ return false;
830
+ return typeof params.turnId !== 'string' || this.myTurns.has(params.turnId);
831
+ }
832
+ onNotification(method, params) {
833
+ this.noteCommand(method, params);
834
+ // In a turn the goal's state is read in order with everything else Codex sent.
835
+ if (this.inTurn) {
836
+ this.inbox.push({ kind: 'note', method, params });
837
+ return;
838
+ }
839
+ this.noteGoal(method, params);
840
+ this.noteLate(method, params);
841
+ const change = this.noteSettings(method, params);
842
+ if (change === 'turned-off')
843
+ this.turnedOffElsewhere = true;
844
+ if (change === 'loosened') {
845
+ this.restoredElsewhere = true;
846
+ void this.restore().catch(() => undefined);
847
+ }
848
+ }
849
+ /**
850
+ * A change of the thread's approvals made without Moxxy (another client, a
851
+ * hook): only the person loosens them. A change that holds Codex back at
852
+ * least as much is `kept` (`turned-off` when auto-approve was on), a looser
853
+ * one is `loosened` and set back. Codex reports Moxxy's own changes too; those
854
+ * match what Moxxy set and are passed over.
855
+ */
856
+ noteSettings(method, params) {
857
+ if (method !== 'thread/settings/updated')
858
+ return undefined;
859
+ const update = params;
860
+ if (!update || update.threadId !== this.threadId || !update.threadSettings)
861
+ return undefined;
862
+ const settings = update.threadSettings;
863
+ const now = { approvalPolicy: settings.approvalPolicy, sandbox: settings.sandboxPolicy, reviewer: settings.approvalsReviewer };
864
+ const was = this.current;
865
+ if (same(now.approvalPolicy, was.approvalPolicy) && same(now.sandbox, was.sandbox) && now.reviewer === was.reviewer)
866
+ return undefined;
867
+ if (!holdsBackAsMuch(now, was))
868
+ return 'loosened';
869
+ this.current = { ...was, ...now };
870
+ if (isAuto(now))
871
+ return 'kept';
872
+ this.asking = { approvalPolicy: now.approvalPolicy, sandbox: now.sandbox };
873
+ return isAuto(was) ? 'turned-off' : 'kept';
874
+ }
875
+ /** Put the thread's approvals back as Moxxy last set them. */
876
+ async restore() {
877
+ const { approvalPolicy, sandbox, reviewer } = this.current;
878
+ await this.client.request('thread/settings/update', { threadId: this.threadId, approvalPolicy, sandboxPolicy: sandbox, approvalsReviewer: reviewer });
879
+ }
880
+ /**
881
+ * Between turns: what Codex's background work does. Codex starts no turn
882
+ * when it ends (0.155.1), so the conversation is asked for one to show it:
883
+ * a subagent's tools and answer, a command's output.
884
+ */
885
+ noteLate(method, params) {
886
+ const p = (params ?? {});
887
+ // A subagent's steps are recorded as they come.
888
+ if (typeof p.threadId === 'string' && p.threadId !== this.threadId) {
889
+ const events = this.subagents.fromThread(method, p);
890
+ this.report(events);
891
+ // A subagent stopped in its own thread asks no turn: the person stopped it.
892
+ if (events.some((event) => event.type === 'subagent_completed')) {
893
+ this.stopping.delete(p.threadId);
894
+ this.reportWork();
895
+ }
896
+ return;
897
+ }
898
+ const ended = this.endOfLeftRunning(method, p);
899
+ if (ended) {
900
+ // What the person stopped is ended for them already; it asks no turn.
901
+ if (ended.stopped)
902
+ return;
903
+ this.late.push(...ended.events);
904
+ this.askForTurn('A command Codex left running finished', ended.id);
905
+ return;
906
+ }
907
+ if (!isSubagentActivity(method, p))
908
+ return;
909
+ const done = this.subagents.activity(p.item).filter((event) => event.type === 'subagent_completed');
910
+ if (done.length === 0)
911
+ return;
912
+ const item = p.item;
913
+ const workId = typeof item.agentThreadId === 'string' ? item.agentThreadId : undefined;
914
+ if (workId !== undefined && this.stopping.delete(workId)) {
915
+ this.report(done);
916
+ this.reportWork();
917
+ return;
918
+ }
919
+ this.late.push(...done);
920
+ this.reportWork();
921
+ this.askForTurn(`${labelOf(item.agentPath)} finished in the background`, workId);
922
+ }
923
+ /**
924
+ * The end of a call Codex left running, as a call of its own beside the one
925
+ * shown running; `undefined` for anything else. Its end is recorded for the
926
+ * agents panel at once, with what the command printed last.
927
+ */
928
+ endOfLeftRunning(method, params) {
929
+ const item = params.item;
930
+ if (method !== 'item/completed' || typeof item?.id !== 'string')
931
+ return undefined;
932
+ const call = this.leftRunning.get(item.id);
933
+ if (!call)
934
+ return undefined;
935
+ this.leftRunning.delete(item.id);
936
+ const stopped = this.stopping.delete(item.id);
937
+ const output = typeof item.aggregatedOutput === 'string' ? item.aggregatedOutput : '';
938
+ const status = stopped ? 'stopped' : commandEnd(item);
939
+ this.report([{ type: 'background_work_ended', id: item.id, kind: 'command', label: workLabel(call), status, ...(output ? { output } : {}) }]);
940
+ this.reportWork();
941
+ const translator = new TurnTranslator(this.settings.cwd, (path) => readImageFile(path, this.settings.cwd));
942
+ return { id: item.id, stopped, events: translator.translate('item/completed', { ...params, item: { ...item, id: `${item.id}-ended` } }) };
943
+ }
944
+ askForTurn(name, workId) {
945
+ if (this.closed || this.agentTurnPending)
946
+ return;
947
+ this.agentTurnPending = true;
948
+ this.settings.onAgentTurn?.(name, workId);
949
+ }
950
+ /** What Codex runs in the background now: the commands its turns left running and the subagents still working. Reported when it changes. */
951
+ reportWork(work = this.workNow()) {
952
+ if (sameWork(work, this.shownWork))
953
+ return;
954
+ this.shownWork = work;
955
+ this.report([{ type: 'background_work', work }]);
956
+ }
957
+ workNow() {
958
+ const commands = [...this.leftRunning].map(([id, call]) => ({ id, kind: 'command', label: workLabel(call) }));
959
+ return [...commands, ...this.subagents.running()];
960
+ }
961
+ report(events) {
962
+ if (events.length > 0)
963
+ this.settings.onActivity?.(events);
964
+ }
965
+ *takeLate() {
966
+ const late = this.late;
967
+ this.late = [];
968
+ yield* late;
969
+ }
970
+ /** Keep the goal's state, whether or not a turn is reading. */
971
+ noteGoal(method, params) {
972
+ if (method === 'thread/goal/updated') {
973
+ const goal = params?.goal;
974
+ if (goal)
975
+ this.goal = goal;
976
+ }
977
+ else if (method === 'thread/goal/cleared') {
978
+ this.goal = undefined;
979
+ }
980
+ }
981
+ ended() {
982
+ if (!this.alive)
983
+ return;
984
+ this.alive = false;
985
+ this.inbox.push({ kind: 'ended' });
986
+ }
987
+ endedMessage() {
988
+ const stderr = this.stderr.trim();
989
+ return stderr ? `Codex stopped.\n${stderr}` : 'Codex stopped.';
990
+ }
991
+ /** Answer whatever is still queued once no turn can: Codex must never wait on Moxxy forever. */
992
+ dismissQueued() {
993
+ for (const item of this.inbox.drain()) {
994
+ if (item.kind === 'ended')
995
+ this.inbox.push(item);
996
+ else if (item.kind === 'note') {
997
+ this.noteGoal(item.method, item.params);
998
+ this.noteLate(item.method, item.params);
999
+ }
1000
+ else if (item.kind === 'moxxy-tool')
1001
+ item.resolve({ ok: false, message: 'No Moxxy turn is running, so the tool cannot run now.' });
1002
+ else if (item.kind === 'request') {
1003
+ try {
1004
+ item.resolve(refusal(item.method));
1005
+ }
1006
+ catch (err) {
1007
+ item.reject(err instanceof Error ? err : new Error(String(err)));
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ }
1013
+ /** What Moxxy tells Codex when nobody is in the conversation to decide. */
1014
+ function refusal(method) {
1015
+ switch (method) {
1016
+ case 'item/commandExecution/requestApproval':
1017
+ case 'item/fileChange/requestApproval':
1018
+ return { decision: 'decline' };
1019
+ case 'item/permissions/requestApproval':
1020
+ return { permissions: {}, scope: 'turn' };
1021
+ case 'item/tool/requestUserInput':
1022
+ return { answers: {} };
1023
+ case 'mcpServer/elicitation/request':
1024
+ return { action: 'decline', content: null, _meta: null };
1025
+ default:
1026
+ throw new JsonRpcError(-32601, `Moxxy does not handle ${method}.`);
1027
+ }
1028
+ }
1029
+ function approvalCall(method, p) {
1030
+ if (method === 'item/fileChange/requestApproval')
1031
+ return { name: 'apply_patch', input: { changes: [] } };
1032
+ return { name: 'Bash', input: { command: shownCommand(String(p.command ?? '')), cwd: p.cwd } };
1033
+ }
1034
+ function approvalDecision(decision, offered) {
1035
+ if (!decision.allow)
1036
+ return 'decline';
1037
+ const forSession = !Array.isArray(offered) || offered.includes('acceptForSession');
1038
+ return decision.always && forSession ? 'acceptForSession' : 'accept';
1039
+ }
1040
+ function declineNote(call, reason) {
1041
+ const input = (call.input ?? {});
1042
+ const what = typeof input.command === 'string' ? `${call.name} \`${input.command}\``
1043
+ : Array.isArray(input.changes) && input.changes.length > 0 ? `${call.name} (${input.changes.map((change) => String(change.path)).join(', ')})`
1044
+ : call.name;
1045
+ return `The person declined ${what}. Their reason: ${reason}`;
1046
+ }
1047
+ function withoutNulls(value) {
1048
+ if (typeof value !== 'object' || value === null)
1049
+ return {};
1050
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null && v !== undefined));
1051
+ }
1052
+ function same(a, b) {
1053
+ return JSON.stringify(a) === JSON.stringify(b);
1054
+ }
1055
+ function messageOf(err) {
1056
+ return err instanceof Error ? err.message : String(err);
1057
+ }
1058
+ /** Codex's answer to resuming a thread it no longer has, or never could have. */
1059
+ function threadGone(error) {
1060
+ return error instanceof JsonRpcError && /no rollout found|invalid session id/i.test(error.message);
1061
+ }
1062
+ /**
1063
+ * Codex's checklist (`update_plan`) is off by default since 0.152; Moxxy turns it on for its
1064
+ * own threads so the step list shows, unless the person chose either way in their config.
1065
+ * A dotted key changes only that setting, never the rest of the person's `[tools]`.
1066
+ */
1067
+ function checklistFor(read) {
1068
+ const tools = read?.config.tools;
1069
+ if (tools && typeof tools.update_plan?.enabled === 'boolean')
1070
+ return {};
1071
+ return { 'tools.update_plan.enabled': true };
1072
+ }
1073
+ function isTurnEvent(method) {
1074
+ return method === 'turn/started' || method === 'turn/completed';
1075
+ }
1076
+ function isSubagentActivity(method, params) {
1077
+ if (method !== 'item/started' && method !== 'item/completed')
1078
+ return false;
1079
+ const item = params.item;
1080
+ return item !== undefined && item.type === 'subAgentActivity';
1081
+ }
1082
+ /** The window Codex runs the turn's model with: its config's `model_context_window` or Codex's own default. */
1083
+ function contextWindowOf(method, params, model) {
1084
+ if (method !== 'thread/tokenUsage/updated' || !model)
1085
+ return [];
1086
+ const usage = params?.tokenUsage;
1087
+ const tokens = usage?.modelContextWindow;
1088
+ return isContextWindowSize(tokens) ? [{ type: 'context_window', model, tokens }] : [];
1089
+ }
1090
+ /** A background command as the person reads it: the command it runs. */
1091
+ function workLabel(call) {
1092
+ const input = call.input;
1093
+ return typeof input?.command === 'string' && input.command ? input.command : call.name;
1094
+ }
1095
+ function commandEnd(item) {
1096
+ if (item.status === 'completed')
1097
+ return item.exitCode === 0 || item.exitCode === null || item.exitCode === undefined ? 'completed' : 'failed';
1098
+ return item.status === 'failed' ? 'failed' : 'stopped';
1099
+ }
1100
+ function sameWork(a, b) {
1101
+ return a.length === b.length && a.every((work, i) => work.id === b[i]?.id && work.kind === b[i]?.kind && work.label === b[i]?.label);
1102
+ }
1103
+ /**
1104
+ * Codex's next message after its turn ended with a question open: the answer,
1105
+ * or, when Codex waits on the question and nobody can answer, that it picks
1106
+ * itself. None when it was closed unanswered.
1107
+ */
1108
+ function replyTo(items, outcome, waits) {
1109
+ if (outcome.kind === 'answered')
1110
+ return answersMessage(items, outcome.answers);
1111
+ return outcome.kind === 'unanswerable' && waits ? NOBODY_TO_ASK : undefined;
1112
+ }
1113
+ //# sourceMappingURL=codex-session.js.map