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