@moxxy/plugin-provider-codex-agent 0.0.0-stage → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/src/constants.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Provider name in the registry / model picker / provider config. */
|
|
2
|
+
export const CODEX_AGENT_PROVIDER_ID = 'codex-agent';
|
|
3
|
+
/** The agent's name as the person knows it. */
|
|
4
|
+
export const CODEX_AGENT_NAME = 'Codex';
|
|
5
|
+
|
|
6
|
+
/** Human-readable upstream service, shown in login and setup surfaces. */
|
|
7
|
+
export const CODEX_SERVICE_NAME = 'ChatGPT subscription (Codex)';
|
|
8
|
+
|
|
9
|
+
/** Optional executable override for login/status surfaces that do not carry provider config. */
|
|
10
|
+
export const CODEX_EXECUTABLE_ENV = 'CODEX_EXECUTABLE';
|
|
11
|
+
|
|
12
|
+
/** The name Moxxy's MCP server has in Codex's thread config. */
|
|
13
|
+
export const MOXXY_MCP_SERVER = 'moxxy';
|
|
14
|
+
|
|
15
|
+
export const CODEX_APP_SERVER_ARGS = ['app-server'] as const;
|
|
16
|
+
export const CODEX_LOGIN_STATUS_ARGS = ['login', 'status'] as const;
|
|
17
|
+
export const CODEX_LOGIN_ARGS = ['login'] as const;
|
|
18
|
+
export const CODEX_LOGOUT_ARGS = ['logout'] as const;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything `codex app-server` 0.163.0 can send (0.155.1, the oldest supported, sends a part of it) — its generated protocol types,
|
|
3
|
+
* listed in fixtures/ — checked against what Moxxy does with it. An item shows
|
|
4
|
+
* in the chat or is left out on purpose, a notification is read or left alone
|
|
5
|
+
* on purpose, a request always gets an answer. A newer Codex brings a new
|
|
6
|
+
* fixture, and each kind it adds becomes a decision here instead of something
|
|
7
|
+
* lost without a trace.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import type { PermissionResolver, ToolImage } from '@moxxy/sdk';
|
|
12
|
+
import { collectTurn, type Session } from '@moxxy/core';
|
|
13
|
+
import { createFakeSession } from '@moxxy/testing';
|
|
14
|
+
import { defaultModePlugin } from '@moxxy/mode-default';
|
|
15
|
+
import { AgentSessionPool } from '@moxxy/agent-connection-kit';
|
|
16
|
+
import { CodexAgentProvider } from './agent-provider.js';
|
|
17
|
+
import type { CodexAgentSession } from './codex-session.js';
|
|
18
|
+
import { FakeCodex, type CodexScene } from './fake-codex.testkit.js';
|
|
19
|
+
import { CODEX_ITEMS_LEFT_OUT } from './protocol.js';
|
|
20
|
+
import { TurnTranslator } from './translate.js';
|
|
21
|
+
|
|
22
|
+
interface CodexKinds {
|
|
23
|
+
readonly items: ReadonlyArray<string>;
|
|
24
|
+
readonly notifications: ReadonlyArray<string>;
|
|
25
|
+
readonly requests: ReadonlyArray<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const kinds = JSON.parse(readFileSync(new URL('./fixtures/codex-app-server-0.163.0.json', import.meta.url), 'utf8')) as CodexKinds;
|
|
29
|
+
|
|
30
|
+
/** One item of every kind, shaped as Codex 0.155.1–0.163.0 sends it once it ends. */
|
|
31
|
+
const ITEMS: Record<string, Record<string, unknown>> = {
|
|
32
|
+
agentMessage: { text: 'Done.' },
|
|
33
|
+
plan: { text: '1. Read the code' },
|
|
34
|
+
reasoning: { summary: ['Looking at the files'], content: [] },
|
|
35
|
+
commandExecution: { command: 'ls', cwd: '/work/app', status: 'completed', aggregatedOutput: 'a.txt', exitCode: 0 },
|
|
36
|
+
fileChange: { status: 'completed', changes: [{ path: '/work/app/a.txt', kind: { type: 'add' }, diff: 'hi\n' }] },
|
|
37
|
+
mcpToolCall: { server: 'docs', tool: 'search', status: 'completed', arguments: { q: 'x' }, result: { content: [] }, error: null },
|
|
38
|
+
webSearch: { query: 'moxxy', action: { type: 'search', query: 'moxxy' } },
|
|
39
|
+
imageView: { path: '/work/app/a.png' },
|
|
40
|
+
sleep: { durationMs: 2000 },
|
|
41
|
+
imageGeneration: { status: 'completed', revisedPrompt: 'A red cube', result: 'iVBORw0KGgo=', failure: null },
|
|
42
|
+
dynamicToolCall: { namespace: null, tool: 'lookup', arguments: { id: 1 }, status: 'completed', contentItems: [{ type: 'inputText', text: 'found' }], success: true, durationMs: 5 },
|
|
43
|
+
enteredReviewMode: { review: 'the current changes' },
|
|
44
|
+
exitedReviewMode: { review: 'Looks fine.' },
|
|
45
|
+
functionCallOutput: { name: 'lookup', namespace: null, output: 'found' },
|
|
46
|
+
userMessage: { clientId: null, content: [{ type: 'text', text: 'hi', text_elements: [] }] },
|
|
47
|
+
hookPrompt: { fragments: [{ text: 'Be brief.', hookRunId: 'h1' }] },
|
|
48
|
+
contextCompaction: {},
|
|
49
|
+
collabAgentToolCall: { tool: 'wait', status: 'completed', senderThreadId: 't0', receiverThreadIds: [], prompt: null, model: null },
|
|
50
|
+
subAgentActivity: { kind: 'started', agentThreadId: 't1', agentPath: '/root/check' },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What Moxxy does with each notification. Those it reads carry the turn's
|
|
55
|
+
* content or the conversation's state; the rest it leaves alone, for the reason given.
|
|
56
|
+
*/
|
|
57
|
+
const SHOWN = 'shown in the chat';
|
|
58
|
+
const STATE = "kept as the conversation's state";
|
|
59
|
+
const NOTIFICATIONS: Record<string, string> = {
|
|
60
|
+
'item/started': SHOWN,
|
|
61
|
+
'item/completed': SHOWN,
|
|
62
|
+
'item/agentMessage/delta': SHOWN,
|
|
63
|
+
'item/reasoning/summaryTextDelta': SHOWN,
|
|
64
|
+
'turn/plan/updated': SHOWN,
|
|
65
|
+
'thread/tokenUsage/updated': SHOWN,
|
|
66
|
+
'account/rateLimits/updated': SHOWN,
|
|
67
|
+
error: SHOWN,
|
|
68
|
+
'turn/started': STATE,
|
|
69
|
+
'turn/completed': STATE,
|
|
70
|
+
'thread/settings/updated': STATE,
|
|
71
|
+
'thread/goal/updated': STATE,
|
|
72
|
+
'thread/goal/cleared': STATE,
|
|
73
|
+
};
|
|
74
|
+
const LEFT_ALONE: ReadonlyArray<readonly [RegExp, string]> = [
|
|
75
|
+
[/^item\/(plan\/delta|reasoning\/(textDelta|summaryPartAdded)|commandExecution\/(outputDelta|terminalInteraction)|fileChange\/(outputDelta|patchUpdated)|mcpToolCall\/progress)$/, 'the item shows whole once it ends'],
|
|
76
|
+
[/^item\/autoApprovalReview\/|^autoApprovalReview\/|^guardianWarning$/, "Codex's own reviewer; the approvals it answers are Codex's"],
|
|
77
|
+
[/^turn\/(diff\/updated|moderationMetadata)$/, 'each file change shows its own diff; moderation stays with Codex'],
|
|
78
|
+
[/^thread\/realtime\//, 'voice sessions, which Moxxy does not start'],
|
|
79
|
+
[/^thread\/(started|status\/changed|name\/updated|archived|unarchived|closed|deleted|compacted|reverted|queue\/changed|readState\/changed|attachment\/updated|project\/updated|environment\/(connected|disconnected))$/, "the thread's housekeeping; Moxxy keeps its own record"],
|
|
80
|
+
[/^(account\/(login\/completed|updated|gatewayOAuth\/changed)|modelProvider\/authRecovery(Started|Completed))$/, 'sign-in, which Moxxy checks itself'],
|
|
81
|
+
[/^thread\/prediction\/updated$/, "Codex's guess at the person's next message, for Codex's own app"],
|
|
82
|
+
[/^(model\/(rerouted|safetyBuffering\/updated|verification)|rawResponse\/completed|rawResponseItem\/completed)$/, "the model's raw stream; the items carry what it did"],
|
|
83
|
+
[/^(warning|configWarning|deprecationNotice|windows\/worldWritableWarning|windowsSandbox\/setupCompleted)$/, "notes about the person's Codex setup, for their own Codex"],
|
|
84
|
+
[/^(mcpServer\/|app\/list\/updated|skills\/changed|serverRequest\/resolved)/, 'servers, apps and skills Codex keeps; its requests are answered as they come'],
|
|
85
|
+
[/^(command\/exec\/outputDelta|process\/(exited|outputDelta)|fs\/changed|fuzzyFileSearch\/|project\/changed|remoteControl\/status\/changed|externalAgentConfig\/import\/|hook\/(started|completed))/, 'calls Moxxy does not make, or work whose result is an item'],
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const sessions: Session[] = [];
|
|
89
|
+
const pools: AgentSessionPool<CodexAgentSession>[] = [];
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await Promise.all(pools.splice(0).map((pool) => pool.closeAll()));
|
|
93
|
+
await Promise.all(sessions.splice(0).map((session) => session.close()));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('every item Codex 0.163.0 can send', () => {
|
|
97
|
+
it('has a sample here, so a kind a newer Codex adds needs a decision', () => {
|
|
98
|
+
expect(Object.keys(ITEMS).sort()).toEqual([...kinds.items]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each(kinds.items)('%s shows in the chat, or is left out on purpose', (type) => {
|
|
102
|
+
const picture: ToolImage = { mediaType: 'image/png', base64: 'AAA' };
|
|
103
|
+
const events = new TurnTranslator('/work/app', () => picture).translate('item/completed', { item: { type, id: `item-${type}`, ...ITEMS[type] } });
|
|
104
|
+
|
|
105
|
+
if (type in CODEX_ITEMS_LEFT_OUT) expect(events).toEqual([]);
|
|
106
|
+
else expect(events.length).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('every notification Codex 0.163.0 can send', () => {
|
|
111
|
+
it.each(kinds.notifications)('%s is read, or left alone for a reason', (method) => {
|
|
112
|
+
const reasons = [NOTIFICATIONS[method], ...LEFT_ALONE.filter(([pattern]) => pattern.test(method)).map(([, reason]) => reason)].filter(Boolean);
|
|
113
|
+
|
|
114
|
+
expect(reasons).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('every request Codex 0.163.0 can make', () => {
|
|
119
|
+
const allowAll: PermissionResolver = { name: 'allow-all', check: async () => ({ mode: 'allow' }) };
|
|
120
|
+
const PARAMS: Record<string, Record<string, unknown>> = {
|
|
121
|
+
'item/commandExecution/requestApproval': { itemId: 'c1', command: 'ls', cwd: '/work/app' },
|
|
122
|
+
'item/fileChange/requestApproval': { itemId: 'f1' },
|
|
123
|
+
'item/permissions/requestApproval': { itemId: 'p1', permissions: { network: { enabled: true } } },
|
|
124
|
+
'item/tool/requestUserInput': { itemId: 'q1', questions: [] },
|
|
125
|
+
'mcpServer/elicitation/request': { serverName: 'moxxy', message: 'Go ahead?' },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
it('gets an answer, so Codex never waits on Moxxy', async () => {
|
|
129
|
+
const answers: Record<string, 'answered' | 'refused'> = {};
|
|
130
|
+
const scene: CodexScene = async (turn) => {
|
|
131
|
+
for (const method of kinds.requests) {
|
|
132
|
+
answers[method] = await turn.request(method, PARAMS[method] ?? {}).then(
|
|
133
|
+
() => 'answered' as const,
|
|
134
|
+
() => 'refused' as const,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
turn.say('Done.');
|
|
138
|
+
};
|
|
139
|
+
const pool = new AgentSessionPool<CodexAgentSession>({ limit: 1 });
|
|
140
|
+
pools.push(pool);
|
|
141
|
+
const codex = new FakeCodex(scene);
|
|
142
|
+
const session = createFakeSession({ provider: new CodexAgentProvider({ startAppServer: codex.start, sessions: pool }), cwd: '/work/app' });
|
|
143
|
+
sessions.push(session);
|
|
144
|
+
session.pluginHost.registerStatic(defaultModePlugin);
|
|
145
|
+
session.setPermissionResolver(allowAll);
|
|
146
|
+
|
|
147
|
+
await collectTurn(session, 'Go');
|
|
148
|
+
|
|
149
|
+
expect(Object.keys(answers).sort()).toEqual([...kinds.requests]);
|
|
150
|
+
for (const method of Object.keys(PARAMS)) expect(answers[method]).toBe('answered');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('an item kind Moxxy does not know', () => {
|
|
155
|
+
it('shows as a call named after it, with what Codex said of it', () => {
|
|
156
|
+
const translator = new TurnTranslator();
|
|
157
|
+
const started = translator.translate('item/started', { item: { type: 'boardUpdate', id: 'b1', status: 'inProgress', board: 'Sprint 4' } });
|
|
158
|
+
const ended = translator.translate('item/completed', { item: { type: 'boardUpdate', id: 'b1', status: 'completed', board: 'Sprint 4', moved: 3 } });
|
|
159
|
+
|
|
160
|
+
expect(started).toEqual([{ type: 'tool_call', callId: 'b1', name: 'boardUpdate', input: { board: 'Sprint 4' } }]);
|
|
161
|
+
expect(ended).toEqual([{ type: 'tool_result', callId: 'b1', ok: true, output: { moved: 3 } }]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('shows as failed when Codex says it failed', () => {
|
|
165
|
+
const [, result] = new TurnTranslator().translate('item/completed', { item: { type: 'boardUpdate', id: 'b2', status: 'failed', board: 'Sprint 4' } });
|
|
166
|
+
|
|
167
|
+
expect(result).toMatchObject({ type: 'tool_result', callId: 'b2', ok: false });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("Codex's own pauses and pictures", () => {
|
|
172
|
+
it('show a pause as a Sleep, like Moxxy\'s own', () => {
|
|
173
|
+
expect(new TurnTranslator().translate('item/completed', { item: { type: 'sleep', id: 's1', durationMs: 2000 } })).toEqual([
|
|
174
|
+
{ type: 'tool_call', callId: 's1', name: 'Sleep', input: { ms: 2000 } },
|
|
175
|
+
{ type: 'tool_result', callId: 's1', ok: true, output: 'Waited 2 s.' },
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('show the picture Codex generated, from the file it saved or from what it sent', () => {
|
|
180
|
+
const saved: ToolImage = { mediaType: 'image/png', base64: 'SAVED' };
|
|
181
|
+
const fromFile = new TurnTranslator('/work/app', (path) => (path === '/work/app/cube.png' ? saved : undefined)).translate('item/completed', {
|
|
182
|
+
item: { type: 'imageGeneration', id: 'g1', status: 'completed', revisedPrompt: 'A red cube', result: 'SENT', failure: null, savedPath: '/work/app/cube.png' },
|
|
183
|
+
});
|
|
184
|
+
const sent = new TurnTranslator().translate('item/completed', {
|
|
185
|
+
item: { type: 'imageGeneration', id: 'g2', status: 'completed', revisedPrompt: null, result: 'SENT', failure: null },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(fromFile[0]).toEqual({ type: 'tool_call', callId: 'g1', name: 'image_generation', input: { prompt: 'A red cube' } });
|
|
189
|
+
expect(fromFile[1]).toMatchObject({ ok: true, output: { forModel: expect.stringContaining('/work/app/cube.png'), mediaType: 'image/png', base64: 'SAVED' } });
|
|
190
|
+
expect(sent[1]).toMatchObject({ ok: true, output: { mediaType: 'image/png', base64: 'SENT' } });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('say why a picture could not be made', () => {
|
|
194
|
+
const [, result] = new TurnTranslator().translate('item/completed', {
|
|
195
|
+
item: { type: 'imageGeneration', id: 'g3', status: 'failed', revisedPrompt: null, result: '', failure: { type: 'usageLimitExceeded', limitId: 'images', resetsAt: null } },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result).toEqual({ type: 'tool_result', callId: 'g3', ok: false, error: 'Codex has used up its limit for pictures.' });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { codexTarget } from './executable.js';
|
|
3
|
+
|
|
4
|
+
describe('starting the person Codex without a shell', () => {
|
|
5
|
+
it('runs Codex from PATH as it is on macOS and Linux', () => {
|
|
6
|
+
expect(codexTarget('codex', { platform: 'darwin', find: () => '/Users/me/.local/bin/codex' })).toEqual({
|
|
7
|
+
kind: 'direct',
|
|
8
|
+
command: '/Users/me/.local/bin/codex',
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('starts the codex.exe behind a Windows npm shim itself, so no console window opens for it', () => {
|
|
13
|
+
const codexPackage = 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex';
|
|
14
|
+
const exe = `${codexPackage}\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe`;
|
|
15
|
+
const target = codexTarget('codex', {
|
|
16
|
+
platform: 'win32',
|
|
17
|
+
arch: 'x64',
|
|
18
|
+
find: () => 'C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd',
|
|
19
|
+
exists: (file) => file === exe,
|
|
20
|
+
});
|
|
21
|
+
expect(target).toEqual({
|
|
22
|
+
kind: 'direct',
|
|
23
|
+
command: exe,
|
|
24
|
+
env: { CODEX_MANAGED_BY_NPM: '1', CODEX_MANAGED_PACKAGE_ROOT: codexPackage },
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('runs the codex.js behind a Windows npm shim with Node when its codex.exe is missing', () => {
|
|
29
|
+
const target = codexTarget('codex', {
|
|
30
|
+
platform: 'win32',
|
|
31
|
+
find: () => 'C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd',
|
|
32
|
+
exists: (file) => file === 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
|
|
33
|
+
electron: true,
|
|
34
|
+
});
|
|
35
|
+
expect(target).toEqual({
|
|
36
|
+
kind: 'node',
|
|
37
|
+
command: process.execPath,
|
|
38
|
+
entry: 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
|
|
39
|
+
env: { ELECTRON_RUN_AS_NODE: '1' },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('runs the standalone codex.exe on Windows as it is', () => {
|
|
44
|
+
expect(codexTarget('codex', { platform: 'win32', find: () => 'C:\\Users\\me\\.codex\\bin\\codex.exe' })).toEqual({
|
|
45
|
+
kind: 'direct',
|
|
46
|
+
command: 'C:\\Users\\me\\.codex\\bin\\codex.exe',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('says so when Codex is not installed', () => {
|
|
51
|
+
expect(() => codexTarget('codex', { find: () => null })).toThrow(/Codex CLI not found/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawnExecutableTarget, type ExecutableTarget } from '@moxxy/sdk/server';
|
|
2
|
+
import { CODEX_CLI, agentCliTargetAt, findAgentCli, stopAgentProcess, type AgentCliTargetOptions } from '@moxxy/agent-connection-kit';
|
|
3
|
+
import { CODEX_APP_SERVER_ARGS } from './constants.js';
|
|
4
|
+
import type { CodexConnection, StartCodexAppServer } from './codex-session.js';
|
|
5
|
+
|
|
6
|
+
export interface CodexTargetOptions extends AgentCliTargetOptions {
|
|
7
|
+
readonly find?: (command: string) => string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How to start the person's Codex without a shell. Behind a Windows npm
|
|
12
|
+
* install's `.cmd` shim that is Codex's own `codex.exe` (its `codex.js` only
|
|
13
|
+
* starts it, in a console window of its own); the standalone installer's
|
|
14
|
+
* `codex.exe` runs as it is.
|
|
15
|
+
*/
|
|
16
|
+
export function codexTarget(command: string, opts: CodexTargetOptions = {}): ExecutableTarget {
|
|
17
|
+
const found = (opts.find ?? ((name) => findAgentCli(CODEX_CLI, { command: name })))(command);
|
|
18
|
+
if (!found) throw new Error(`Codex CLI not found: ${command}. Install Codex or set CODEX_EXECUTABLE.`);
|
|
19
|
+
return agentCliTargetAt(CODEX_CLI, found, opts);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Starts `codex app-server` in the conversation folder, speaking JSON-RPC on stdio. */
|
|
23
|
+
export function spawnCodexAppServer(command: string): StartCodexAppServer {
|
|
24
|
+
return ({ cwd }) =>
|
|
25
|
+
new Promise<CodexConnection>((resolve, reject) => {
|
|
26
|
+
const child = spawnExecutableTarget(codexTarget(command), CODEX_APP_SERVER_ARGS, { cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
27
|
+
const { stdin, stdout, stderr } = child;
|
|
28
|
+
if (!stdin || !stdout || !stderr) {
|
|
29
|
+
stopAgentProcess(child);
|
|
30
|
+
reject(new Error('Codex started without its stdio.'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
child.once('error', reject);
|
|
34
|
+
child.once('spawn', () => {
|
|
35
|
+
child.off('error', reject);
|
|
36
|
+
// A failed write after Codex exits is reported by the closed stdout.
|
|
37
|
+
stdin.on('error', () => undefined);
|
|
38
|
+
resolve({
|
|
39
|
+
readable: stdout,
|
|
40
|
+
writable: stdin,
|
|
41
|
+
stderr,
|
|
42
|
+
close: () => {
|
|
43
|
+
stdin.end();
|
|
44
|
+
stopAgentProcess(child);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|