codex-multi-auth 2.23.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.codex-plugin/plugin.json +9 -9
  2. package/LICENSE +21 -21
  3. package/README.md +159 -152
  4. package/assets/codex-multi-auth-icon.svg +39 -39
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +31 -31
  7. package/config/codex-legacy.json +1021 -1021
  8. package/config/codex-modern.json +347 -347
  9. package/config/minimal-codex.json +15 -15
  10. package/dist/index.js +33 -10
  11. package/dist/lib/account-policy.d.ts +2 -0
  12. package/dist/lib/account-policy.js +3 -1
  13. package/dist/lib/accounts/rate-limits.d.ts +6 -0
  14. package/dist/lib/accounts/rate-limits.js +14 -1
  15. package/dist/lib/accounts.d.ts +6 -1
  16. package/dist/lib/accounts.js +33 -8
  17. package/dist/lib/auth/token-utils.d.ts +5 -0
  18. package/dist/lib/auth/token-utils.js +10 -0
  19. package/dist/lib/codex-manager/commands/account.js +15 -0
  20. package/dist/lib/codex-manager/commands/resets.js +18 -5
  21. package/dist/lib/codex-manager/commands/workspace.d.ts +4 -2
  22. package/dist/lib/codex-manager/commands/workspace.js +33 -2
  23. package/dist/lib/codex-manager/help.js +2 -2
  24. package/dist/lib/codex-manager.js +14 -1
  25. package/dist/lib/constants.d.ts +2 -0
  26. package/dist/lib/constants.js +2 -0
  27. package/dist/lib/context-budget-response.js +8 -8
  28. package/dist/lib/context-overflow.js +10 -10
  29. package/dist/lib/oauth-success.html +125 -125
  30. package/dist/lib/policy/runtime-policy-cache.js +1 -1
  31. package/dist/lib/policy/runtime-policy.d.ts +1 -0
  32. package/dist/lib/policy/runtime-policy.js +4 -0
  33. package/dist/lib/preemptive-quota-scheduler.js +12 -3
  34. package/dist/lib/prompts/codex-host-bridge.js +138 -138
  35. package/dist/lib/prompts/codex.js +79 -79
  36. package/dist/lib/quota-cache.d.ts +4 -0
  37. package/dist/lib/quota-readiness.d.ts +1 -1
  38. package/dist/lib/quota-readiness.js +9 -2
  39. package/dist/lib/request/rate-limit-decision.js +1 -1
  40. package/dist/lib/runtime/account-reset-credits.d.ts +11 -2
  41. package/dist/lib/runtime/account-reset-credits.js +35 -12
  42. package/dist/lib/runtime/automatic-account-checks.js +6 -5
  43. package/dist/lib/runtime/automatic-subscription-checks.js +4 -3
  44. package/dist/lib/runtime/model-discovery-status.js +2 -2
  45. package/dist/lib/runtime/reset-credit-routing.d.ts +11 -3
  46. package/dist/lib/runtime/reset-credit-routing.js +66 -21
  47. package/dist/lib/runtime/reset-credits.d.ts +31 -7
  48. package/dist/lib/runtime/reset-credits.js +135 -28
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +3 -0
  50. package/dist/lib/runtime/subscription-quota-order.d.ts +1 -0
  51. package/dist/lib/runtime/subscription-quota-order.js +7 -3
  52. package/dist/lib/runtime/workspace-model-scopes.d.ts +2 -0
  53. package/dist/lib/runtime/workspace-model-scopes.js +14 -4
  54. package/dist/lib/runtime-rotation-proxy.js +108 -29
  55. package/dist/lib/storage/snapshot-merge.js +15 -4
  56. package/dist/lib/web-dashboard-html.js +53 -53
  57. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
  58. package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
  59. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
  60. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
  61. package/node_modules/@codex-ai/plugin/package.json +9 -9
  62. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -4
  63. package/node_modules/@codex-ai/sdk/dist/index.js +2 -2
  64. package/node_modules/@codex-ai/sdk/package.json +8 -8
  65. package/package.json +209 -209
  66. package/scripts/codex-app-launcher.js +653 -653
  67. package/scripts/codex-app-router.js +446 -446
  68. package/scripts/codex-bin-resolver.js +332 -332
  69. package/scripts/codex-multi-auth.js +65 -65
  70. package/scripts/codex-routing.js +63 -63
  71. package/scripts/codex.js +7143 -7143
  72. package/scripts/install-codex-auth-utils.js +213 -213
  73. package/scripts/mcodex.js +367 -367
  74. package/scripts/postinstall.js +114 -114
  75. package/scripts/preuninstall.js +265 -265
  76. package/skills/codex-auth-setup/SKILL.md +73 -73
  77. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
  78. package/vendor/codex-ai-plugin/dist/index.js +2 -2
  79. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
  80. package/vendor/codex-ai-plugin/dist/tool.js +29 -29
  81. package/vendor/codex-ai-plugin/package.json +9 -9
  82. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
  83. package/vendor/codex-ai-sdk/dist/index.js +2 -2
  84. package/vendor/codex-ai-sdk/package.json +8 -8
package/scripts/mcodex.js CHANGED
@@ -1,367 +1,367 @@
1
- #!/usr/bin/env node
2
-
3
- import { spawn, spawnSync } from "node:child_process";
4
- import { existsSync, realpathSync } from "node:fs";
5
- import { dirname, join, resolve as resolvePath } from "node:path";
6
- import process from "node:process";
7
- import { fileURLToPath } from "node:url";
8
- import { splitPathEntries } from "./codex-bin-resolver.js";
9
-
10
- // Cross-platform replacement for the legacy `scripts/mcodex` bash launcher.
11
- // The bash shim was shipped as a Windows `bin`; npm's generated mcodex.cmd/.ps1
12
- // invoked bare `bash`, and when a WSL stub resolved before git-bash on PATH the
13
- // launcher died with HCS_E_SERVICE_NOT_AVAILABLE. This node entry forwards to the
14
- // sibling codex wrapper with zero bash dependency, and only reaches for the POSIX
15
- // tools (`tmux`/`watch`) when they actually exist — otherwise it degrades with the
16
- // same friendly messages the bash version printed.
17
-
18
- const DEFAULT_MONITOR_INTERVAL = "5";
19
- const DEFAULT_TMUX_HISTORY_LIMIT = "50000";
20
- const DEFAULT_TMUX_SESSION = "mcodex";
21
-
22
- // Security: monitor_interval is interpolated into the `watch -n <n> ...` argument
23
- // and tmux_history_limit into a tmux option argument. Require a positive (optionally
24
- // fractional) number / plain integer and fall back to the default otherwise, so no
25
- // shell metacharacters can ever reach a command — preserving the bash hardening.
26
- const MONITOR_INTERVAL_PATTERN = /^[0-9]+(\.[0-9]+)?$/;
27
- const TMUX_HISTORY_LIMIT_PATTERN = /^[0-9]+$/;
28
-
29
- const scriptDir = dirname(fileURLToPath(import.meta.url));
30
- // Spawn the sibling codex wrapper directly through `node` (process.execPath) rather
31
- // than relying on the installed `codex-multi-auth-codex` shim being on PATH. This is
32
- // the same idiom codex-app-launcher.js uses to launch codex.js and is what makes the
33
- // default forward work on Windows without any bash/.cmd resolution.
34
- const codexWrapperScript = join(scriptDir, "codex.js");
35
-
36
- function defaultWarn(message) {
37
- console.error(message);
38
- }
39
-
40
- // When mcodex itself is asked to terminate, forward the signal to the spawned
41
- // child so the forwarded codex.js / watch process doesn't outlive us as an
42
- // orphan. Without this, a SIGTERM to the launcher exits the parent while the
43
- // child keeps running detached. Listeners are registered with `once` and torn
44
- // down when the child settles (returned cleanup), so they never leak across the
45
- // launcher's lifetime. `proc` is injectable so the wiring is unit-testable
46
- // without registering handlers on the real process.
47
- export function relaySignalsToChild(
48
- child,
49
- { proc = process, signals = ["SIGTERM", "SIGINT"] } = {},
50
- ) {
51
- const registered = signals.map((signal) => {
52
- const handler = () => {
53
- try {
54
- child.kill(signal);
55
- } catch {
56
- // Child may have already exited; nothing left to forward.
57
- }
58
- };
59
- proc.once(signal, handler);
60
- return { signal, handler };
61
- });
62
- return function removeSignalRelays() {
63
- for (const { signal, handler } of registered) {
64
- proc.removeListener(signal, handler);
65
- }
66
- };
67
- }
68
-
69
- function coerceValidatedSetting(rawValue, pattern, fallback, envName, warn) {
70
- // Mirror bash `${VAR:-default}`: unset OR empty falls back silently; any other
71
- // value is validated and, if it fails, replaced with the default plus a warning.
72
- const value = rawValue === undefined || rawValue === "" ? fallback : rawValue;
73
- if (!pattern.test(value)) {
74
- warn(`mcodex: invalid ${envName} '${value}'; using ${fallback}`);
75
- return fallback;
76
- }
77
- return value;
78
- }
79
-
80
- export function resolveMonitorInterval(env = process.env, warn = defaultWarn) {
81
- return coerceValidatedSetting(
82
- env.MCODEX_MONITOR_INTERVAL,
83
- MONITOR_INTERVAL_PATTERN,
84
- DEFAULT_MONITOR_INTERVAL,
85
- "MCODEX_MONITOR_INTERVAL",
86
- warn,
87
- );
88
- }
89
-
90
- export function resolveTmuxHistoryLimit(env = process.env, warn = defaultWarn) {
91
- return coerceValidatedSetting(
92
- env.MCODEX_TMUX_HISTORY_LIMIT,
93
- TMUX_HISTORY_LIMIT_PATTERN,
94
- DEFAULT_TMUX_HISTORY_LIMIT,
95
- "MCODEX_TMUX_HISTORY_LIMIT",
96
- warn,
97
- );
98
- }
99
-
100
- export function resolveTmuxSession(env = process.env) {
101
- const configured = (env.MCODEX_TMUX_SESSION ?? "").trim();
102
- return configured.length > 0 ? configured : DEFAULT_TMUX_SESSION;
103
- }
104
-
105
- /**
106
- * Parse the launcher flags the same way the bash script did: the FIRST argument
107
- * selects a mode (--monitor, or --tmux/-t), and only when --tmux/-t is present is
108
- * a following --live-accounts consumed. Every remaining token is passthrough for
109
- * the codex wrapper. Returns the mode plus the forwarded args.
110
- *
111
- * @param {string[]} argv
112
- */
113
- export function parseMcodexArgs(argv) {
114
- const first = argv[0] ?? "";
115
- if (first === "--monitor") {
116
- // --monitor takes no extra args; the live account list is fixed.
117
- return { mode: "monitor", liveAccounts: false, forwardArgs: argv.slice(1) };
118
- }
119
- if (first === "--tmux" || first === "-t") {
120
- let rest = argv.slice(1);
121
- let liveAccounts = false;
122
- if (rest[0] === "--live-accounts") {
123
- liveAccounts = true;
124
- rest = rest.slice(1);
125
- }
126
- return { mode: "tmux", liveAccounts, forwardArgs: rest };
127
- }
128
- return { mode: "forward", liveAccounts: false, forwardArgs: argv };
129
- }
130
-
131
- // Resolve a POSIX helper (tmux/watch) on PATH ourselves rather than shelling out,
132
- // so we never depend on bash. Mirrors codex-bin-resolver's PATH handling.
133
- function resolvePosixToolOnPath(tool, env = process.env, platform = process.platform) {
134
- const names = platform === "win32" ? [`${tool}.exe`, `${tool}.cmd`, tool] : [tool];
135
- for (const entry of splitPathEntries(env.PATH ?? env.Path ?? "")) {
136
- for (const name of names) {
137
- const candidate = join(entry, name);
138
- if (existsSync(candidate)) {
139
- return candidate;
140
- }
141
- }
142
- }
143
- return null;
144
- }
145
-
146
- function hasPosixTool(tool, env = process.env, platform = process.platform) {
147
- return resolvePosixToolOnPath(tool, env, platform) !== null;
148
- }
149
-
150
- // Build the watch argv as an array (NOT a shell string): `watch -n <interval>
151
- // codex-multi-auth list`. Because the interval is validated to digits/dot and the
152
- // command is passed as discrete argv tokens, no shell interpolation occurs — this
153
- // is strictly safer than the bash `printf %q` approach.
154
- function buildWatchArgs(interval) {
155
- return ["-n", interval, "codex-multi-auth", "list"];
156
- }
157
-
158
- function forwardToCodexWrapper(forwardArgs, env = process.env) {
159
- const child = spawn(process.execPath, [codexWrapperScript, ...forwardArgs], {
160
- stdio: "inherit",
161
- env,
162
- });
163
- const removeSignalRelays = relaySignalsToChild(child);
164
- child.once("error", (error) => {
165
- removeSignalRelays();
166
- console.error(
167
- `mcodex: failed to launch codex wrapper: ${error instanceof Error ? error.message : String(error)}`,
168
- );
169
- process.exit(1);
170
- });
171
- child.once("close", (code, signal) => {
172
- removeSignalRelays();
173
- if (signal === "SIGINT") {
174
- process.exit(130);
175
- return;
176
- }
177
- process.exit(typeof code === "number" ? code : 1);
178
- });
179
- }
180
-
181
- function runMonitor(interval, env = process.env, platform = process.platform) {
182
- const watchPath = resolvePosixToolOnPath("watch", env, platform);
183
- if (!watchPath) {
184
- console.error(
185
- "mcodex: 'watch' is not installed; the live account monitor requires it (install procps / procps-ng).",
186
- );
187
- process.exit(1);
188
- return;
189
- }
190
- const child = spawn(watchPath, buildWatchArgs(interval), { stdio: "inherit", env });
191
- const removeSignalRelays = relaySignalsToChild(child);
192
- child.once("error", (error) => {
193
- removeSignalRelays();
194
- console.error(
195
- `mcodex: failed to launch watch: ${error instanceof Error ? error.message : String(error)}`,
196
- );
197
- process.exit(1);
198
- });
199
- child.once("close", (code, signal) => {
200
- removeSignalRelays();
201
- if (signal === "SIGINT") {
202
- process.exit(130);
203
- return;
204
- }
205
- process.exit(typeof code === "number" ? code : 1);
206
- });
207
- }
208
-
209
- // Run a tmux subcommand synchronously, swallowing output exactly like the bash
210
- // `>/dev/null 2>&1`. argv-only, so the validated history-limit/session never reach
211
- // a shell.
212
- function runTmux(tmuxPath, args, env = process.env) {
213
- return spawnSync(tmuxPath, args, {
214
- stdio: ["ignore", "ignore", "ignore"],
215
- env,
216
- });
217
- }
218
-
219
- function configureTmuxScrollback(tmuxPath, historyLimit, target, env = process.env) {
220
- const targetArgs = target ? ["-t", target] : [];
221
- runTmux(tmuxPath, ["set-option", ...targetArgs, "mouse", "on"], env);
222
- runTmux(tmuxPath, ["set-option", ...targetArgs, "history-limit", historyLimit], env);
223
- runTmux(tmuxPath, ["bind-key", "-T", "root", "WheelUpPane", "copy-mode", "-e"], env);
224
- runTmux(tmuxPath, ["bind-key", "-T", "copy-mode", "WheelUpPane", "send-keys", "-X", "scroll-up"], env);
225
- runTmux(tmuxPath, ["bind-key", "-T", "copy-mode", "WheelDownPane", "send-keys", "-X", "scroll-down"], env);
226
- runTmux(tmuxPath, ["bind-key", "-T", "copy-mode-vi", "WheelUpPane", "send-keys", "-X", "scroll-up"], env);
227
- runTmux(tmuxPath, ["bind-key", "-T", "copy-mode-vi", "WheelDownPane", "send-keys", "-X", "scroll-down"], env);
228
- }
229
-
230
- function warnWatchMissing() {
231
- console.error(
232
- "mcodex: 'watch' is not installed; the live account monitor requires it (install procps / procps-ng).",
233
- );
234
- }
235
-
236
- function formatTmuxSessionSuffix(date = new Date()) {
237
- const pad = (value) => String(value).padStart(2, "0");
238
- return `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
239
- }
240
-
241
- // The codex command tmux should run inside a pane. Use node + the sibling wrapper
242
- // (same as the default forward) so the pane never depends on the installed
243
- // `codex-multi-auth-codex` shim being resolvable.
244
- function buildCodexPaneCommand(forwardArgs) {
245
- return [process.execPath, codexWrapperScript, ...forwardArgs];
246
- }
247
-
248
- function runTmuxMode(options) {
249
- const { liveAccounts, forwardArgs, interval, historyLimit, session, env, platform } =
250
- options;
251
-
252
- const tmuxPath = resolvePosixToolOnPath("tmux", env, platform);
253
- if (!tmuxPath) {
254
- // Degrade exactly like the bash version: warn, then forward without tmux.
255
- console.error("mcodex: tmux is not installed; launching without tmux");
256
- forwardToCodexWrapper(forwardArgs, env);
257
- return;
258
- }
259
-
260
- const watchAvailable = liveAccounts ? hasPosixTool("watch", env, platform) : false;
261
- if (liveAccounts && !watchAvailable) {
262
- warnWatchMissing();
263
- }
264
-
265
- // Already inside a tmux client: configure the current session, optionally add a
266
- // live-accounts pane, then run codex in the current pane (forward + exit code).
267
- if ((env.TMUX ?? "").length > 0) {
268
- configureTmuxScrollback(tmuxPath, historyLimit, undefined, env);
269
- if (liveAccounts && watchAvailable) {
270
- runTmux(tmuxPath, ["split-window", "-h", "watch", ...buildWatchArgs(interval)], env);
271
- }
272
- forwardToCodexWrapper(forwardArgs, env);
273
- return;
274
- }
275
-
276
- // Not inside tmux: create a fresh detached session, configure it, optionally add
277
- // the live pane, then attach (inheriting the terminal) and propagate the code.
278
- let targetSession = session;
279
- if (runTmux(tmuxPath, ["has-session", "-t", targetSession], env).status === 0) {
280
- targetSession = `${targetSession}-${formatTmuxSessionSuffix()}`;
281
- }
282
- runTmux(
283
- tmuxPath,
284
- ["new-session", "-d", "-s", targetSession, "-n", "codex", ...buildCodexPaneCommand(forwardArgs)],
285
- env,
286
- );
287
- configureTmuxScrollback(tmuxPath, historyLimit, targetSession, env);
288
- if (liveAccounts && watchAvailable) {
289
- runTmux(
290
- tmuxPath,
291
- ["split-window", "-h", "-t", `${targetSession}:0`, "watch", ...buildWatchArgs(interval)],
292
- env,
293
- );
294
- }
295
- runTmux(tmuxPath, ["select-pane", "-t", `${targetSession}:0.0`], env);
296
- const attach = spawn(tmuxPath, ["attach-session", "-t", targetSession], {
297
- stdio: "inherit",
298
- env,
299
- });
300
- attach.once("error", (error) => {
301
- console.error(
302
- `mcodex: failed to attach tmux session: ${error instanceof Error ? error.message : String(error)}`,
303
- );
304
- process.exit(1);
305
- });
306
- attach.once("close", (code, signal) => {
307
- if (signal === "SIGINT") {
308
- process.exit(130);
309
- return;
310
- }
311
- process.exit(typeof code === "number" ? code : 0);
312
- });
313
- }
314
-
315
- export function runMcodex(argv = process.argv.slice(2), env = process.env, platform = process.platform) {
316
- const { mode, liveAccounts, forwardArgs } = parseMcodexArgs(argv);
317
- const interval = resolveMonitorInterval(env);
318
- const historyLimit = resolveTmuxHistoryLimit(env);
319
-
320
- if (mode === "monitor") {
321
- runMonitor(interval, env, platform);
322
- return;
323
- }
324
- if (mode === "tmux") {
325
- runTmuxMode({
326
- liveAccounts,
327
- forwardArgs,
328
- interval,
329
- historyLimit,
330
- session: resolveTmuxSession(env),
331
- env,
332
- platform,
333
- });
334
- return;
335
- }
336
- forwardToCodexWrapper(forwardArgs, env);
337
- }
338
-
339
- export function isDirectRunInvocation(invoked, selfUrl) {
340
- if (!invoked) return false;
341
- const selfPath = fileURLToPath(selfUrl);
342
- // Canonicalize both sides: npm installs bins as symlinks (and on Windows the
343
- // .cmd/.ps1 shim invokes this file via its real path), so a raw string compare
344
- // of process.argv[1] against import.meta.url misses the symlinked-bin case and
345
- // the launcher silently becomes a no-op. realpathSync resolves both to the same
346
- // target; fall back to a resolved-path compare if realpath fails (e.g. the file
347
- // was unlinked between launch and this check).
348
- const canonical = (p) => {
349
- try {
350
- return realpathSync(p);
351
- } catch {
352
- return resolvePath(p);
353
- }
354
- };
355
- try {
356
- return canonical(invoked) === canonical(selfPath);
357
- } catch {
358
- return false;
359
- }
360
- }
361
-
362
- const isDirectRun = isDirectRunInvocation(process.argv[1], import.meta.url);
363
-
364
- if (isDirectRun) {
365
- runMcodex();
366
- }
367
-
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { dirname, join, resolve as resolvePath } from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { splitPathEntries } from "./codex-bin-resolver.js";
9
+
10
+ // Cross-platform replacement for the legacy `scripts/mcodex` bash launcher.
11
+ // The bash shim was shipped as a Windows `bin`; npm's generated mcodex.cmd/.ps1
12
+ // invoked bare `bash`, and when a WSL stub resolved before git-bash on PATH the
13
+ // launcher died with HCS_E_SERVICE_NOT_AVAILABLE. This node entry forwards to the
14
+ // sibling codex wrapper with zero bash dependency, and only reaches for the POSIX
15
+ // tools (`tmux`/`watch`) when they actually exist — otherwise it degrades with the
16
+ // same friendly messages the bash version printed.
17
+
18
+ const DEFAULT_MONITOR_INTERVAL = "5";
19
+ const DEFAULT_TMUX_HISTORY_LIMIT = "50000";
20
+ const DEFAULT_TMUX_SESSION = "mcodex";
21
+
22
+ // Security: monitor_interval is interpolated into the `watch -n <n> ...` argument
23
+ // and tmux_history_limit into a tmux option argument. Require a positive (optionally
24
+ // fractional) number / plain integer and fall back to the default otherwise, so no
25
+ // shell metacharacters can ever reach a command — preserving the bash hardening.
26
+ const MONITOR_INTERVAL_PATTERN = /^[0-9]+(\.[0-9]+)?$/;
27
+ const TMUX_HISTORY_LIMIT_PATTERN = /^[0-9]+$/;
28
+
29
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
30
+ // Spawn the sibling codex wrapper directly through `node` (process.execPath) rather
31
+ // than relying on the installed `codex-multi-auth-codex` shim being on PATH. This is
32
+ // the same idiom codex-app-launcher.js uses to launch codex.js and is what makes the
33
+ // default forward work on Windows without any bash/.cmd resolution.
34
+ const codexWrapperScript = join(scriptDir, "codex.js");
35
+
36
+ function defaultWarn(message) {
37
+ console.error(message);
38
+ }
39
+
40
+ // When mcodex itself is asked to terminate, forward the signal to the spawned
41
+ // child so the forwarded codex.js / watch process doesn't outlive us as an
42
+ // orphan. Without this, a SIGTERM to the launcher exits the parent while the
43
+ // child keeps running detached. Listeners are registered with `once` and torn
44
+ // down when the child settles (returned cleanup), so they never leak across the
45
+ // launcher's lifetime. `proc` is injectable so the wiring is unit-testable
46
+ // without registering handlers on the real process.
47
+ export function relaySignalsToChild(
48
+ child,
49
+ { proc = process, signals = ["SIGTERM", "SIGINT"] } = {},
50
+ ) {
51
+ const registered = signals.map((signal) => {
52
+ const handler = () => {
53
+ try {
54
+ child.kill(signal);
55
+ } catch {
56
+ // Child may have already exited; nothing left to forward.
57
+ }
58
+ };
59
+ proc.once(signal, handler);
60
+ return { signal, handler };
61
+ });
62
+ return function removeSignalRelays() {
63
+ for (const { signal, handler } of registered) {
64
+ proc.removeListener(signal, handler);
65
+ }
66
+ };
67
+ }
68
+
69
+ function coerceValidatedSetting(rawValue, pattern, fallback, envName, warn) {
70
+ // Mirror bash `${VAR:-default}`: unset OR empty falls back silently; any other
71
+ // value is validated and, if it fails, replaced with the default plus a warning.
72
+ const value = rawValue === undefined || rawValue === "" ? fallback : rawValue;
73
+ if (!pattern.test(value)) {
74
+ warn(`mcodex: invalid ${envName} '${value}'; using ${fallback}`);
75
+ return fallback;
76
+ }
77
+ return value;
78
+ }
79
+
80
+ export function resolveMonitorInterval(env = process.env, warn = defaultWarn) {
81
+ return coerceValidatedSetting(
82
+ env.MCODEX_MONITOR_INTERVAL,
83
+ MONITOR_INTERVAL_PATTERN,
84
+ DEFAULT_MONITOR_INTERVAL,
85
+ "MCODEX_MONITOR_INTERVAL",
86
+ warn,
87
+ );
88
+ }
89
+
90
+ export function resolveTmuxHistoryLimit(env = process.env, warn = defaultWarn) {
91
+ return coerceValidatedSetting(
92
+ env.MCODEX_TMUX_HISTORY_LIMIT,
93
+ TMUX_HISTORY_LIMIT_PATTERN,
94
+ DEFAULT_TMUX_HISTORY_LIMIT,
95
+ "MCODEX_TMUX_HISTORY_LIMIT",
96
+ warn,
97
+ );
98
+ }
99
+
100
+ export function resolveTmuxSession(env = process.env) {
101
+ const configured = (env.MCODEX_TMUX_SESSION ?? "").trim();
102
+ return configured.length > 0 ? configured : DEFAULT_TMUX_SESSION;
103
+ }
104
+
105
+ /**
106
+ * Parse the launcher flags the same way the bash script did: the FIRST argument
107
+ * selects a mode (--monitor, or --tmux/-t), and only when --tmux/-t is present is
108
+ * a following --live-accounts consumed. Every remaining token is passthrough for
109
+ * the codex wrapper. Returns the mode plus the forwarded args.
110
+ *
111
+ * @param {string[]} argv
112
+ */
113
+ export function parseMcodexArgs(argv) {
114
+ const first = argv[0] ?? "";
115
+ if (first === "--monitor") {
116
+ // --monitor takes no extra args; the live account list is fixed.
117
+ return { mode: "monitor", liveAccounts: false, forwardArgs: argv.slice(1) };
118
+ }
119
+ if (first === "--tmux" || first === "-t") {
120
+ let rest = argv.slice(1);
121
+ let liveAccounts = false;
122
+ if (rest[0] === "--live-accounts") {
123
+ liveAccounts = true;
124
+ rest = rest.slice(1);
125
+ }
126
+ return { mode: "tmux", liveAccounts, forwardArgs: rest };
127
+ }
128
+ return { mode: "forward", liveAccounts: false, forwardArgs: argv };
129
+ }
130
+
131
+ // Resolve a POSIX helper (tmux/watch) on PATH ourselves rather than shelling out,
132
+ // so we never depend on bash. Mirrors codex-bin-resolver's PATH handling.
133
+ function resolvePosixToolOnPath(tool, env = process.env, platform = process.platform) {
134
+ const names = platform === "win32" ? [`${tool}.exe`, `${tool}.cmd`, tool] : [tool];
135
+ for (const entry of splitPathEntries(env.PATH ?? env.Path ?? "")) {
136
+ for (const name of names) {
137
+ const candidate = join(entry, name);
138
+ if (existsSync(candidate)) {
139
+ return candidate;
140
+ }
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+
146
+ function hasPosixTool(tool, env = process.env, platform = process.platform) {
147
+ return resolvePosixToolOnPath(tool, env, platform) !== null;
148
+ }
149
+
150
+ // Build the watch argv as an array (NOT a shell string): `watch -n <interval>
151
+ // codex-multi-auth list`. Because the interval is validated to digits/dot and the
152
+ // command is passed as discrete argv tokens, no shell interpolation occurs — this
153
+ // is strictly safer than the bash `printf %q` approach.
154
+ function buildWatchArgs(interval) {
155
+ return ["-n", interval, "codex-multi-auth", "list"];
156
+ }
157
+
158
+ function forwardToCodexWrapper(forwardArgs, env = process.env) {
159
+ const child = spawn(process.execPath, [codexWrapperScript, ...forwardArgs], {
160
+ stdio: "inherit",
161
+ env,
162
+ });
163
+ const removeSignalRelays = relaySignalsToChild(child);
164
+ child.once("error", (error) => {
165
+ removeSignalRelays();
166
+ console.error(
167
+ `mcodex: failed to launch codex wrapper: ${error instanceof Error ? error.message : String(error)}`,
168
+ );
169
+ process.exit(1);
170
+ });
171
+ child.once("close", (code, signal) => {
172
+ removeSignalRelays();
173
+ if (signal === "SIGINT") {
174
+ process.exit(130);
175
+ return;
176
+ }
177
+ process.exit(typeof code === "number" ? code : 1);
178
+ });
179
+ }
180
+
181
+ function runMonitor(interval, env = process.env, platform = process.platform) {
182
+ const watchPath = resolvePosixToolOnPath("watch", env, platform);
183
+ if (!watchPath) {
184
+ console.error(
185
+ "mcodex: 'watch' is not installed; the live account monitor requires it (install procps / procps-ng).",
186
+ );
187
+ process.exit(1);
188
+ return;
189
+ }
190
+ const child = spawn(watchPath, buildWatchArgs(interval), { stdio: "inherit", env });
191
+ const removeSignalRelays = relaySignalsToChild(child);
192
+ child.once("error", (error) => {
193
+ removeSignalRelays();
194
+ console.error(
195
+ `mcodex: failed to launch watch: ${error instanceof Error ? error.message : String(error)}`,
196
+ );
197
+ process.exit(1);
198
+ });
199
+ child.once("close", (code, signal) => {
200
+ removeSignalRelays();
201
+ if (signal === "SIGINT") {
202
+ process.exit(130);
203
+ return;
204
+ }
205
+ process.exit(typeof code === "number" ? code : 1);
206
+ });
207
+ }
208
+
209
+ // Run a tmux subcommand synchronously, swallowing output exactly like the bash
210
+ // `>/dev/null 2>&1`. argv-only, so the validated history-limit/session never reach
211
+ // a shell.
212
+ function runTmux(tmuxPath, args, env = process.env) {
213
+ return spawnSync(tmuxPath, args, {
214
+ stdio: ["ignore", "ignore", "ignore"],
215
+ env,
216
+ });
217
+ }
218
+
219
+ function configureTmuxScrollback(tmuxPath, historyLimit, target, env = process.env) {
220
+ const targetArgs = target ? ["-t", target] : [];
221
+ runTmux(tmuxPath, ["set-option", ...targetArgs, "mouse", "on"], env);
222
+ runTmux(tmuxPath, ["set-option", ...targetArgs, "history-limit", historyLimit], env);
223
+ runTmux(tmuxPath, ["bind-key", "-T", "root", "WheelUpPane", "copy-mode", "-e"], env);
224
+ runTmux(tmuxPath, ["bind-key", "-T", "copy-mode", "WheelUpPane", "send-keys", "-X", "scroll-up"], env);
225
+ runTmux(tmuxPath, ["bind-key", "-T", "copy-mode", "WheelDownPane", "send-keys", "-X", "scroll-down"], env);
226
+ runTmux(tmuxPath, ["bind-key", "-T", "copy-mode-vi", "WheelUpPane", "send-keys", "-X", "scroll-up"], env);
227
+ runTmux(tmuxPath, ["bind-key", "-T", "copy-mode-vi", "WheelDownPane", "send-keys", "-X", "scroll-down"], env);
228
+ }
229
+
230
+ function warnWatchMissing() {
231
+ console.error(
232
+ "mcodex: 'watch' is not installed; the live account monitor requires it (install procps / procps-ng).",
233
+ );
234
+ }
235
+
236
+ function formatTmuxSessionSuffix(date = new Date()) {
237
+ const pad = (value) => String(value).padStart(2, "0");
238
+ return `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
239
+ }
240
+
241
+ // The codex command tmux should run inside a pane. Use node + the sibling wrapper
242
+ // (same as the default forward) so the pane never depends on the installed
243
+ // `codex-multi-auth-codex` shim being resolvable.
244
+ function buildCodexPaneCommand(forwardArgs) {
245
+ return [process.execPath, codexWrapperScript, ...forwardArgs];
246
+ }
247
+
248
+ function runTmuxMode(options) {
249
+ const { liveAccounts, forwardArgs, interval, historyLimit, session, env, platform } =
250
+ options;
251
+
252
+ const tmuxPath = resolvePosixToolOnPath("tmux", env, platform);
253
+ if (!tmuxPath) {
254
+ // Degrade exactly like the bash version: warn, then forward without tmux.
255
+ console.error("mcodex: tmux is not installed; launching without tmux");
256
+ forwardToCodexWrapper(forwardArgs, env);
257
+ return;
258
+ }
259
+
260
+ const watchAvailable = liveAccounts ? hasPosixTool("watch", env, platform) : false;
261
+ if (liveAccounts && !watchAvailable) {
262
+ warnWatchMissing();
263
+ }
264
+
265
+ // Already inside a tmux client: configure the current session, optionally add a
266
+ // live-accounts pane, then run codex in the current pane (forward + exit code).
267
+ if ((env.TMUX ?? "").length > 0) {
268
+ configureTmuxScrollback(tmuxPath, historyLimit, undefined, env);
269
+ if (liveAccounts && watchAvailable) {
270
+ runTmux(tmuxPath, ["split-window", "-h", "watch", ...buildWatchArgs(interval)], env);
271
+ }
272
+ forwardToCodexWrapper(forwardArgs, env);
273
+ return;
274
+ }
275
+
276
+ // Not inside tmux: create a fresh detached session, configure it, optionally add
277
+ // the live pane, then attach (inheriting the terminal) and propagate the code.
278
+ let targetSession = session;
279
+ if (runTmux(tmuxPath, ["has-session", "-t", targetSession], env).status === 0) {
280
+ targetSession = `${targetSession}-${formatTmuxSessionSuffix()}`;
281
+ }
282
+ runTmux(
283
+ tmuxPath,
284
+ ["new-session", "-d", "-s", targetSession, "-n", "codex", ...buildCodexPaneCommand(forwardArgs)],
285
+ env,
286
+ );
287
+ configureTmuxScrollback(tmuxPath, historyLimit, targetSession, env);
288
+ if (liveAccounts && watchAvailable) {
289
+ runTmux(
290
+ tmuxPath,
291
+ ["split-window", "-h", "-t", `${targetSession}:0`, "watch", ...buildWatchArgs(interval)],
292
+ env,
293
+ );
294
+ }
295
+ runTmux(tmuxPath, ["select-pane", "-t", `${targetSession}:0.0`], env);
296
+ const attach = spawn(tmuxPath, ["attach-session", "-t", targetSession], {
297
+ stdio: "inherit",
298
+ env,
299
+ });
300
+ attach.once("error", (error) => {
301
+ console.error(
302
+ `mcodex: failed to attach tmux session: ${error instanceof Error ? error.message : String(error)}`,
303
+ );
304
+ process.exit(1);
305
+ });
306
+ attach.once("close", (code, signal) => {
307
+ if (signal === "SIGINT") {
308
+ process.exit(130);
309
+ return;
310
+ }
311
+ process.exit(typeof code === "number" ? code : 0);
312
+ });
313
+ }
314
+
315
+ export function runMcodex(argv = process.argv.slice(2), env = process.env, platform = process.platform) {
316
+ const { mode, liveAccounts, forwardArgs } = parseMcodexArgs(argv);
317
+ const interval = resolveMonitorInterval(env);
318
+ const historyLimit = resolveTmuxHistoryLimit(env);
319
+
320
+ if (mode === "monitor") {
321
+ runMonitor(interval, env, platform);
322
+ return;
323
+ }
324
+ if (mode === "tmux") {
325
+ runTmuxMode({
326
+ liveAccounts,
327
+ forwardArgs,
328
+ interval,
329
+ historyLimit,
330
+ session: resolveTmuxSession(env),
331
+ env,
332
+ platform,
333
+ });
334
+ return;
335
+ }
336
+ forwardToCodexWrapper(forwardArgs, env);
337
+ }
338
+
339
+ export function isDirectRunInvocation(invoked, selfUrl) {
340
+ if (!invoked) return false;
341
+ const selfPath = fileURLToPath(selfUrl);
342
+ // Canonicalize both sides: npm installs bins as symlinks (and on Windows the
343
+ // .cmd/.ps1 shim invokes this file via its real path), so a raw string compare
344
+ // of process.argv[1] against import.meta.url misses the symlinked-bin case and
345
+ // the launcher silently becomes a no-op. realpathSync resolves both to the same
346
+ // target; fall back to a resolved-path compare if realpath fails (e.g. the file
347
+ // was unlinked between launch and this check).
348
+ const canonical = (p) => {
349
+ try {
350
+ return realpathSync(p);
351
+ } catch {
352
+ return resolvePath(p);
353
+ }
354
+ };
355
+ try {
356
+ return canonical(invoked) === canonical(selfPath);
357
+ } catch {
358
+ return false;
359
+ }
360
+ }
361
+
362
+ const isDirectRun = isDirectRunInvocation(process.argv[1], import.meta.url);
363
+
364
+ if (isDirectRun) {
365
+ runMcodex();
366
+ }
367
+