@henryqw/pi-multi-codex 1.0.2 → 1.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@henryqw/pi-multi-codex`
2
2
 
3
- Add multiple ChatGPT Codex OAuth accounts and start new Pi work on the eligible slot with the most weekly quota. Optional automatic switching can retry an HTTP 429 on another eligible slot.
3
+ Add multiple ChatGPT Codex OAuth accounts and start Pi work on the eligible slot with the most weekly quota. The extension switches away from active five-hour blocks and can retry an HTTP 429 on another eligible slot.
4
4
 
5
5
  ![Pi showing Codex account quotas and the active footer slot](./example.png)
6
6
  ![Flowchart separating fresh-quota startup ranking from broader HTTP 429 failover](./docs/codex-routing-flow.svg)
@@ -13,11 +13,11 @@ pi install npm:@henryqw/pi-multi-codex
13
13
 
14
14
  ## Works with
15
15
 
16
- | Package | Why |
17
- | --- | --- |
18
- | [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) | Improves. Shows the active slot's quota or five-hour block in the footer. |
19
- | [`@henryqw/pi-subagent`](https://pi.henry.wang/extensions/pi-subagent) | Improves. Isolated children keep Main's active Codex slot. |
20
- | [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Improves. Numbered slots share one profile route. |
16
+ | Package | Relationship | Purpose |
17
+ | --- | --- | --- |
18
+ | [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) | Improves | Shows the active slot's quota or five-hour block in the footer. |
19
+ | [`@henryqw/pi-subagent`](https://pi.henry.wang/extensions/pi-subagent) | Improves | Isolated children keep Main's active Codex slot. |
20
+ | [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Improves | Numbered slots share one profile route. |
21
21
 
22
22
  ## Use
23
23
 
@@ -36,6 +36,8 @@ A numbered slot is one Codex account position in Pi.
36
36
  ## Flow
37
37
 
38
38
  - Before the first agent start, only fresh quota snapshots enter startup ranking. The slot with the most seven-day quota wins.
39
+ - An explicit model selection always wins for the next agent start.
40
+ - Before later agent starts, a known active five-hour block switches to the eligible fresh slot with the most seven-day quota.
39
41
  - After HTTP 429, failover can use authenticated, registered, scope-allowed, untried slots with stale or missing quota.
40
42
  - Failover skips known active five-hour blocks. It ranks fresh known quota first, then unranked slots by slot number.
41
43
  - Routing preserves the model ID.
@@ -45,24 +47,20 @@ A numbered slot is one Codex account position in Pi.
45
47
 
46
48
  ## Config
47
49
 
48
- Automatic HTTP 429 switching is on by default. To disable it, create `~/.pi/agent/config/pi-multi-codex/config.json`:
50
+ Package-owned: `~/.pi/agent/config/pi-multi-codex/config.json`
49
51
 
50
- ```json
51
- {
52
- "autoSwitchOn429": false
53
- }
54
- ```
52
+ | Name | Description | Values | Default |
53
+ | --- | --- | --- | --- |
54
+ | `autoSwitchOn429` | Switches to another eligible slot after an HTTP 429. | Boolean. | `true` |
55
55
 
56
- The config must contain only `autoSwitchOn429` as a boolean. Invalid config is preserved and disables automatic switching.
56
+ Set `autoSwitchOn429` to `false` to disable automatic switching. The config must contain only this field as a boolean. Invalid config is preserved and disables automatic switching.
57
57
 
58
58
  ## State and storage
59
59
 
60
60
  The extension maintains a generated, credential-free quota cache at `~/.pi/agent/config/pi-multi-codex/usage.json`.
61
61
 
62
- ## Data, cost, and privacy
62
+ ## Limits and recovery
63
63
 
64
64
  The extension reads `auth.json`. It never writes or refreshes credentials.
65
65
 
66
- ## Limits and recovery
67
-
68
66
  Scoped sessions can switch only to exact scoped aliases.
@@ -1,9 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
- import { createConfigStore, extensionConfigDir } from "@henryqw/pi-config-store";
6
+ import { createConfigStore, extensionConfigDir, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
7
7
  import { lock } from "proper-lockfile";
8
8
  import {
9
9
  createAssistantMessageEventStream,
@@ -258,22 +258,11 @@ async function saveState(state: UsageState, signal?: AbortSignal): Promise<void>
258
258
  signal?.throwIfAborted();
259
259
  if (state.untrusted) return;
260
260
  const file = cachePath();
261
- const directory = extensionConfigDir("pi-multi-codex");
262
- await mkdir(directory, { recursive: true, mode: 0o700 });
263
261
  const data = `${JSON.stringify({
264
262
  slots: [...state.slots.values()].sort((a, b) => a.slot - b.slot),
265
263
  locks: [...state.locks.values()].sort((a, b) => a.slot - b.slot),
266
264
  })}\n`;
267
- const temporary = `${file}.${randomUUID()}.tmp`;
268
- try {
269
- await writeFile(temporary, data, { encoding: "utf8", mode: 0o600, flag: "wx", signal });
270
- signal?.throwIfAborted();
271
- await rename(temporary, file);
272
- signal?.throwIfAborted();
273
- await chmod(file, 0o600);
274
- } finally {
275
- await rm(temporary, { force: true }).catch(() => undefined);
276
- }
265
+ await writePrivateTextFileAtomically(file, data, { signal });
277
266
  }
278
267
 
279
268
  async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
@@ -835,6 +824,8 @@ export default function multiCodex(pi: ExtensionAPI): void {
835
824
  let sessionContext: ExtensionContext | undefined;
836
825
  let automaticOpen = false;
837
826
  let automaticCandidate: Model<any> | undefined;
827
+ let preserveModelChoice = false;
828
+ let selectingAutomatically = false;
838
829
  let autoSwitchOn429 = true;
839
830
  let configWarned = false;
840
831
  let providerRequest: { provider?: string; status?: number } | undefined;
@@ -921,8 +912,17 @@ export default function multiCodex(pi: ExtensionAPI): void {
921
912
  if (ctx) ctx.ui.setStatus?.("pi-multi-codex", footerText(ctx));
922
913
  };
923
914
 
915
+ const setModelAutomatically = async (model: Model<any>): Promise<boolean> => {
916
+ selectingAutomatically = true;
917
+ try {
918
+ return await pi.setModel(model);
919
+ } finally {
920
+ selectingAutomatically = false;
921
+ }
922
+ };
923
+
924
924
  const updatePendingCandidate = (ctx: ExtensionContext): void => {
925
- if (!automaticOpen || !ctx.model || !isManagedProvider(ctx.model.provider)) return;
925
+ if (!ctx.model || !isManagedProvider(ctx.model.provider)) return;
926
926
  const slot = selectFreshSlot(ctx, ctx.model);
927
927
  automaticCandidate = slot ? { ...ctx.model, provider: providerForSlot(slot) } : ctx.model;
928
928
  };
@@ -941,6 +941,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
941
941
  sessionContext = ctx;
942
942
  triedSlots.clear();
943
943
  providerRequest = undefined;
944
+ preserveModelChoice = false;
944
945
  try {
945
946
  autoSwitchOn429 = configStore.loadSync().value.autoSwitchOn429;
946
947
  } catch {
@@ -962,14 +963,17 @@ export default function multiCodex(pi: ExtensionAPI): void {
962
963
  pi.on("before_agent_start", async (_event, ctx) => {
963
964
  triedSlots.clear();
964
965
  providerRequest = undefined;
965
- if (!automaticOpen) return;
966
966
  const model = ctx.model;
967
+ const slot = slotForProvider(model?.provider ?? "");
968
+ const identity = slot ? currentIdentity(slot) : undefined;
969
+ const snapshot = slot ? quota.snapshot(slot) : undefined;
970
+ if (!automaticOpen && (preserveModelChoice || !identity || snapshot?.accountHash !== identity.accountHash || !isFiveHourLimited(snapshot, Date.now()))) return;
967
971
  updatePendingCandidate(ctx);
968
972
  // Close before await: no timer, refresh, queued turn, or provider event can route later.
969
973
  automaticOpen = false;
970
974
  const candidate = automaticCandidate;
971
975
  if (!model || !candidate || !isManagedProvider(model.provider) || candidate.id !== model.id || candidate.provider === model.provider) return;
972
- await pi.setModel(candidate);
976
+ await setModelAutomatically(candidate);
973
977
  updateFooter(ctx);
974
978
  });
975
979
 
@@ -1002,7 +1006,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
1002
1006
  syncSlots();
1003
1007
  for (const slot of failoverSlots(ctx, model)) {
1004
1008
  const provider = providerForSlot(slot);
1005
- if (!await pi.setModel({ ...model, provider })) continue;
1009
+ if (!await setModelAutomatically({ ...model, provider })) continue;
1006
1010
  ctx.ui.notify(`Codex #${failedSlot} returned HTTP 429. Retrying with Codex #${slot}.`, "warning");
1007
1011
  updateFooter(ctx);
1008
1012
  return { message: { ...event.message, errorMessage: `HTTP 429: ${event.message.errorMessage ?? "Codex request was rate limited."}` } };
@@ -1018,6 +1022,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
1018
1022
 
1019
1023
  pi.on("agent_start", (_event, ctx) => {
1020
1024
  automaticOpen = false;
1025
+ preserveModelChoice = false;
1021
1026
  if (!sessionHasAgentWork(ctx)) pi.appendEntry(AGENT_STARTED_ENTRY);
1022
1027
  });
1023
1028
  pi.on("agent_settled", () => {
@@ -1032,8 +1037,9 @@ export default function multiCodex(pi: ExtensionAPI): void {
1032
1037
  quota.stop();
1033
1038
  });
1034
1039
  pi.on("model_select", (_event, ctx) => {
1035
- // Explicit selector choice wins over startup routing.
1040
+ // Explicit selector choice wins over routing at the next agent boundary.
1036
1041
  automaticOpen = false;
1042
+ if (!selectingAutomatically) preserveModelChoice = true;
1037
1043
  syncSlots();
1038
1044
  quota.requestRefresh();
1039
1045
  updateFooter(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-multi-codex",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Use multiple ChatGPT Codex OAuth accounts in Pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -46,7 +46,7 @@
46
46
  "image": "https://raw.githubusercontent.com/HenryQW/pi-harness/main/extensions/pi-multi-codex/example.png"
47
47
  },
48
48
  "dependencies": {
49
- "@henryqw/pi-config-store": "^1.0.0",
49
+ "@henryqw/pi-config-store": "^1.1.0",
50
50
  "proper-lockfile": "^4.1.2"
51
51
  },
52
52
  "devDependencies": {