@galaxy-yearn/codex-deepseek-gateway 0.1.3 → 0.1.4
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/README.md +49 -7
- package/bin/codex-deepseek-gateway.js +8 -8
- package/package.json +1 -1
- package/src/codex-launch.js +233 -0
- package/src/codex-sessions.js +42 -182
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Codex DeepSeek Gateway
|
|
2
2
|
|
|
3
|
-
A small local gateway for using DeepSeek
|
|
3
|
+
A small local gateway for using DeepSeek models from Codex.
|
|
4
4
|
|
|
5
5
|
Codex keeps sending OpenAI `Responses API` requests to a local endpoint. The gateway converts those requests to DeepSeek-compatible `Chat Completions`, calls DeepSeek, then converts the result back to Responses JSON or streaming `response.*` events.
|
|
6
6
|
|
|
@@ -43,7 +43,7 @@ Put your DeepSeek API key in `gateway.local.json`:
|
|
|
43
43
|
"upstreamApiKey": "sk-..."
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
`model-aliases.json` controls the gateway-facing model IDs exposed on `GET /v1/models` and used by the `sessions`
|
|
46
|
+
`model-aliases.json` controls the gateway-facing model IDs exposed on `GET /v1/models` and used by the `new` and `sessions` pickers. Edit it only if you want to add or rename model aliases.
|
|
47
47
|
|
|
48
48
|
If the key is already configured, `install` also starts the gateway. If this is your first install, add the key and then run:
|
|
49
49
|
|
|
@@ -97,7 +97,7 @@ Important fields:
|
|
|
97
97
|
- `codexReasoningEffort` should match `model_reasoning_effort`
|
|
98
98
|
- `deepseekThinking` shows the DeepSeek `thinking` payload
|
|
99
99
|
- `deepseekReasoningEffort` shows the DeepSeek effort sent upstream
|
|
100
|
-
- `
|
|
100
|
+
- `reasoningDisplayMode` shows whether reasoning is shown as `summary`, `hidden`, or `disabled`
|
|
101
101
|
- `tavilyWebSearchReady` and `firecrawlWebFetchReady` show whether optional web search backends are usable
|
|
102
102
|
|
|
103
103
|
Avoid running Codex through a proxy while using the local gateway. Some proxy clients intercept `http://127.0.0.1:3000` and can break local gateway requests.
|
|
@@ -125,7 +125,7 @@ The gateway serves these aliases directly on `GET /v1/models`. If you also want
|
|
|
125
125
|
"fetchUpstreamModels": true
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
Whether Codex TUI `/model` shows custom provider models depends on the Codex client build. `config.toml` and
|
|
128
|
+
Whether Codex TUI `/model` shows custom provider models depends on the Codex client build. `config.toml`, `new`, and `sessions` remain the reliable ways to choose a model.
|
|
129
129
|
|
|
130
130
|
## Reasoning
|
|
131
131
|
|
|
@@ -171,6 +171,34 @@ DeepSeek receives compact text context containing the search query, sources, sni
|
|
|
171
171
|
|
|
172
172
|
The web payload is intentionally text-focused. Tavily is called with `include_raw_content: false`; Firecrawl defaults to main content, removes base64 images, rejects local/private URLs, and truncates page text before it reaches the model.
|
|
173
173
|
|
|
174
|
+
## New Conversations
|
|
175
|
+
|
|
176
|
+
Start a new Codex conversation with a temporary gateway model override:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
npx @galaxy-yearn/codex-deepseek-gateway new
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The command first lets you choose a model from `~/.codex/deepseek-gateway/config/model-aliases.json`, then choose Codex reasoning effort, then runs:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
codex -c model_provider=deepseek-gateway -c model=<model> -c model_reasoning_effort=<effort>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
This does not edit `~/.codex/config.toml`; the `model_providers.deepseek-gateway` provider entry still needs to exist there.
|
|
189
|
+
|
|
190
|
+
You can skip the picker:
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
npx @galaxy-yearn/codex-deepseek-gateway new --model deepseek-v4-flash --reasoning-effort low
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Print the generated launch command instead of running Codex:
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
npx @galaxy-yearn/codex-deepseek-gateway new --print
|
|
200
|
+
```
|
|
201
|
+
|
|
174
202
|
## Sessions
|
|
175
203
|
|
|
176
204
|
Open a cross-provider session picker from a project:
|
|
@@ -189,8 +217,8 @@ Flow:
|
|
|
189
217
|
|
|
190
218
|
- choose a model from `~/.codex/deepseek-gateway/config/model-aliases.json`
|
|
191
219
|
- choose Codex reasoning effort
|
|
192
|
-
- choose the session to resume
|
|
193
|
-
- use
|
|
220
|
+
- choose `[New conversation]` or the session to resume
|
|
221
|
+
- use `↑/↓` to select, `Enter` to confirm, `←` to go back, and `Esc` to quit
|
|
194
222
|
|
|
195
223
|
Print copyable resume commands instead of opening the picker:
|
|
196
224
|
|
|
@@ -204,6 +232,18 @@ Include sessions outside the current project:
|
|
|
204
232
|
npx @galaxy-yearn/codex-deepseek-gateway sessions --all
|
|
205
233
|
```
|
|
206
234
|
|
|
235
|
+
Resume a matching session directly by row number or unique session id prefix:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions --exec <id-or-row>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Limit how many session rows are shown or offered in the picker:
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
npx @galaxy-yearn/codex-deepseek-gateway sessions --limit 50
|
|
245
|
+
```
|
|
246
|
+
|
|
207
247
|
The command does not edit session files, change provider ownership, or change Codex's native resume picker filters. It only helps you find the hidden session id and resume it with explicit config overrides.
|
|
208
248
|
|
|
209
249
|
## Commands
|
|
@@ -214,6 +254,7 @@ npx @galaxy-yearn/codex-deepseek-gateway start
|
|
|
214
254
|
npx @galaxy-yearn/codex-deepseek-gateway stop
|
|
215
255
|
npx @galaxy-yearn/codex-deepseek-gateway status
|
|
216
256
|
npx @galaxy-yearn/codex-deepseek-gateway doctor
|
|
257
|
+
npx @galaxy-yearn/codex-deepseek-gateway new
|
|
217
258
|
npx @galaxy-yearn/codex-deepseek-gateway sessions
|
|
218
259
|
npx @galaxy-yearn/codex-deepseek-gateway uninstall
|
|
219
260
|
```
|
|
@@ -233,6 +274,7 @@ If `start` returns without visible output on your terminal, run `status`; `"reac
|
|
|
233
274
|
- lightweight local `previous_response_id` / `conversation` history while the gateway process is running
|
|
234
275
|
- `GET /v1/models` with local model aliases and optional upstream discovery
|
|
235
276
|
- optional Tavily/Firecrawl-backed `web_search` emulation
|
|
277
|
+
- new-conversation launcher with per-run Codex config overrides
|
|
236
278
|
- read-only cross-provider session picker
|
|
237
279
|
|
|
238
280
|
## Limits
|
|
@@ -244,7 +286,7 @@ Chat Completions is not a full Responses API replacement. Some Responses feature
|
|
|
244
286
|
- URL citations are returned in the Responses metadata path. Whether they appear as clickable links in the terminal depends on the Codex client build and how it renders custom-provider citation annotations.
|
|
245
287
|
- OpenAI `file_id` values are passed through; the gateway cannot fetch private OpenAI-hosted files.
|
|
246
288
|
- In-memory conversation history is lost when the gateway restarts.
|
|
247
|
-
- The gateway exposes model aliases on `/v1/models`, including aliases from `config/model-aliases.json`. Whether Codex TUI `/model` actually shows custom provider models depends on the Codex build. `config.toml`
|
|
289
|
+
- The gateway exposes model aliases on `/v1/models`, including aliases from `config/model-aliases.json`. Whether Codex TUI `/model` actually shows custom provider models depends on the Codex build. `config.toml`, `new`, and `sessions` remain the reliable ways to choose a model.
|
|
248
290
|
|
|
249
291
|
## License
|
|
250
292
|
|
|
@@ -13,6 +13,7 @@ import { createRequire } from 'node:module';
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { loadConfig } from '../src/config.js';
|
|
16
|
+
import { newConversation } from '../src/codex-launch.js';
|
|
16
17
|
import { sessions } from '../src/codex-sessions.js';
|
|
17
18
|
import { toProviderChatCompletionsRequest } from '../src/protocol.js';
|
|
18
19
|
|
|
@@ -33,6 +34,7 @@ Usage:
|
|
|
33
34
|
codex-deepseek-gateway stop
|
|
34
35
|
codex-deepseek-gateway status
|
|
35
36
|
codex-deepseek-gateway doctor
|
|
37
|
+
codex-deepseek-gateway new
|
|
36
38
|
codex-deepseek-gateway sessions
|
|
37
39
|
codex-deepseek-gateway uninstall
|
|
38
40
|
|
|
@@ -40,13 +42,14 @@ Options:
|
|
|
40
42
|
--dir <path> Install directory, defaults to ~/.codex/deepseek-gateway
|
|
41
43
|
--no-edit Do not open the local config file after install
|
|
42
44
|
--all With sessions, include sessions outside the current project
|
|
43
|
-
--provider <id> With sessions, target model_provider override
|
|
44
|
-
--model <id> With sessions, target model override
|
|
45
|
+
--provider <id> With new/sessions, target model_provider override
|
|
46
|
+
--model <id> With new/sessions, target model override
|
|
45
47
|
--reasoning-effort <level>
|
|
46
|
-
With sessions, target Codex reasoning effort
|
|
48
|
+
With new/sessions, target Codex reasoning effort
|
|
47
49
|
--exec <id> With sessions, run the generated codex resume command
|
|
48
50
|
--limit <n> With sessions, max rows to print, defaults to 20
|
|
49
|
-
--print With
|
|
51
|
+
--print With new, print the launch command. With sessions, print
|
|
52
|
+
copyable resume commands instead of picker
|
|
50
53
|
`);
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -363,7 +366,6 @@ async function doctor(options) {
|
|
|
363
366
|
: thinkingEnabled
|
|
364
367
|
? 'summary'
|
|
365
368
|
: 'disabled';
|
|
366
|
-
const codexSummaryConfigured = supportsReasoningSummaries && summaryMode && summaryMode !== 'none';
|
|
367
369
|
print(JSON.stringify({
|
|
368
370
|
packageVersion: packageJson.version,
|
|
369
371
|
installDir: options.dir,
|
|
@@ -380,8 +382,6 @@ async function doctor(options) {
|
|
|
380
382
|
deepseekThinking: upstreamRequest.thinking || null,
|
|
381
383
|
deepseekReasoningEffort: upstreamRequest.reasoning_effort || null,
|
|
382
384
|
reasoningDisplayMode,
|
|
383
|
-
gatewayEmitsReasoningSummary: reasoningDisplayMode === 'summary',
|
|
384
|
-
codexSummaryConfigured,
|
|
385
385
|
tavilyWebSearchEnabled: Boolean(config.tavilyWebSearchEnabled),
|
|
386
386
|
tavilyWebSearchReady: Boolean(config.tavilyWebSearchEnabled && config.tavilyApiKey),
|
|
387
387
|
firecrawlWebFetchEnabled: Boolean(config.firecrawlWebFetchEnabled),
|
|
@@ -404,7 +404,7 @@ async function main() {
|
|
|
404
404
|
usage();
|
|
405
405
|
return;
|
|
406
406
|
}
|
|
407
|
-
const commands = { install, start, stop, status, doctor, sessions, uninstall };
|
|
407
|
+
const commands = { install, start, stop, status, doctor, new: newConversation, sessions, uninstall };
|
|
408
408
|
const handler = commands[command];
|
|
409
409
|
if (!handler) {
|
|
410
410
|
throw new Error(`Unknown command: ${command}`);
|
package/package.json
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { clearScreenDown, cursorTo } from 'node:readline';
|
|
5
|
+
import { readCodexConfig } from './codex-config.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PROVIDER = 'deepseek-gateway';
|
|
8
|
+
export const REASONING_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
|
|
9
|
+
const SELECTED_ROW = '\x1b[38;5;81m';
|
|
10
|
+
const RESET_STYLE = '\x1b[0m';
|
|
11
|
+
|
|
12
|
+
function print(message = '') {
|
|
13
|
+
process.stdout.write(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function defaultCodexHome() {
|
|
17
|
+
return process.env.CODEX_HOME || join(process.env.USERPROFILE || process.env.HOME || process.cwd(), '.codex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findProjectRoot(start) {
|
|
21
|
+
let current = resolve(start);
|
|
22
|
+
while (true) {
|
|
23
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
24
|
+
const parent = dirname(current);
|
|
25
|
+
if (parent === current) return resolve(start);
|
|
26
|
+
current = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJsonObject(file) {
|
|
31
|
+
if (!existsSync(file)) return {};
|
|
32
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
33
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function gatewayModels(installDir) {
|
|
37
|
+
return Object.keys(readJsonObject(join(installDir, 'config', 'model-aliases.json'))).sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createLaunchContext(options) {
|
|
41
|
+
const codexHome = defaultCodexHome();
|
|
42
|
+
const models = gatewayModels(options.dir);
|
|
43
|
+
const codexConfig = readCodexConfig();
|
|
44
|
+
const configModel = codexConfig.modelProvider === DEFAULT_PROVIDER && models.includes(codexConfig.model) ? codexConfig.model : '';
|
|
45
|
+
return {
|
|
46
|
+
all: options.all,
|
|
47
|
+
codexHome,
|
|
48
|
+
installDir: options.dir,
|
|
49
|
+
models,
|
|
50
|
+
projectRoot: findProjectRoot(process.cwd()),
|
|
51
|
+
provider: options.provider || DEFAULT_PROVIDER,
|
|
52
|
+
model: options.model || configModel || models[0] || '',
|
|
53
|
+
reasoningEffort: options.reasoningEffort || codexConfig.modelReasoningEffort || 'low',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function missingModelMessage(context) {
|
|
58
|
+
return `No gateway models found in ${join(context.installDir, 'config', 'model-aliases.json')}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function configOverrideArgs(context) {
|
|
62
|
+
return [
|
|
63
|
+
'-c',
|
|
64
|
+
`model_provider=${JSON.stringify(context.provider)}`,
|
|
65
|
+
'-c',
|
|
66
|
+
`model=${JSON.stringify(context.model)}`,
|
|
67
|
+
'-c',
|
|
68
|
+
`model_reasoning_effort=${JSON.stringify(context.reasoningEffort)}`,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function configOverrideCommandParts(context) {
|
|
73
|
+
return [
|
|
74
|
+
'-c',
|
|
75
|
+
`model_provider=${context.provider}`,
|
|
76
|
+
'-c',
|
|
77
|
+
`model=${context.model}`,
|
|
78
|
+
'-c',
|
|
79
|
+
`model_reasoning_effort=${context.reasoningEffort}`,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function codexNewArgs(context) {
|
|
84
|
+
return configOverrideArgs(context);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function codexResumeArgs(sessionId, context) {
|
|
88
|
+
return ['resume', sessionId, ...configOverrideArgs(context)];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function codexNewCommand(context) {
|
|
92
|
+
return ['codex', ...configOverrideCommandParts(context)].join(' ');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function codexResumeCommand(sessionId, context) {
|
|
96
|
+
return ['codex', 'resume', sessionId, ...configOverrideCommandParts(context)].join(' ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function runCodex(args) {
|
|
100
|
+
const child = spawn('codex', args, {
|
|
101
|
+
stdio: 'inherit',
|
|
102
|
+
shell: process.platform === 'win32',
|
|
103
|
+
});
|
|
104
|
+
process.exitCode = await new Promise((resolveChild) => {
|
|
105
|
+
child.on('exit', (exitCode) => resolveChild(exitCode ?? 0));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rowOffset(header) {
|
|
110
|
+
return header ? 4 : 2;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderRow(state, index) {
|
|
114
|
+
cursorTo(process.stdout, 0, rowOffset(state.header) + index);
|
|
115
|
+
const row = `${index === state.selected ? '>' : ' '} ${state.rows[index]}`;
|
|
116
|
+
const styled = index === state.selected ? `${SELECTED_ROW}${row}${RESET_STYLE}` : row;
|
|
117
|
+
print(`\x1b[2K${styled}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderPicker(state) {
|
|
121
|
+
const { title, rows, selected, header } = state;
|
|
122
|
+
cursorTo(process.stdout, 0, 0);
|
|
123
|
+
clearScreenDown(process.stdout);
|
|
124
|
+
let output = `${title}\n\n`;
|
|
125
|
+
if (header) output += ` ${header}\n ${'-'.repeat(header.length)}\n`;
|
|
126
|
+
for (const [index] of rows.entries()) {
|
|
127
|
+
const row = `${index === selected ? '>' : ' '} ${rows[index]}`;
|
|
128
|
+
output += `${index === selected ? `${SELECTED_ROW}${row}${RESET_STYLE}` : row}\n`;
|
|
129
|
+
}
|
|
130
|
+
output += '\n↑/↓ select Enter confirm ← back Esc quit\n';
|
|
131
|
+
print(output);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function openPickerScreen() {
|
|
135
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function closePickerScreen() {
|
|
139
|
+
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function pick(title, rows, header = '') {
|
|
143
|
+
if (!rows.length) return { action: 'back' };
|
|
144
|
+
let selected = 0;
|
|
145
|
+
const stdin = process.stdin;
|
|
146
|
+
const state = { title, rows, selected, header };
|
|
147
|
+
renderPicker(state);
|
|
148
|
+
|
|
149
|
+
return await new Promise((resolvePick) => {
|
|
150
|
+
const done = (result) => {
|
|
151
|
+
stdin.off('data', onData);
|
|
152
|
+
resolvePick(result);
|
|
153
|
+
};
|
|
154
|
+
const onData = (chunk) => {
|
|
155
|
+
const key = chunk.toString('utf8');
|
|
156
|
+
if (key === '\u0003' || key === '\u001b') return done({ action: 'cancel' });
|
|
157
|
+
if (key === '\u001b[D') return done({ action: 'back' });
|
|
158
|
+
if (key === '\r' || key === '\n') return done({ action: 'select', index: selected });
|
|
159
|
+
const previous = selected;
|
|
160
|
+
if (key === '\u001b[A') selected = Math.max(0, selected - 1);
|
|
161
|
+
else if (key === '\u001b[B') selected = Math.min(rows.length - 1, selected + 1);
|
|
162
|
+
else return;
|
|
163
|
+
if (selected === previous) return;
|
|
164
|
+
state.selected = selected;
|
|
165
|
+
renderRow(state, previous);
|
|
166
|
+
renderRow(state, selected);
|
|
167
|
+
};
|
|
168
|
+
stdin.on('data', onData);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function withPickerScreen(callback) {
|
|
173
|
+
const stdin = process.stdin;
|
|
174
|
+
stdin.resume();
|
|
175
|
+
stdin.setRawMode(true);
|
|
176
|
+
openPickerScreen();
|
|
177
|
+
try {
|
|
178
|
+
return await callback();
|
|
179
|
+
} finally {
|
|
180
|
+
stdin.setRawMode(false);
|
|
181
|
+
stdin.pause();
|
|
182
|
+
closePickerScreen();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function pickModel(context) {
|
|
187
|
+
const result = await pick('Choose gateway model', context.models);
|
|
188
|
+
if (result.action === 'select') context.model = context.models[result.index];
|
|
189
|
+
return result.action;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function pickReasoning(context) {
|
|
193
|
+
const result = await pick(`Choose Codex reasoning effort for ${context.model}`, REASONING_EFFORTS);
|
|
194
|
+
if (result.action === 'select') context.reasoningEffort = REASONING_EFFORTS[result.index];
|
|
195
|
+
return result.action;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function chooseLaunchContext(context) {
|
|
199
|
+
return await withPickerScreen(async () => {
|
|
200
|
+
let step = 'model';
|
|
201
|
+
while (true) {
|
|
202
|
+
if (step === 'model') {
|
|
203
|
+
const action = await pickModel(context);
|
|
204
|
+
if (action !== 'select') return false;
|
|
205
|
+
step = 'reasoning';
|
|
206
|
+
} else {
|
|
207
|
+
const action = await pickReasoning(context);
|
|
208
|
+
if (action === 'cancel') return false;
|
|
209
|
+
if (action === 'back') step = 'model';
|
|
210
|
+
else return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function newConversation(options) {
|
|
217
|
+
const context = createLaunchContext(options);
|
|
218
|
+
if (!context.model) {
|
|
219
|
+
print(missingModelMessage(context));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const canPick = process.stdin.isTTY && process.stdout.isTTY;
|
|
223
|
+
const hasLaunchOverrides = options.provider || options.model || options.reasoningEffort;
|
|
224
|
+
if (!hasLaunchOverrides && !options.print && canPick) {
|
|
225
|
+
const selected = await chooseLaunchContext(context);
|
|
226
|
+
if (!selected) return;
|
|
227
|
+
}
|
|
228
|
+
if (options.print || (!hasLaunchOverrides && !canPick)) {
|
|
229
|
+
print(`${codexNewCommand(context)}\n`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
await runCodex(codexNewArgs(context));
|
|
233
|
+
}
|
package/src/codex-sessions.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import {
|
|
3
2
|
closeSync,
|
|
4
3
|
existsSync,
|
|
@@ -7,27 +6,32 @@ import {
|
|
|
7
6
|
readdirSync,
|
|
8
7
|
readSync,
|
|
9
8
|
} from 'node:fs';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
codexNewArgs,
|
|
12
|
+
codexResumeArgs,
|
|
13
|
+
codexResumeCommand,
|
|
14
|
+
createLaunchContext,
|
|
15
|
+
missingModelMessage,
|
|
16
|
+
pick,
|
|
17
|
+
pickModel,
|
|
18
|
+
pickReasoning,
|
|
19
|
+
runCodex,
|
|
20
|
+
withPickerScreen,
|
|
21
|
+
} from './codex-launch.js';
|
|
13
22
|
|
|
14
|
-
const DEFAULT_PROVIDER = 'deepseek-gateway';
|
|
15
|
-
const REASONING_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
|
|
16
23
|
const TIME_WIDTH = 10;
|
|
17
|
-
const PROVIDER_WIDTH =
|
|
24
|
+
const PROVIDER_WIDTH = 17;
|
|
18
25
|
const ID_WIDTH = 36;
|
|
19
26
|
const TITLE_WIDTH = 16;
|
|
20
27
|
const TABLE_INDENT = ' ';
|
|
21
|
-
const COLUMN_GAP = '
|
|
28
|
+
const COLUMN_GAP = ' ';
|
|
29
|
+
const NEW_SESSION_ROW = '[New conversation]';
|
|
22
30
|
|
|
23
31
|
function print(message = '') {
|
|
24
32
|
process.stdout.write(message);
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
function defaultCodexHome() {
|
|
28
|
-
return process.env.CODEX_HOME || join(process.env.USERPROFILE || process.env.HOME || process.cwd(), '.codex');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
35
|
function readStart(file, bytes = 256 * 1024) {
|
|
32
36
|
const handle = openSync(file, 'r');
|
|
33
37
|
try {
|
|
@@ -107,16 +111,6 @@ function normalizePath(path) {
|
|
|
107
111
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
function findProjectRoot(start) {
|
|
111
|
-
let current = resolve(start);
|
|
112
|
-
while (true) {
|
|
113
|
-
if (existsSync(join(current, '.git'))) return current;
|
|
114
|
-
const parent = dirname(current);
|
|
115
|
-
if (parent === current) return resolve(start);
|
|
116
|
-
current = parent;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
114
|
function isInsideProject(cwd, projectRoot) {
|
|
121
115
|
if (!cwd) return false;
|
|
122
116
|
const sessionCwd = normalizePath(cwd);
|
|
@@ -138,31 +132,6 @@ function readSession(file, indexById) {
|
|
|
138
132
|
};
|
|
139
133
|
}
|
|
140
134
|
|
|
141
|
-
function shellQuoteTomlString(key, value) {
|
|
142
|
-
return `-c ${key}=${value}`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function resumeCommand(sessionId, context) {
|
|
146
|
-
return [
|
|
147
|
-
'codex',
|
|
148
|
-
'resume',
|
|
149
|
-
sessionId,
|
|
150
|
-
shellQuoteTomlString('model_provider', context.provider),
|
|
151
|
-
shellQuoteTomlString('model', context.model),
|
|
152
|
-
shellQuoteTomlString('model_reasoning_effort', context.reasoningEffort),
|
|
153
|
-
].join(' ');
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function readJsonObject(file) {
|
|
157
|
-
if (!existsSync(file)) return {};
|
|
158
|
-
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
159
|
-
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function gatewayModels(installDir) {
|
|
163
|
-
return Object.keys(readJsonObject(join(installDir, 'config', 'model-aliases.json'))).sort();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
135
|
function resolveSessionSelection(selection, sessionsList) {
|
|
167
136
|
if (/^\d+$/.test(selection)) {
|
|
168
137
|
const byIndex = sessionsList[Number(selection) - 1];
|
|
@@ -172,29 +141,6 @@ function resolveSessionSelection(selection, sessionsList) {
|
|
|
172
141
|
return matches.length === 1 ? matches[0] : null;
|
|
173
142
|
}
|
|
174
143
|
|
|
175
|
-
function resumeArgs(session, context) {
|
|
176
|
-
return [
|
|
177
|
-
'resume',
|
|
178
|
-
session.id,
|
|
179
|
-
'-c',
|
|
180
|
-
`model_provider=${JSON.stringify(context.provider)}`,
|
|
181
|
-
'-c',
|
|
182
|
-
`model=${JSON.stringify(context.model)}`,
|
|
183
|
-
'-c',
|
|
184
|
-
`model_reasoning_effort=${JSON.stringify(context.reasoningEffort)}`,
|
|
185
|
-
];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function runCodexResume(session, context) {
|
|
189
|
-
const child = spawn('codex', resumeArgs(session, context), {
|
|
190
|
-
stdio: 'inherit',
|
|
191
|
-
shell: process.platform === 'win32',
|
|
192
|
-
});
|
|
193
|
-
process.exitCode = await new Promise((resolveChild) => {
|
|
194
|
-
child.on('exit', (exitCode) => resolveChild(exitCode ?? 0));
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
144
|
function printSessions(sessionsList, options, context) {
|
|
199
145
|
const listed = sessionsList.slice(0, options.limit || 20);
|
|
200
146
|
print(`Codex sessions ${options.all ? `under ${context.codexHome}` : `for project ${context.projectRoot}`}\n`);
|
|
@@ -204,11 +150,11 @@ function printSessions(sessionsList, options, context) {
|
|
|
204
150
|
return;
|
|
205
151
|
}
|
|
206
152
|
print(`${TABLE_INDENT}${sessionHeader()}\n`);
|
|
207
|
-
print(`${TABLE_INDENT}${'-'.repeat(
|
|
153
|
+
print(`${TABLE_INDENT}${'-'.repeat(sessionHeader().length)}\n`);
|
|
208
154
|
listed.forEach((session, index) => {
|
|
209
155
|
print(`${String(index + 1).padStart(2, ' ')} ${sessionRow(session)}\n`);
|
|
210
156
|
if (options.all) print(` cwd: ${session.cwd || '(unknown cwd)'}\n`);
|
|
211
|
-
print(` resume: ${
|
|
157
|
+
print(` resume: ${codexResumeCommand(session.id, context)}\n\n`);
|
|
212
158
|
});
|
|
213
159
|
if (sessionsList.length > listed.length) print(`Showing ${listed.length} of ${sessionsList.length}. Use --limit ${sessionsList.length} or --all as needed.\n`);
|
|
214
160
|
}
|
|
@@ -225,134 +171,47 @@ function sessionRows(sessionsList) {
|
|
|
225
171
|
return sessionsList.map((session) => sessionRow(session));
|
|
226
172
|
}
|
|
227
173
|
|
|
228
|
-
function rowOffset(header) {
|
|
229
|
-
return header ? 4 : 2;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function renderRow(state, index) {
|
|
233
|
-
cursorTo(process.stdout, 0, rowOffset(state.header) + index);
|
|
234
|
-
clearLine(process.stdout, 0);
|
|
235
|
-
print(`${index === state.selected ? '>' : ' '} ${state.rows[index]}`);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function renderPicker(state) {
|
|
239
|
-
const { title, rows, selected, header } = state;
|
|
240
|
-
cursorTo(process.stdout, 0, 0);
|
|
241
|
-
clearScreenDown(process.stdout);
|
|
242
|
-
let output = `${title}\n\n`;
|
|
243
|
-
if (header) output += ` ${header}\n ${'-'.repeat(header.length)}\n`;
|
|
244
|
-
for (const [index, row] of rows.entries()) output += `${index === selected ? '>' : ' '} ${row}\n`;
|
|
245
|
-
output += '\n↑/↓ select Enter confirm ← back Esc quit\n';
|
|
246
|
-
print(output);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function openPickerScreen() {
|
|
250
|
-
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function closePickerScreen() {
|
|
254
|
-
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
async function pick(title, rows, header = '') {
|
|
258
|
-
if (!rows.length) return { action: 'back' };
|
|
259
|
-
let selected = 0;
|
|
260
|
-
const stdin = process.stdin;
|
|
261
|
-
const state = { title, rows, selected, header };
|
|
262
|
-
renderPicker(state);
|
|
263
|
-
|
|
264
|
-
return await new Promise((resolvePick) => {
|
|
265
|
-
const done = (result) => {
|
|
266
|
-
stdin.off('data', onData);
|
|
267
|
-
resolvePick(result);
|
|
268
|
-
};
|
|
269
|
-
const onData = (chunk) => {
|
|
270
|
-
const key = chunk.toString('utf8');
|
|
271
|
-
if (key === '\u0003' || key === '\u001b') return done({ action: 'cancel' });
|
|
272
|
-
if (key === '\u001b[D') return done({ action: 'back' });
|
|
273
|
-
if (key === '\r' || key === '\n') return done({ action: 'select', index: selected });
|
|
274
|
-
const previous = selected;
|
|
275
|
-
if (key === '\u001b[A') selected = Math.max(0, selected - 1);
|
|
276
|
-
else if (key === '\u001b[B') selected = Math.min(rows.length - 1, selected + 1);
|
|
277
|
-
else return;
|
|
278
|
-
if (selected === previous) return;
|
|
279
|
-
state.selected = selected;
|
|
280
|
-
renderRow(state, previous);
|
|
281
|
-
renderRow(state, selected);
|
|
282
|
-
};
|
|
283
|
-
stdin.on('data', onData);
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
|
|
287
174
|
async function chooseSessionFlow(allSessions, context, limit) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const header = sessionHeader();
|
|
291
|
-
if (!listed.length) {
|
|
292
|
-
print('No matching sessions found.\n');
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
if (!models.length) {
|
|
296
|
-
print(`No gateway models found in ${join(context.installDir, 'config', 'model-aliases.json')}\n`);
|
|
175
|
+
if (!context.models.length) {
|
|
176
|
+
print(missingModelMessage(context));
|
|
297
177
|
return null;
|
|
298
178
|
}
|
|
299
179
|
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
openPickerScreen();
|
|
304
|
-
try {
|
|
180
|
+
const listed = allSessions.slice(0, limit);
|
|
181
|
+
const header = sessionHeader();
|
|
182
|
+
return await withPickerScreen(async () => {
|
|
305
183
|
let step = 'model';
|
|
306
184
|
while (true) {
|
|
307
185
|
if (step === 'model') {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
context.model = models[result.index];
|
|
186
|
+
const action = await pickModel(context);
|
|
187
|
+
if (action !== 'select') return null;
|
|
311
188
|
step = 'reasoning';
|
|
312
189
|
} else if (step === 'reasoning') {
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
315
|
-
if (
|
|
316
|
-
else
|
|
317
|
-
context.reasoningEffort = REASONING_EFFORTS[result.index];
|
|
318
|
-
step = 'session';
|
|
319
|
-
}
|
|
190
|
+
const action = await pickReasoning(context);
|
|
191
|
+
if (action === 'cancel') return null;
|
|
192
|
+
if (action === 'back') step = 'model';
|
|
193
|
+
else step = 'session';
|
|
320
194
|
} else {
|
|
321
|
-
const
|
|
195
|
+
const rows = [NEW_SESSION_ROW, ...sessionRows(listed)];
|
|
196
|
+
const result = await pick(`Choose Codex session for ${context.provider} / ${context.model} / ${context.reasoningEffort}`, rows, header);
|
|
322
197
|
if (result.action === 'cancel') return null;
|
|
323
198
|
if (result.action === 'back') step = 'reasoning';
|
|
324
|
-
else
|
|
199
|
+
else if (result.index === 0) return { newConversation: true };
|
|
200
|
+
else return listed[result.index - 1];
|
|
325
201
|
}
|
|
326
202
|
}
|
|
327
|
-
}
|
|
328
|
-
stdin.setRawMode(false);
|
|
329
|
-
stdin.pause();
|
|
330
|
-
closePickerScreen();
|
|
331
|
-
}
|
|
203
|
+
});
|
|
332
204
|
}
|
|
333
205
|
|
|
334
206
|
export async function sessions(options) {
|
|
335
|
-
const
|
|
336
|
-
const sessionsDir = join(codexHome, 'sessions');
|
|
337
|
-
const indexById = readSessionIndex(codexHome);
|
|
338
|
-
const projectRoot = findProjectRoot(process.cwd());
|
|
339
|
-
const codexConfig = readCodexConfig();
|
|
340
|
-
const models = gatewayModels(options.dir);
|
|
341
|
-
const configModel = codexConfig.modelProvider === DEFAULT_PROVIDER && models.includes(codexConfig.model) ? codexConfig.model : '';
|
|
342
|
-
const context = {
|
|
343
|
-
all: options.all,
|
|
344
|
-
codexHome,
|
|
345
|
-
installDir: options.dir,
|
|
346
|
-
projectRoot,
|
|
347
|
-
provider: options.provider || DEFAULT_PROVIDER,
|
|
348
|
-
model: options.model || configModel || models[0] || '',
|
|
349
|
-
reasoningEffort: options.reasoningEffort || codexConfig.modelReasoningEffort || 'low',
|
|
350
|
-
};
|
|
207
|
+
const context = createLaunchContext(options);
|
|
208
|
+
const sessionsDir = join(context.codexHome, 'sessions');
|
|
209
|
+
const indexById = readSessionIndex(context.codexHome);
|
|
351
210
|
const seen = new Map();
|
|
352
211
|
|
|
353
212
|
for (const file of walkJsonl(sessionsDir)) {
|
|
354
213
|
const session = readSession(file, indexById);
|
|
355
|
-
if (!session || (!options.all && !isInsideProject(session.cwd, projectRoot))) continue;
|
|
214
|
+
if (!session || (!options.all && !isInsideProject(session.cwd, context.projectRoot))) continue;
|
|
356
215
|
const prior = seen.get(session.id);
|
|
357
216
|
if (!prior || session.updatedAt > prior.updatedAt) seen.set(session.id, session);
|
|
358
217
|
}
|
|
@@ -361,7 +220,7 @@ export async function sessions(options) {
|
|
|
361
220
|
if (options.exec) {
|
|
362
221
|
const session = resolveSessionSelection(options.exec, allSessions);
|
|
363
222
|
if (!session) throw new Error(`Session not found or ambiguous: ${options.exec}`);
|
|
364
|
-
await
|
|
223
|
+
await runCodex(codexResumeArgs(session.id, context));
|
|
365
224
|
return;
|
|
366
225
|
}
|
|
367
226
|
|
|
@@ -370,6 +229,7 @@ export async function sessions(options) {
|
|
|
370
229
|
return;
|
|
371
230
|
}
|
|
372
231
|
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
232
|
+
const selected = await chooseSessionFlow(allSessions, context, options.limit || 20);
|
|
233
|
+
if (selected?.newConversation) await runCodex(codexNewArgs(context));
|
|
234
|
+
else if (selected) await runCodex(codexResumeArgs(selected.id, context));
|
|
375
235
|
}
|