@moxxy/plugin-provider-codex-agent 0.0.0-stage → 0.46.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,576 @@
1
+ /**
2
+ * A stand-in for `codex app-server`, the external boundary of the Codex agent
3
+ * connection. It speaks the same newline JSON-RPC over the same two streams,
4
+ * shaped after recorded 0.155.1 sessions: `thread/start` answers with the
5
+ * thread as the person's config runs it, `turn/start` answers and then streams
6
+ * `turn/started`, items and `turn/completed`, approvals are requests to Moxxy,
7
+ * a goal starts its own turns until it is met, and Moxxy's tools are called
8
+ * over Moxxy's MCP server with the real MCP client. What Codex does in a turn
9
+ * is a scene the test writes.
10
+ */
11
+ import { PassThrough } from 'node:stream';
12
+ import { createInterface } from 'node:readline';
13
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
14
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
15
+ import { Inbox } from '@moxxy/agent-connection-kit';
16
+ import type { CodexConnection } from './codex-session.js';
17
+ import type { CodexApprovalPolicy, CodexApprovalsReviewer, CodexGoal, CodexGoalStatus, CodexSandboxPolicy, CodexUserInput } from './protocol.js';
18
+
19
+ export interface FakeCodexTurn {
20
+ readonly id: string;
21
+ readonly threadId: string;
22
+ readonly input: ReadonlyArray<CodexUserInput>;
23
+ /** The person's words in this turn ('' for a turn a goal started). */
24
+ readonly text: string;
25
+ /** Set on a turn the goal started (the first one and each continuation). */
26
+ readonly objective?: string;
27
+ readonly thread: FakeThread;
28
+ readonly server: FakeCodexServer;
29
+ /** Aborted when Moxxy interrupts the turn. */
30
+ readonly signal: AbortSignal;
31
+ started(item: Record<string, unknown>): void;
32
+ completed(item: Record<string, unknown>): void;
33
+ /** An agent message, streamed as deltas and then completed. */
34
+ say(text: string, extra?: Record<string, unknown>): void;
35
+ /** A shell command; with `approval` Codex asks Moxxy first and returns the decision. */
36
+ run(command: string, output: string, opts?: { readonly approval?: boolean; readonly reason?: string }): Promise<unknown>;
37
+ request(method: string, params: Record<string, unknown>): Promise<unknown>;
38
+ callMoxxyTool(name: string, args: Record<string, unknown>): Promise<{ content: unknown; isError?: boolean }>;
39
+ usage(input: number, output: number, cached?: number, contextWindow?: number): void;
40
+ goal(status: CodexGoalStatus): void;
41
+ /** The next text Moxxy steers into this running turn (`turn/steer`). */
42
+ steered(): Promise<string>;
43
+ }
44
+
45
+ /** A scene's failed turn; `errorInfo` is Codex's code for it (`usageLimitExceeded`). */
46
+ export type CodexScene = (turn: FakeCodexTurn) => Promise<void | { readonly status: 'failed'; readonly error: string; readonly errorInfo?: string }>;
47
+
48
+ export interface FakeThread {
49
+ readonly id: string;
50
+ readonly cwd: string;
51
+ readonly ephemeral: boolean;
52
+ readonly startParams: Record<string, unknown>;
53
+ approvalPolicy: CodexApprovalPolicy;
54
+ sandbox: CodexSandboxPolicy;
55
+ /** Who answers its approvals: the person, or Codex's own reviewer (`auto_review`). */
56
+ approvalsReviewer: CodexApprovalsReviewer;
57
+ /** The reasoning summary asked of the model; `null` leaves it to the model's default. */
58
+ summary: string | null;
59
+ model: string;
60
+ effort: string | null;
61
+ serviceTier: string | null;
62
+ collaborationMode: string;
63
+ goal: CodexGoal | undefined;
64
+ mcp: { readonly url: string; readonly headers: Record<string, string> } | undefined;
65
+ /** Moxxy's tools as Codex read them when the thread opened; Codex 0.155 keeps that list for the thread. */
66
+ moxxyTools: ReadonlyArray<string>;
67
+ /**
68
+ * Commands still running, by item id: a command item started and not yet
69
+ * completed. Like Codex 0.155–0.163, interrupting a turn leaves its commands
70
+ * running; only `thread/backgroundTerminals/terminate` (or closing) ends them.
71
+ */
72
+ readonly terminals: Map<string, { readonly processId: string; readonly command: string }>;
73
+ }
74
+
75
+ export interface FakeCodexDefaults {
76
+ readonly approvalPolicy?: CodexApprovalPolicy;
77
+ readonly sandbox?: CodexSandboxPolicy;
78
+ readonly model?: string;
79
+ readonly effort?: string | null;
80
+ readonly serviceTier?: string | null;
81
+ readonly approvalsReviewer?: CodexApprovalsReviewer;
82
+ /** `model_reasoning_summary` from the person's config; unset leaves the model's default. */
83
+ readonly reasoningSummary?: string;
84
+ /** `tools.update_plan.enabled` from the person's config; unset leaves Codex's default (off). */
85
+ readonly planTool?: boolean;
86
+ /** How many `thread/resume` calls fail first, as a Codex briefly unable to read its threads. */
87
+ readonly resumeFailures?: number;
88
+ /** Refuse `turn/steer`, as Codex does for a turn that cannot take more input. */
89
+ readonly refuseSteer?: boolean;
90
+ /** Approval policies `thread/settings/update` refuses, as a later Codex that dropped one would. */
91
+ readonly refusedApprovalPolicies?: ReadonlyArray<string>;
92
+ /** Names of the person's own Codex skills, as `skills/list` reports them. */
93
+ readonly ownSkills?: ReadonlyArray<string>;
94
+ /** The person's own MCP servers, as `config/read` reports them under `mcp_servers`. */
95
+ readonly mcpServers?: Readonly<Record<string, { readonly enabled: boolean }>>;
96
+ /** `model/list` pages as Codex answers them, each `{ data, nextCursor }`. */
97
+ readonly modelPages?: ReadonlyArray<{ readonly data: ReadonlyArray<unknown>; readonly nextCursor?: string | null }>;
98
+ }
99
+
100
+ /** The person's Codex install: every app-server Moxxy starts, and the threads it saved. */
101
+ export class FakeCodex {
102
+ readonly servers: FakeCodexServer[] = [];
103
+ /** Threads saved to disk, which a later process can `thread/resume`. */
104
+ readonly saved = new Map<string, FakeThread>();
105
+ threadCount = 0;
106
+ turnCount = 0;
107
+ processCount = 0;
108
+ resumeFailures = 0;
109
+ /** A Codex that started but answers nothing, as one stuck on startup. */
110
+ silent = false;
111
+
112
+ constructor(
113
+ readonly scene: CodexScene,
114
+ readonly defaults: FakeCodexDefaults = {},
115
+ ) {}
116
+
117
+ /** What Moxxy calls to start `codex app-server`. */
118
+ readonly start = async (opts: { readonly cwd: string }): Promise<CodexConnection> => {
119
+ const server = new FakeCodexServer(this, opts.cwd);
120
+ this.servers.push(server);
121
+ return server.connection;
122
+ };
123
+
124
+ get last(): FakeCodexServer {
125
+ const server = this.servers.at(-1);
126
+ if (!server) throw new Error('Codex was never started');
127
+ return server;
128
+ }
129
+ }
130
+
131
+ export class FakeCodexServer {
132
+ readonly requests: Array<{ readonly method: string; readonly params: Record<string, unknown> }> = [];
133
+ readonly notifications: string[] = [];
134
+ readonly threads = new Map<string, FakeThread>();
135
+ readonly connection: CodexConnection;
136
+ closed = false;
137
+ private readonly toMoxxy = new PassThrough();
138
+ private readonly fromMoxxy = new PassThrough();
139
+ private readonly pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
140
+ private nextRequestId = 0;
141
+ private active: { id: string; threadId: string; stop: AbortController; steers: Inbox<string> } | undefined;
142
+ private readonly mcpClients: Client[] = [];
143
+
144
+ constructor(
145
+ private readonly codex: FakeCodex,
146
+ readonly cwd: string,
147
+ ) {
148
+ createInterface({ input: this.fromMoxxy }).on('line', (line) => void this.receive(line));
149
+ this.connection = {
150
+ readable: this.toMoxxy,
151
+ writable: this.fromMoxxy,
152
+ close: () => {
153
+ if (this.closed) return;
154
+ this.closed = true;
155
+ this.active?.stop.abort();
156
+ this.toMoxxy.end();
157
+ for (const client of this.mcpClients) void client.close();
158
+ },
159
+ };
160
+ }
161
+
162
+ /** Every request Moxxy made with this method, in order. */
163
+ sent(method: string): Array<Record<string, unknown>> {
164
+ return this.requests.filter((r) => r.method === method).map((r) => r.params);
165
+ }
166
+
167
+ get thread(): FakeThread {
168
+ const thread = [...this.threads.values()].at(-1);
169
+ if (!thread) throw new Error('No thread was started');
170
+ return thread;
171
+ }
172
+
173
+ /** The thread's approvals change without Moxxy asking (another client, a hook); Codex reports it as it does any change. */
174
+ changeSettingsElsewhere(changes: {
175
+ readonly approvalPolicy?: CodexApprovalPolicy;
176
+ readonly sandbox?: CodexSandboxPolicy;
177
+ readonly approvalsReviewer?: CodexApprovalsReviewer;
178
+ }): void {
179
+ const thread = this.thread;
180
+ if (changes.approvalPolicy) thread.approvalPolicy = changes.approvalPolicy;
181
+ if (changes.sandbox) thread.sandbox = changes.sandbox;
182
+ if (changes.approvalsReviewer) thread.approvalsReviewer = changes.approvalsReviewer;
183
+ this.notify('thread/settings/updated', { threadId: thread.id, threadSettings: threadSettingsOf(thread) });
184
+ }
185
+
186
+ /** Codex crashed: its stdout closes. */
187
+ crash(): void {
188
+ this.toMoxxy.end();
189
+ }
190
+
191
+ notify(method: string, params: Record<string, unknown>): void {
192
+ this.notifications.push(method);
193
+ this.write({ method, params });
194
+ }
195
+
196
+ request(method: string, params: Record<string, unknown>): Promise<unknown> {
197
+ const id = this.nextRequestId++;
198
+ return new Promise((resolve, reject) => {
199
+ this.pending.set(id, { resolve, reject });
200
+ this.write({ id, method, params });
201
+ });
202
+ }
203
+
204
+ private write(message: Record<string, unknown>): void {
205
+ if (!this.toMoxxy.writableEnded) this.toMoxxy.write(`${JSON.stringify(message)}\n`);
206
+ }
207
+
208
+ private async receive(line: string): Promise<void> {
209
+ const message = JSON.parse(line) as { id?: number; method?: string; params?: Record<string, unknown>; result?: unknown; error?: { message: string } };
210
+ if (message.method === undefined) {
211
+ const waiting = message.id === undefined ? undefined : this.pending.get(message.id);
212
+ if (!waiting || message.id === undefined) return;
213
+ this.pending.delete(message.id);
214
+ if (message.error) waiting.reject(new Error(message.error.message));
215
+ else waiting.resolve(message.result);
216
+ return;
217
+ }
218
+ const params = message.params ?? {};
219
+ this.requests.push({ method: message.method, params });
220
+ if (message.id === undefined || this.codex.silent) return;
221
+ try {
222
+ const { result, after } = await this.handle(message.method, params);
223
+ this.write({ id: message.id, result });
224
+ after?.();
225
+ } catch (err) {
226
+ this.write({ id: message.id, error: { code: -32600, message: err instanceof Error ? err.message : String(err) } });
227
+ }
228
+ }
229
+
230
+ private async handle(method: string, params: Record<string, unknown>): Promise<{ result: unknown; after?: () => void }> {
231
+ switch (method) {
232
+ case 'initialize':
233
+ return { result: { userAgent: 'moxxy/0.155.1', codexHome: '/home/me/.codex', platformFamily: 'unix', platformOs: 'macos' } };
234
+ case 'thread/start': {
235
+ const started = this.startThread(params);
236
+ const thread = this.threads.get(String((started.result as { thread: { id: string } }).thread.id));
237
+ if (thread) thread.moxxyTools = await this.readMoxxyTools(thread);
238
+ return started;
239
+ }
240
+ case 'model/list': {
241
+ // Codex's cursor is the offset of the next page's first model.
242
+ const pages = this.codex.defaults.modelPages ?? [];
243
+ const offset = typeof params.cursor === 'string' ? Number(params.cursor) : 0;
244
+ let start = 0;
245
+ const page = pages.find((candidate) => {
246
+ const here = start === offset;
247
+ start += candidate.data.length;
248
+ return here;
249
+ });
250
+ if (!page) throw new Error(`no model/list page for cursor ${String(params.cursor)}`);
251
+ return { result: page };
252
+ }
253
+ case 'config/read':
254
+ return {
255
+ result: {
256
+ config: {
257
+ model_reasoning_summary: this.codex.defaults.reasoningSummary ?? null,
258
+ approvals_reviewer: this.codex.defaults.approvalsReviewer ?? 'user',
259
+ approval_policy: this.codex.defaults.approvalPolicy ?? 'never',
260
+ sandbox_mode: modeOfSandbox(this.codex.defaults.sandbox ?? { type: 'dangerFullAccess' }),
261
+ tools: this.codex.defaults.planTool === undefined ? null : { update_plan: { enabled: this.codex.defaults.planTool } },
262
+ mcp_servers: this.codex.defaults.mcpServers ?? {},
263
+ },
264
+ },
265
+ };
266
+ case 'thread/resume': {
267
+ if (this.codex.resumeFailures < (this.codex.defaults.resumeFailures ?? 0)) {
268
+ this.codex.resumeFailures += 1;
269
+ throw new Error('failed to read the thread store');
270
+ }
271
+ const saved = this.codex.saved.get(String(params.threadId));
272
+ if (!saved) throw new Error(`no rollout found for thread id ${String(params.threadId)}`);
273
+ // As Codex 0.155 resumes a thread: the approval policy Moxxy set on it is kept, the sandbox is the person's config again.
274
+ const thread: FakeThread = { ...saved, sandbox: this.codex.defaults.sandbox ?? { type: 'dangerFullAccess' }, mcp: mcpOf(params.config), moxxyTools: [] };
275
+ thread.moxxyTools = await this.readMoxxyTools(thread);
276
+ this.threads.set(thread.id, thread);
277
+ return { result: threadResponse(thread) };
278
+ }
279
+ case 'thread/settings/update': {
280
+ const thread = this.threadOf(params);
281
+ if (this.codex.defaults.refusedApprovalPolicies?.includes(String(params.approvalPolicy))) throw new Error(`unknown variant \`${String(params.approvalPolicy)}\``);
282
+ if (params.approvalPolicy !== undefined) thread.approvalPolicy = params.approvalPolicy as CodexApprovalPolicy;
283
+ if (params.sandboxPolicy !== undefined) thread.sandbox = params.sandboxPolicy as CodexSandboxPolicy;
284
+ if (params.approvalsReviewer !== undefined) thread.approvalsReviewer = params.approvalsReviewer as CodexApprovalsReviewer;
285
+ if (typeof params.summary === 'string') thread.summary = params.summary;
286
+ if (typeof params.model === 'string') thread.model = params.model;
287
+ if (typeof params.effort === 'string') thread.effort = params.effort;
288
+ // `null` clears the tier back to Codex's standard one, which it calls `default`.
289
+ if (params.serviceTier !== undefined) thread.serviceTier = (params.serviceTier as string | null) ?? 'default';
290
+ const collaboration = params.collaborationMode as { mode?: string } | undefined;
291
+ if (collaboration?.mode) thread.collaborationMode = collaboration.mode;
292
+ return { result: {}, after: () => this.notify('thread/settings/updated', { threadId: thread.id, threadSettings: threadSettingsOf(thread) }) };
293
+ }
294
+ case 'turn/start': {
295
+ const thread = this.threadOf(params);
296
+ if (this.active) throw new Error('a turn is already running');
297
+ const id = `turn-${++this.codex.turnCount}`;
298
+ const input = params.input as CodexUserInput[];
299
+ return { result: { turn: { id, items: [], status: 'inProgress', error: null } }, after: () => void this.runTurn(thread, id, input) };
300
+ }
301
+ case 'turn/steer': {
302
+ const active = this.active;
303
+ if (this.codex.defaults.refuseSteer || !active || active.id !== params.expectedTurnId) throw new Error('no steerable turn');
304
+ const text = (params.input as CodexUserInput[]).flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('\n');
305
+ active.steers.push(text);
306
+ return { result: { turnId: active.id } };
307
+ }
308
+ case 'turn/interrupt': {
309
+ if (!this.active || this.active.id !== params.turnId) throw new Error('no active turn to interrupt');
310
+ this.active.stop.abort();
311
+ return { result: {} };
312
+ }
313
+ case 'thread/goal/set': {
314
+ const thread = this.threadOf(params);
315
+ const objective = typeof params.objective === 'string' ? params.objective : undefined;
316
+ // 0.155.1: a new objective on a met goal leaves it met and starts nothing; clear it first.
317
+ if (thread.goal?.status === 'complete' && objective !== undefined) return { result: { goal: thread.goal } };
318
+ thread.goal = { objective: objective ?? thread.goal?.objective ?? '', status: (params.status as CodexGoalStatus | undefined) ?? 'active' };
319
+ const goal = thread.goal;
320
+ return {
321
+ result: { goal },
322
+ after: () => {
323
+ this.notify('thread/goal/updated', { threadId: thread.id, turnId: null, goal });
324
+ if (goal.status === 'active' && !this.active) void this.runTurn(thread, `turn-${++this.codex.turnCount}`, [], goal.objective);
325
+ },
326
+ };
327
+ }
328
+ case 'thread/goal/clear': {
329
+ const thread = this.threadOf(params);
330
+ const cleared = thread.goal !== undefined;
331
+ thread.goal = undefined;
332
+ return { result: { cleared }, after: () => this.notify('thread/goal/cleared', { threadId: thread.id }) };
333
+ }
334
+ case 'skills/list': {
335
+ const skills = (this.codex.defaults.ownSkills ?? []).map((name) => ({
336
+ name,
337
+ description: `The person's ${name}`,
338
+ path: `/home/me/.codex/skills/${name}/SKILL.md`,
339
+ scope: 'user',
340
+ enabled: true,
341
+ pluginId: null,
342
+ }));
343
+ const off = { name: 'switched-off', description: 'Turned off', path: '/home/me/.codex/skills/switched-off/SKILL.md', scope: 'user', enabled: false, pluginId: null };
344
+ return { result: { data: [{ cwd: this.cwd, skills: [...skills, off], errors: [] }] } };
345
+ }
346
+ case 'thread/goal/get':
347
+ return { result: { goal: this.threadOf(params).goal ?? null } };
348
+ case 'thread/backgroundTerminals/list': {
349
+ const terminals = [...this.threadOf(params).terminals].map(([itemId, { processId, command }]) => ({
350
+ itemId,
351
+ processId,
352
+ command,
353
+ cwd: this.cwd,
354
+ osPid: null,
355
+ cpuPercent: null,
356
+ rssKb: null,
357
+ }));
358
+ return { result: { data: terminals, nextCursor: null } };
359
+ }
360
+ case 'thread/backgroundTerminals/terminate': {
361
+ const { terminals } = this.threadOf(params);
362
+ const running = [...terminals].find(([, terminal]) => terminal.processId === params.processId);
363
+ if (running) terminals.delete(running[0]);
364
+ return { result: { terminated: running !== undefined } };
365
+ }
366
+ default:
367
+ throw new Error(`Method not found: ${method}`);
368
+ }
369
+ }
370
+
371
+ private startThread(params: Record<string, unknown>): { result: unknown; after: () => void } {
372
+ const { defaults } = this.codex;
373
+ const id = `thread-${++this.codex.threadCount}`;
374
+ const thread: FakeThread = {
375
+ id,
376
+ cwd: String(params.cwd ?? this.cwd),
377
+ ephemeral: params.ephemeral === true,
378
+ startParams: params,
379
+ approvalPolicy: (params.approvalPolicy as CodexApprovalPolicy | undefined) ?? defaults.approvalPolicy ?? 'never',
380
+ sandbox: sandboxFromMode(params.sandbox) ?? defaults.sandbox ?? { type: 'dangerFullAccess' },
381
+ approvalsReviewer: defaults.approvalsReviewer ?? 'user',
382
+ summary: defaults.reasoningSummary ?? null,
383
+ model: typeof params.model === 'string' ? params.model : (defaults.model ?? 'gpt-6-luna'),
384
+ effort: defaults.effort === undefined ? 'xhigh' : defaults.effort,
385
+ serviceTier: defaults.serviceTier ?? 'default',
386
+ collaborationMode: 'default',
387
+ goal: undefined,
388
+ mcp: mcpOf(params.config),
389
+ moxxyTools: [],
390
+ terminals: new Map(),
391
+ };
392
+ this.threads.set(id, thread);
393
+ if (!thread.ephemeral) this.codex.saved.set(id, thread);
394
+ return { result: threadResponse(thread), after: () => this.notify('thread/started', { thread: { id } }) };
395
+ }
396
+
397
+ private threadOf(params: Record<string, unknown>): FakeThread {
398
+ const thread = this.threads.get(String(params.threadId));
399
+ if (!thread) throw new Error(`thread not found: ${String(params.threadId)}`);
400
+ return thread;
401
+ }
402
+
403
+ private async runTurn(thread: FakeThread, id: string, input: ReadonlyArray<CodexUserInput>, objective?: string): Promise<void> {
404
+ const stop = new AbortController();
405
+ const steers = new Inbox<string>();
406
+ this.active = { id, threadId: thread.id, stop, steers };
407
+ this.notify('turn/started', { threadId: thread.id, turn: { id, items: [], status: 'inProgress', error: null } });
408
+ const stopped = new Promise<'stopped'>((resolve) => stop.signal.addEventListener('abort', () => resolve('stopped'), { once: true }));
409
+ let outcome: Awaited<ReturnType<CodexScene>> | 'stopped';
410
+ try {
411
+ outcome = await Promise.race([this.codex.scene(this.turn(thread, id, input, stop.signal, steers, objective)), stopped]);
412
+ } catch (err) {
413
+ outcome = { status: 'failed', error: err instanceof Error ? err.message : String(err) };
414
+ }
415
+ this.active = undefined;
416
+ if (this.closed) return;
417
+ const status = stop.signal.aborted ? 'interrupted' : outcome && outcome !== 'stopped' ? outcome.status : 'completed';
418
+ const error =
419
+ outcome && outcome !== 'stopped' ? { message: outcome.error, codexErrorInfo: outcome.errorInfo ?? null, additionalDetails: null } : null;
420
+ this.notify('turn/completed', { threadId: thread.id, turn: { id, items: [], status, error } });
421
+ // A goal still open keeps going on its own, as Codex's goal runtime does.
422
+ if (status === 'completed' && thread.goal?.status === 'active') {
423
+ void this.runTurn(thread, `turn-${++this.codex.turnCount}`, [], thread.goal.objective);
424
+ }
425
+ }
426
+
427
+ private turn(
428
+ thread: FakeThread,
429
+ id: string,
430
+ input: ReadonlyArray<CodexUserInput>,
431
+ signal: AbortSignal,
432
+ steers: Inbox<string>,
433
+ objective?: string,
434
+ ): FakeCodexTurn {
435
+ const base = { threadId: thread.id, turnId: id };
436
+ const text = input.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('\n');
437
+ const item = (method: 'item/started' | 'item/completed', it: Record<string, unknown>) => {
438
+ if (it.type === 'commandExecution' && typeof it.id === 'string') {
439
+ if (method === 'item/started') thread.terminals.set(it.id, { processId: String(++this.codex.processCount), command: String(it.command) });
440
+ else thread.terminals.delete(it.id);
441
+ }
442
+ this.notify(method, { ...base, item: it });
443
+ };
444
+ return {
445
+ id,
446
+ threadId: thread.id,
447
+ input,
448
+ text,
449
+ ...(objective !== undefined ? { objective } : {}),
450
+ thread,
451
+ server: this,
452
+ signal,
453
+ steered: () => steers.next(),
454
+ started: (it) => item('item/started', it),
455
+ completed: (it) => item('item/completed', it),
456
+ say: (said, extra = {}) => {
457
+ const itemId = `msg-${Math.random().toString(36).slice(2)}`;
458
+ const item = { type: 'agentMessage', id: itemId, text: said, phase: 'final_answer', delivery: null, questions: null, ...extra };
459
+ this.notify('item/started', { ...base, item: { ...item, text: '' } });
460
+ for (const delta of said.match(/.{1,8}/gs) ?? []) this.notify('item/agentMessage/delta', { ...base, itemId, delta });
461
+ this.notify('item/completed', { ...base, item });
462
+ },
463
+ run: async (command, output, opts = {}) => {
464
+ const itemId = `exec-${Math.random().toString(36).slice(2)}`;
465
+ const wrapped = `/bin/zsh -lc '${command.replace(/'/g, `'\\''`)}'`;
466
+ const execution = { type: 'commandExecution', id: itemId, command: wrapped, cwd: thread.cwd, status: 'inProgress', aggregatedOutput: null, exitCode: null };
467
+ item('item/started', execution);
468
+ let decision: unknown;
469
+ // Codex's own reviewer answers in its place; Moxxy never hears of it.
470
+ if (opts.approval && thread.approvalsReviewer !== 'user') decision = { decision: 'accept' };
471
+ else if (opts.approval) {
472
+ decision = await this.request('item/commandExecution/requestApproval', {
473
+ ...base,
474
+ itemId,
475
+ reason: opts.reason ?? null,
476
+ command: wrapped,
477
+ cwd: thread.cwd,
478
+ availableDecisions: ['accept', 'cancel'],
479
+ });
480
+ const answer = (decision as { decision?: unknown }).decision;
481
+ if (answer !== 'accept' && answer !== 'acceptForSession') {
482
+ item('item/completed', { ...execution, status: 'declined' });
483
+ return decision;
484
+ }
485
+ }
486
+ item('item/completed', { ...execution, status: 'completed', aggregatedOutput: output, exitCode: 0 });
487
+ return decision;
488
+ },
489
+ request: (method, params) => this.request(method, { ...base, ...params }),
490
+ callMoxxyTool: async (name, args) => {
491
+ // A tool Moxxy lends behind its catalog is run the way the model runs it: through call_tool.
492
+ const direct = thread.moxxyTools.includes(name);
493
+ if (!direct && !thread.moxxyTools.includes('call_tool')) throw new Error(`Codex has no tool mcp__moxxy__${name}; it read ${JSON.stringify(thread.moxxyTools)}`);
494
+ const client = await this.moxxyClient(thread);
495
+ const result = await client.callTool(direct ? { name, arguments: args } : { name: 'call_tool', arguments: { name, arguments: args } });
496
+ return { content: result.content, ...(result.isError ? { isError: true } : {}) };
497
+ },
498
+ usage: (inputTokens, outputTokens, cached = 0, contextWindow = 258_400) => {
499
+ const last = { totalTokens: inputTokens + outputTokens, inputTokens, cachedInputTokens: cached, cacheWriteInputTokens: 0, outputTokens, reasoningOutputTokens: 0 };
500
+ this.notify('thread/tokenUsage/updated', { ...base, tokenUsage: { total: last, last, modelContextWindow: contextWindow } });
501
+ },
502
+ goal: (status) => {
503
+ if (!thread.goal) throw new Error('no goal is set');
504
+ thread.goal = { ...thread.goal, status };
505
+ this.notify('thread/goal/updated', { ...base, goal: thread.goal });
506
+ },
507
+ };
508
+ }
509
+
510
+ private async readMoxxyTools(thread: FakeThread): Promise<string[]> {
511
+ if (!thread.mcp) return [];
512
+ const { tools } = await (await this.moxxyClient(thread)).listTools();
513
+ return tools.map((tool) => tool.name);
514
+ }
515
+
516
+ /** Connect to Moxxy's MCP server the way Codex does, from the thread config Moxxy passed. */
517
+ private async moxxyClient(thread: FakeThread): Promise<Client> {
518
+ if (!thread.mcp) throw new Error('Moxxy passed no MCP server in the thread config');
519
+ const client = new Client({ name: 'codex-mcp-client', version: '0.155.1' });
520
+ await client.connect(new StreamableHTTPClientTransport(new URL(thread.mcp.url), { requestInit: { headers: thread.mcp.headers } }));
521
+ this.mcpClients.push(client);
522
+ return client;
523
+ }
524
+ }
525
+
526
+ function threadResponse(thread: FakeThread): Record<string, unknown> {
527
+ return {
528
+ thread: { id: thread.id },
529
+ model: thread.model,
530
+ modelProvider: 'openai',
531
+ cwd: thread.cwd,
532
+ approvalPolicy: thread.approvalPolicy,
533
+ sandbox: thread.sandbox,
534
+ approvalsReviewer: thread.approvalsReviewer,
535
+ reasoningEffort: thread.effort,
536
+ serviceTier: thread.serviceTier,
537
+ instructionSources: [],
538
+ };
539
+ }
540
+
541
+ /** The thread's settings as Codex 0.155 reports them in `thread/settings/updated`. */
542
+ function threadSettingsOf(thread: FakeThread): Record<string, unknown> {
543
+ return {
544
+ cwd: thread.cwd,
545
+ approvalPolicy: thread.approvalPolicy,
546
+ approvalsReviewer: thread.approvalsReviewer,
547
+ sandboxPolicy: thread.sandbox,
548
+ activePermissionProfile: null,
549
+ model: thread.model,
550
+ modelProvider: 'openai',
551
+ serviceTier: thread.serviceTier,
552
+ effort: thread.effort,
553
+ summary: thread.summary,
554
+ collaborationMode: { mode: thread.collaborationMode, settings: { model: thread.model, reasoning_effort: thread.effort, developer_instructions: null } },
555
+ personality: null,
556
+ };
557
+ }
558
+
559
+ function modeOfSandbox(sandbox: CodexSandboxPolicy): string {
560
+ if (sandbox.type === 'readOnly') return 'read-only';
561
+ if (sandbox.type === 'workspaceWrite') return 'workspace-write';
562
+ return 'danger-full-access';
563
+ }
564
+
565
+ function sandboxFromMode(mode: unknown): CodexSandboxPolicy | undefined {
566
+ if (mode === 'read-only') return { type: 'readOnly', networkAccess: false };
567
+ if (mode === 'danger-full-access') return { type: 'dangerFullAccess' };
568
+ if (mode === 'workspace-write') return { type: 'workspaceWrite' };
569
+ return undefined;
570
+ }
571
+
572
+ function mcpOf(config: unknown): FakeThread['mcp'] {
573
+ const servers = (config as { mcp_servers?: Record<string, { url?: string; http_headers?: Record<string, string> }> } | undefined)?.mcp_servers;
574
+ const moxxy = servers ? servers.moxxy : undefined;
575
+ return moxxy && typeof moxxy.url === 'string' ? { url: moxxy.url, headers: moxxy.http_headers ?? {} } : undefined;
576
+ }