@herbertgao/pi-extensions 2026.9.13 → 2026.9.15

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 (70) hide show
  1. package/README.md +3 -3
  2. package/THIRD_PARTY_NOTICES.md +2 -2
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  5. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  6. package/node_modules/@herbertgao/pi-subagents/package.json +4 -4
  7. package/node_modules/@herbertgao/resume-from/package.json +2 -2
  8. package/node_modules/@herbertgao/sol-pi/package.json +5 -5
  9. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +2 -0
  10. package/node_modules/pi-jev-auto-mode/CHANGELOG.md +8 -0
  11. package/node_modules/pi-jev-auto-mode/README.md +7 -0
  12. package/node_modules/pi-jev-auto-mode/package.json +1 -1
  13. package/node_modules/pi-jev-auto-mode/src/extension.ts +21 -2
  14. package/node_modules/pi-jev-auto-mode/src/records.ts +68 -2
  15. package/node_modules/pi-jev-auto-mode/src/settings.ts +22 -0
  16. package/node_modules/pi-jev-auto-mode/src/ui.ts +10 -0
  17. package/node_modules/pi-mcp-adapter/CHANGELOG.md +26 -0
  18. package/node_modules/pi-mcp-adapter/README.md +39 -9
  19. package/node_modules/pi-mcp-adapter/cli.js +33 -22
  20. package/node_modules/pi-mcp-adapter/commands.ts +3 -3
  21. package/node_modules/pi-mcp-adapter/config.ts +9 -1
  22. package/node_modules/pi-mcp-adapter/dist/config.js +9 -1
  23. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  24. package/node_modules/pi-mcp-adapter/dist/jev-client.d.ts +1 -2
  25. package/node_modules/pi-mcp-adapter/dist/jev-client.js +59 -32
  26. package/node_modules/pi-mcp-adapter/dist/jev-client.js.map +1 -1
  27. package/node_modules/pi-mcp-adapter/dist/jev-contracts.d.ts +1 -1
  28. package/node_modules/pi-mcp-adapter/dist/jev-key-store.d.ts +33 -4
  29. package/node_modules/pi-mcp-adapter/dist/jev-key-store.js +148 -28
  30. package/node_modules/pi-mcp-adapter/dist/jev-key-store.js.map +1 -1
  31. package/node_modules/pi-mcp-adapter/dist/mcp-auth-flow.js +1 -2
  32. package/node_modules/pi-mcp-adapter/dist/mcp-auth-flow.js.map +1 -1
  33. package/node_modules/pi-mcp-adapter/dist/types.d.ts +7 -1
  34. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  35. package/node_modules/pi-mcp-adapter/index.ts +51 -38
  36. package/node_modules/pi-mcp-adapter/jev-client.ts +58 -31
  37. package/node_modules/pi-mcp-adapter/jev-contracts.ts +2 -2
  38. package/node_modules/pi-mcp-adapter/jev-key-store.ts +144 -31
  39. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  40. package/node_modules/pi-mcp-adapter/mcp-code.ts +6 -6
  41. package/node_modules/pi-mcp-adapter/package.json +5 -5
  42. package/node_modules/pi-mcp-adapter/semantic-search.ts +3 -3
  43. package/node_modules/pi-mcp-adapter/types.ts +7 -1
  44. package/node_modules/pi-multi-account/CHANGELOG.md +12 -0
  45. package/node_modules/pi-multi-account/README.md +21 -6
  46. package/node_modules/pi-multi-account/index.ts +295 -41
  47. package/node_modules/pi-multi-account/package.json +3 -3
  48. package/node_modules/pi-typesafe/dist/backends.d.ts +10 -0
  49. package/node_modules/pi-typesafe/dist/backends.js +26 -0
  50. package/node_modules/pi-typesafe/dist/client.d.ts +1 -1
  51. package/node_modules/pi-typesafe/dist/client.js +10 -6
  52. package/node_modules/pi-typesafe/dist/errors.d.ts +10 -2
  53. package/node_modules/pi-typesafe/dist/errors.js +28 -7
  54. package/node_modules/pi-typesafe/dist/extension.js +2 -1
  55. package/node_modules/pi-typesafe/package.json +4 -4
  56. package/node_modules/pi-web-access/CHANGELOG.md +22 -0
  57. package/node_modules/pi-web-access/README.md +21 -8
  58. package/node_modules/pi-web-access/baizhi.ts +124 -0
  59. package/node_modules/pi-web-access/crawl4ai.ts +2 -10
  60. package/node_modules/pi-web-access/curator-page.ts +3 -1
  61. package/node_modules/pi-web-access/curator-server.ts +1 -0
  62. package/node_modules/pi-web-access/dist/index.js +540 -220
  63. package/node_modules/pi-web-access/firecrawl.ts +2 -10
  64. package/node_modules/pi-web-access/gemini-search.ts +5 -10
  65. package/node_modules/pi-web-access/index.ts +134 -23
  66. package/node_modules/pi-web-access/package.json +5 -1
  67. package/node_modules/pi-web-access/storage.ts +1 -0
  68. package/node_modules/pi-web-access/tool-activation.ts +134 -0
  69. package/node_modules/pi-web-access/utils.ts +9 -1
  70. package/package.json +14 -14
@@ -42,7 +42,7 @@ import {
42
42
  statSync,
43
43
  writeFileSync,
44
44
  } from "node:fs";
45
- import { createServer, type Server } from "node:http";
45
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
46
46
  import { homedir } from "node:os";
47
47
  import { Readable } from "node:stream";
48
48
  import { dirname, join } from "node:path";
@@ -1195,7 +1195,7 @@ const ANTI_PINGPONG_MS = 60 * 1000; // don't switch straight back to the account
1195
1195
  // Bumped on every release. Printed at startup and in `/multi-account status` so you can verify
1196
1196
  // which version Pi actually loaded (a running Pi keeps the version it started with — /login and
1197
1197
  // /reload do NOT reload extension code; only a full restart does).
1198
- const VERSION = "1.23.0";
1198
+ const VERSION = "1.23.2";
1199
1199
  function sourceFingerprint(): string {
1200
1200
  try {
1201
1201
  const root = dirname(fileURLToPath(import.meta.url));
@@ -3933,11 +3933,48 @@ export default function piMultiAccount(pi: ExtensionAPI) {
3933
3933
  let subagentChild = isSubagentChildProcess();
3934
3934
  let hostOwnsSessionModel = false;
3935
3935
  let startupModel: { provider: string; id: string } | undefined;
3936
- // A live activation lease, not a permanent first-factory flag: /reload and /new
3937
- // must be able to acquire root ownership after the previous root shuts down.
3936
+ // Terminal Pi keeps the conservative one-root lease. A replacement of the SAME host
3937
+ // session can reacquire it; an unrelated in-process child remains passive.
3938
3938
  const activationKey = Symbol.for("pi-multi-account:active-root-session");
3939
- const activations = globalThis as typeof globalThis & { [activationKey]?: object };
3939
+ const activations = globalThis as typeof globalThis & {
3940
+ [activationKey]?: { owner: object; sessionId?: string; relinquish: () => void };
3941
+ };
3940
3942
  const activationOwner = {};
3943
+ // Independent SDK roots share the canonical child listener and its auth/models
3944
+ // publication by port. Their failover state remains private to each session.
3945
+ type SharedProxyMember = {
3946
+ sessionId?: string;
3947
+ ready: boolean;
3948
+ proxyEnabled: boolean;
3949
+ prepareRoutes: (slotIds: readonly string[], port: number) => boolean;
3950
+ relinquish: () => void;
3951
+ handleRequest: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
3952
+ };
3953
+ type SharedProxyCoordinator = {
3954
+ members: Map<object, SharedProxyMember>;
3955
+ // Every published slot stays routable when its discovering root exits first.
3956
+ routes: Map<string, ProxyRoute>;
3957
+ sessionOwners: Map<string, object>;
3958
+ server?: Server;
3959
+ starting?: Promise<number | undefined>;
3960
+ publisher?: object;
3961
+ handlerOwner?: object;
3962
+ };
3963
+ const coordinatorKey = Symbol.for("pi-multi-account:shared-slot-proxy-coordinators");
3964
+ const coordinatorGlobals = globalThis as typeof globalThis & {
3965
+ [coordinatorKey]?: Map<number, SharedProxyCoordinator>;
3966
+ };
3967
+ const coordinators = coordinatorGlobals[coordinatorKey] ??= new Map<number, SharedProxyCoordinator>();
3968
+ let slotProxyCoordinator: SharedProxyCoordinator | undefined;
3969
+ // pi-web's session daemon hosts many independent root sessions in ONE process. The
3970
+ // terminal-Pi lease heuristic would demote every session but the first to passive,
3971
+ // silently disabling failover there. Genuine subagent children under pi-web run in a
3972
+ // runner process marked PI_SUBAGENT_CHILD, which is checked above and still wins.
3973
+ // Any other multi-session SDK host (e.g. Enso) opts in with the host-neutral switch
3974
+ // PI_MULTI_ACCOUNT_INDEPENDENT_ROOTS=1 instead of borrowing pi-web's marker.
3975
+ const multiSessionHost =
3976
+ process.env.PI_WEB_SESSION === "1" ||
3977
+ process.env.PI_MULTI_ACCOUNT_INDEPENDENT_ROOTS === "1";
3941
3978
  const explicitCliArgs = parseExplicitCliArgs();
3942
3979
  const explicitCli = {
3943
3980
  model: explicitCliArgs.model !== undefined,
@@ -3952,6 +3989,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
3952
3989
  const sessionInstanceId = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
3953
3990
  let config = loadConfig();
3954
3991
  let sessionClosed = false;
3992
+ let supersededRoot = false;
3955
3993
  let manualRouteOwnsErrors = false;
3956
3994
  const automaticFailoverEnabled = () => config.enabled && !subagentChild && !sessionClosed;
3957
3995
  debugLogEnabled = config.debugLog;
@@ -10647,15 +10685,90 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10647
10685
  const SLOT_PROXY_PORT =
10648
10686
  Number(process.env.PI_MULTI_ACCOUNT_SLOT_PROXY_PORT) || 41977;
10649
10687
 
10650
- /**
10651
- * One process owns every child-facing file publication. With the normal proxy enabled, the
10652
- * canonical listening port is the ownership token. A pi-subagents child is never an owner,
10653
- * even if no parent happens to be listening when it starts.
10654
- */
10688
+ /** A shared publisher must still own the canonical socket and a live root membership. */
10655
10689
  function ownsSharedChildPublication(): boolean {
10656
10690
  if (subagentChild) return false;
10657
- if (!config.childProxy) return true;
10658
- return !slotProxyForeignOwner && slotProxyPort === SLOT_PROXY_PORT;
10691
+ if (!config.childProxy) {
10692
+ const cohort = slotProxyCoordinator ?? coordinators.get(SLOT_PROXY_PORT);
10693
+ if (cohort?.server || [...(cohort?.members.values() ?? [])].some((member) => member.proxyEnabled)) {
10694
+ return false;
10695
+ }
10696
+ return !Object.entries(readAuthFileRaw()).some(([id, entry]) =>
10697
+ isChildFacingPlaceholderForSlot(entry, id));
10698
+ }
10699
+ if (sessionClosed) return false;
10700
+ const coordinator = slotProxyCoordinator;
10701
+ return !!coordinator && coordinator.members.has(activationOwner) &&
10702
+ coordinator.publisher === activationOwner && coordinator.server?.listening === true &&
10703
+ !slotProxyForeignOwner && slotProxyPort === SLOT_PROXY_PORT;
10704
+ }
10705
+
10706
+ function readyProxyOwner(coordinator: SharedProxyCoordinator): object | undefined {
10707
+ return [...coordinator.members].reverse().find(([, member]) => member.ready)?.[0];
10708
+ }
10709
+
10710
+ function relinquishRoot(): void {
10711
+ if (supersededRoot) return;
10712
+ supersededRoot = true;
10713
+ subagentChild = true;
10714
+ sessionClosed = true;
10715
+ chainEpoch++;
10716
+ completionRouterContext = undefined;
10717
+ if (pendingWakeTimer) clearTimeout(pendingWakeTimer);
10718
+ pendingWakeTimer = undefined;
10719
+ clearUsageStatusTimer();
10720
+ endResumeWatch();
10721
+ clearQueuedInputs();
10722
+ }
10723
+
10724
+ function prepareSharedRoutes(slotIds: readonly string[], port: number): boolean {
10725
+ if (config.childProxy || sessionClosed || subagentChild) return false;
10726
+ slotProxyPort = port;
10727
+ for (const id of slotIds) {
10728
+ if (!registeredSlots.has(id)) continue;
10729
+ const baseUrl = publishedRouteFor(port, id);
10730
+ const family = proxyFamilyOf(id);
10731
+ if (family === "codex") {
10732
+ const cached = codexModelCatalogByProvider.get(id)?.models;
10733
+ registerCodexSlot(pi, id, config.autoDiscoverModels && cached?.length
10734
+ ? (cached as Array<Record<string, unknown>>) : undefined, baseUrl);
10735
+ } else if (family === "anthropic") {
10736
+ registerAnthropicSlot(pi, id, DEFAULT_ANTHROPIC_MODELS, baseUrl);
10737
+ }
10738
+ }
10739
+ return true;
10740
+ }
10741
+
10742
+ function joinSharedProxy(sessionId?: string): void {
10743
+ let coordinator = coordinators.get(SLOT_PROXY_PORT);
10744
+ if (!coordinator) {
10745
+ coordinator = { members: new Map(), routes: new Map(), sessionOwners: new Map() };
10746
+ coordinators.set(SLOT_PROXY_PORT, coordinator);
10747
+ }
10748
+ slotProxyCoordinator = coordinator;
10749
+ if (sessionId) {
10750
+ const previousOwner = coordinator.sessionOwners.get(sessionId);
10751
+ if (previousOwner && previousOwner !== activationOwner) {
10752
+ coordinator.members.get(previousOwner)?.relinquish();
10753
+ coordinator.members.delete(previousOwner);
10754
+ if (coordinator.publisher === previousOwner) coordinator.publisher = readyProxyOwner(coordinator);
10755
+ if (coordinator.handlerOwner === previousOwner) coordinator.handlerOwner = readyProxyOwner(coordinator);
10756
+ }
10757
+ coordinator.sessionOwners.set(sessionId, activationOwner);
10758
+ }
10759
+ coordinator.members.set(activationOwner, {
10760
+ sessionId, ready: false, proxyEnabled: config.childProxy,
10761
+ prepareRoutes: prepareSharedRoutes, relinquish: relinquishRoot,
10762
+ handleRequest: handleProxyRequest,
10763
+ });
10764
+ }
10765
+
10766
+ function activateSharedProxy(): void {
10767
+ const coordinator = slotProxyCoordinator;
10768
+ const member = coordinator?.members.get(activationOwner);
10769
+ if (!member || sessionClosed || subagentChild || !coordinator?.server?.listening) return;
10770
+ member.ready = true;
10771
+ coordinator.handlerOwner = activationOwner;
10659
10772
  }
10660
10773
 
10661
10774
  refreshDiscovery(true);
@@ -10683,12 +10796,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10683
10796
  return proxyFamilyFor(slotId);
10684
10797
  }
10685
10798
 
10686
- //
10687
- // Behavior is unchanged from the single helper this replaced: a numbered Anthropic alias
10688
- // falls back to the public API when this process has no loopback route yet.
10689
- //
10799
+ // An existing child-facing placeholder (possibly published by another root)
10800
+ // must also stay on its canonical loopback, even when this instance's proxy is off.
10690
10801
  function numberedAnthropicBaseUrl(id: string): string {
10691
10802
  if (typeof slotProxyPort === "number") return publishedRouteFor(slotProxyPort, id);
10803
+ if (isChildFacingPlaceholderForSlot(readAuthFileRaw()[id], id)) return publishedRouteFor(SLOT_PROXY_PORT, id);
10692
10804
  return "https://api.anthropic.com";
10693
10805
  }
10694
10806
 
@@ -10707,12 +10819,15 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10707
10819
  * slot. If the port is never served the request fails to connect, which is loud and
10708
10820
  * harmless — unlike a credential that reaches a provider that cannot read it.
10709
10821
  *
10710
- * With the proxy disabled no placeholder is ever published: the real credential is what Pi
10711
- * presents, so the public upstream is the correct destination.
10822
+ * With the proxy disabled AND no other root's placeholder published, Pi presents a real
10823
+ * credential and the public upstream is correct. A published placeholder always takes
10824
+ * the canonical loopback instead, even if this sibling disabled its own proxy.
10712
10825
  */
10713
10826
  function numberedCodexBaseUrl(id: string): string {
10714
10827
  if (typeof slotProxyPort === "number") return publishedRouteFor(slotProxyPort, id);
10715
- if (!config.childProxy) return "https://chatgpt.com/backend-api";
10828
+ if (!config.childProxy && !isChildFacingPlaceholderForSlot(readAuthFileRaw()[id], id)) {
10829
+ return "https://chatgpt.com/backend-api";
10830
+ }
10716
10831
  return publishedRouteFor(SLOT_PROXY_PORT, id);
10717
10832
  }
10718
10833
 
@@ -10776,7 +10891,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10776
10891
  const verdict = admitRequest({
10777
10892
  rawUrl: req.url ?? "",
10778
10893
  headers: req.headers ?? {},
10779
- routes: slotProxyRoutes,
10894
+ routes: slotProxyCoordinator?.server ? slotProxyCoordinator.routes : slotProxyRoutes,
10780
10895
  acceptedSecrets: presentedSecret ? [presentedSecret] : [],
10781
10896
  });
10782
10897
  if (!verdict.ok) {
@@ -10860,11 +10975,38 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10860
10975
  /** Start the loopback listener once, preferring a stable port so published routes survive. */
10861
10976
  function startSlotProxy(): Promise<number | undefined> {
10862
10977
  if (slotProxyPort !== undefined) return Promise.resolve(slotProxyPort);
10978
+ const coordinator = slotProxyCoordinator;
10979
+ if (coordinator?.server?.listening) {
10980
+ slotProxyPort = SLOT_PROXY_PORT;
10981
+ return Promise.resolve(SLOT_PROXY_PORT);
10982
+ }
10983
+ if (coordinator?.starting) {
10984
+ return coordinator.starting.then((port) => {
10985
+ if (port === SLOT_PROXY_PORT && coordinator.server?.listening) {
10986
+ slotProxyPort = SLOT_PROXY_PORT;
10987
+ return SLOT_PROXY_PORT;
10988
+ }
10989
+ return startSlotProxy(); // a foreign owner forced the first member onto a local port
10990
+ });
10991
+ }
10863
10992
  if (slotProxyStarting) return slotProxyStarting;
10864
10993
  slotProxyStarting = new Promise<number | undefined>((resolve) => {
10865
10994
  let retriedOnEphemeralPort = false;
10995
+ let canonicalListener = false;
10866
10996
  const server = createServer((req, res) => {
10867
- handleProxyRequest(req, res).catch((error) => {
10997
+ // The socket outlives its first session. Dispatch only to a live, ready root;
10998
+ // a handoff gap must fail closed, never use a closed root's refresh context.
10999
+ const owner = coordinator?.handlerOwner;
11000
+ const member = owner ? coordinator?.members.get(owner) : undefined;
11001
+ const handler = canonicalListener
11002
+ ? (member?.ready ? member.handleRequest : undefined)
11003
+ : handleProxyRequest;
11004
+ if (!handler) {
11005
+ res.writeHead(503, { "content-type": "application/json" });
11006
+ res.end(JSON.stringify({ error: { message: "slot proxy handoff in progress" } }));
11007
+ return;
11008
+ }
11009
+ handler(req, res).catch((error) => {
10868
11010
  logEvent("slot_proxy_handler_failed", {
10869
11011
  reason: error instanceof Error ? error.message : String(error),
10870
11012
  });
@@ -10926,31 +11068,93 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10926
11068
  // retries on an ephemeral port.
10927
11069
  server.once("listening", () => {
10928
11070
  const address = server.address();
10929
- slotProxyPort = typeof address === "object" && address ? address.port : undefined;
11071
+ const port = typeof address === "object" && address ? address.port : undefined;
11072
+ if (port === SLOT_PROXY_PORT && coordinator) {
11073
+ if (coordinator.members.size === 0) {
11074
+ server.close();
11075
+ resolve(undefined);
11076
+ return;
11077
+ }
11078
+ coordinator.server = server;
11079
+ canonicalListener = true;
11080
+ coordinator.publisher = [...coordinator.members.keys()].at(-1);
11081
+ }
11082
+ // An instance replaced mid-bind must not retain a private listener.
11083
+ if (sessionClosed && port !== SLOT_PROXY_PORT) {
11084
+ server.close();
11085
+ resolve(undefined);
11086
+ return;
11087
+ }
11088
+ slotProxyPort = port;
10930
11089
  slotProxyServer = server;
10931
11090
  server.unref(); // never hold Pi open on our account
10932
- logEvent("slot_proxy_listening", { port: slotProxyPort });
10933
- resolve(slotProxyPort);
11091
+ logEvent("slot_proxy_listening", { port });
11092
+ resolve(port);
10934
11093
  });
10935
11094
  server.listen(SLOT_PROXY_PORT, "127.0.0.1");
10936
11095
  }).finally(() => {
11096
+ if (coordinator && coordinator.starting === slotProxyStarting) coordinator.starting = undefined;
11097
+ if (coordinator && coordinator.members.size === 0 && coordinators.get(SLOT_PROXY_PORT) === coordinator) {
11098
+ coordinators.delete(SLOT_PROXY_PORT);
11099
+ }
10937
11100
  slotProxyStarting = undefined;
10938
11101
  });
11102
+ if (coordinator) coordinator.starting = slotProxyStarting;
10939
11103
  return slotProxyStarting;
10940
11104
  }
10941
11105
 
10942
11106
  function stopSlotProxy() {
10943
- // Capture authority before clearing the port that proves it.
10944
- const publishedSharedFiles = ownsSharedChildPublication();
10945
- try {
10946
- slotProxyServer?.close();
10947
- } catch {
10948
- /* closing a server that never opened is not a problem */
11107
+ const coordinator = slotProxyCoordinator;
11108
+ if (coordinator) {
11109
+ const member = coordinator.members.get(activationOwner);
11110
+ const cleanupWithoutProxy = !config.childProxy && ownsSharedChildPublication();
11111
+ if (member) {
11112
+ coordinator.members.delete(activationOwner);
11113
+ if (member.sessionId && coordinator.sessionOwners.get(member.sessionId) === activationOwner) {
11114
+ coordinator.sessionOwners.delete(member.sessionId);
11115
+ }
11116
+ if (coordinator.publisher === activationOwner) coordinator.publisher = readyProxyOwner(coordinator);
11117
+ if (coordinator.handlerOwner === activationOwner) coordinator.handlerOwner = readyProxyOwner(coordinator);
11118
+ }
11119
+ // An ephemeral listener never owns shared files, even when another process
11120
+ // owns the canonical port. A superseded root cannot close its old canonical
11121
+ // listener: the coordinator still serves it for the replacement.
11122
+ if (slotProxyServer && slotProxyServer !== coordinator.server) {
11123
+ try { slotProxyServer.close(); } catch { /* already closed */ }
11124
+ }
11125
+ if (member && coordinator.members.size === 0) {
11126
+ if (coordinator.server) {
11127
+ // Keep the canonical port claimed until shared files are restored; another
11128
+ // process must not publish a new placeholder during our final cleanup.
11129
+ coordinator.routes.clear();
11130
+ restoreChildFacingAuth();
11131
+ unprovisionOwnLoopbacks();
11132
+ try { coordinator.server.close(); } catch { /* already closed */ }
11133
+ coordinator.server = undefined;
11134
+ }
11135
+ if (!coordinator.server && cleanupWithoutProxy) {
11136
+ unprovisionOwnLoopbacks();
11137
+ restoreChildFacingAuth();
11138
+ }
11139
+ if (!coordinator.starting && coordinators.get(SLOT_PROXY_PORT) === coordinator) {
11140
+ coordinators.delete(SLOT_PROXY_PORT);
11141
+ }
11142
+ coordinator.routes.clear();
11143
+ }
11144
+ slotProxyCoordinator = undefined;
11145
+ slotProxyServer = undefined;
11146
+ slotProxyPort = undefined;
11147
+ slotProxyRoutes.clear();
11148
+ return;
10949
11149
  }
11150
+ // Passive children and non-coordinated (proxy-disabled) instances own only
11151
+ // their process-local server. Preserve the pre-existing disabled-proxy cleanup.
11152
+ const publishedSharedFiles = ownsSharedChildPublication();
11153
+ try { slotProxyServer?.close(); } catch { /* already closed */ }
10950
11154
  slotProxyServer = undefined;
10951
11155
  slotProxyPort = undefined;
10952
11156
  slotProxyRoutes.clear();
10953
- if (!publishedSharedFiles) return; // the owner's state outlives this process
11157
+ if (!publishedSharedFiles) return;
10954
11158
  restoreChildFacingAuth();
10955
11159
  unprovisionOwnLoopbacks();
10956
11160
  }
@@ -10962,6 +11166,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10962
11166
  async function publishProxiedSlots(ctx: any): Promise<void> {
10963
11167
  if (!config.childProxy) return;
10964
11168
  slotProxyContext = ctx ?? slotProxyContext;
11169
+ const coordinator = slotProxyCoordinator;
11170
+ if (coordinator?.server?.listening && coordinator.members.has(activationOwner) && !sessionClosed) {
11171
+ coordinator.publisher = activationOwner;
11172
+ }
10965
11173
  const parent = readAuthFile();
10966
11174
  const slots = [
10967
11175
  ...new Set([
@@ -10971,12 +11179,20 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10971
11179
  ].filter((id) => proxyFamilyOf(id) !== undefined && isEntryUsable(parent[id]));
10972
11180
  if (slots.length === 0) {
10973
11181
  if (ownsSharedChildPublication()) {
10974
- restoreChildFacingAuth();
11182
+ // Keep OAuth in the parent-only sidecar while any root is active, even
11183
+ // when every credential is unusable. Only final shutdown restores it.
11184
+ coordinator?.routes.clear();
10975
11185
  unprovisionOwnLoopbacks();
10976
11186
  }
11187
+ slotProxyRoutes.clear();
11188
+ activateSharedProxy();
10977
11189
  return;
10978
11190
  }
10979
11191
  const port = await startSlotProxy();
11192
+ if (sessionClosed) return;
11193
+ if (port === SLOT_PROXY_PORT && coordinator?.members.has(activationOwner)) {
11194
+ coordinator.publisher = activationOwner;
11195
+ }
10980
11196
  if (port === undefined) {
10981
11197
  if (ownsSharedChildPublication()) {
10982
11198
  restoreChildFacingAuth();
@@ -10992,8 +11208,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10992
11208
  }
10993
11209
  if (!ownsSharedChildPublication()) {
10994
11210
  logEvent("slot_proxy_local_routes_ready", { port, slots });
11211
+ activateSharedProxy();
10995
11212
  return;
10996
11213
  }
11214
+ coordinator?.routes.clear();
11215
+ for (const id of slots) {
11216
+ const family = proxyFamilyOf(id);
11217
+ if (family) coordinator?.routes.set(id, { slotId: id, family });
11218
+ }
11219
+ // A proxy-disabled root may have registered a public URL before this root
11220
+ // started. Retire every such route synchronously BEFORE publishing a placeholder.
11221
+ for (const [owner, member] of coordinator?.members ?? []) {
11222
+ if (owner === activationOwner || member.proxyEnabled) continue;
11223
+ if (!member.prepareRoutes(slots, port)) return;
11224
+ member.ready = true;
11225
+ }
10997
11226
  shadowChildFacingAuth(slots);
10998
11227
  for (const id of slots) {
10999
11228
  const family = proxyFamilyOf(id);
@@ -11029,6 +11258,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11029
11258
  });
11030
11259
  }
11031
11260
  }
11261
+ activateSharedProxy();
11032
11262
  logEvent("slot_proxy_published", { port, slots });
11033
11263
  }
11034
11264
 
@@ -11236,13 +11466,23 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11236
11466
  const startEpoch = ++chainEpoch;
11237
11467
  sessionClosed = false;
11238
11468
  hostOwnsSessionModel = typeof ctx?.sessionManager?.getBranch === "function";
11239
- if (hostOwnsSessionModel && !subagentChild) {
11240
- if (activations[activationKey] && activations[activationKey] !== activationOwner) {
11241
- subagentChild = true;
11242
- } else {
11243
- activations[activationKey] = activationOwner;
11469
+ const rawSessionId = ctx?.sessionManager?.getSessionId?.();
11470
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0
11471
+ ? rawSessionId : undefined;
11472
+ if (hostOwnsSessionModel && !subagentChild && !multiSessionHost) {
11473
+ const previous = activations[activationKey];
11474
+ if (previous && previous.owner !== activationOwner) {
11475
+ if (sessionId && previous.sessionId === sessionId && typeof previous.relinquish === "function") {
11476
+ previous.relinquish();
11477
+ } else {
11478
+ subagentChild = true;
11479
+ }
11244
11480
  }
11481
+ if (!subagentChild) activations[activationKey] = {
11482
+ owner: activationOwner, sessionId, relinquish: relinquishRoot,
11483
+ };
11245
11484
  }
11485
+ if (!subagentChild) joinSharedProxy(sessionId);
11246
11486
  // Pi's branch and SDK launch model belong to THIS session. Shared account
11247
11487
  // telemetry (or another pane's legacy preference) cannot override them.
11248
11488
  startupModel = ctx?.model;
@@ -11276,11 +11516,19 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11276
11516
  slotProxyContext = ctx;
11277
11517
  // Bind the loopback before discovery so numbered slots register against this process,
11278
11518
  // not the real upstream, while a child-facing placeholder is in auth.json.
11279
- if (config.childProxy) await startSlotProxy();
11519
+ if (config.childProxy) {
11520
+ await startSlotProxy();
11521
+ } else if (slotProxyCoordinator) {
11522
+ const cohort = slotProxyCoordinator;
11523
+ if (cohort.starting) await cohort.starting;
11524
+ if (cohort.server?.listening) prepareSharedRoutes([...cohort.routes.keys()], SLOT_PROXY_PORT);
11525
+ }
11526
+ if (sessionClosed || chainEpoch !== startEpoch) return;
11280
11527
  refreshDiscovery(true, ctx);
11281
11528
  // Publish the OAuth slots against a route this process serves, so anything spawned
11282
11529
  // without this extension can actually run on the account the rotation chose.
11283
11530
  await publishProxiedSlots(ctx);
11531
+ if (!config.childProxy) activateSharedProxy();
11284
11532
  publishOwnedNativeAliases(ctx);
11285
11533
  // Not installed → silent skip (refreshCursorSlots warns only on the explicit path).
11286
11534
  if (config.includeCursor) await refreshCursorSlots(readAuthFile(), ctx);
@@ -11334,22 +11582,28 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11334
11582
  // runner owns fallback selection in a child, so multiplying that fleet-wide probe across
11335
11583
  // every parallel child buys nothing.
11336
11584
  if (!subagentChild) await refreshRotationUsage(ctx);
11585
+ if (sessionClosed || chainEpoch !== startEpoch) return;
11337
11586
  await syncCodexModelCatalog(ctx);
11587
+ if (sessionClosed || chainEpoch !== startEpoch) return;
11338
11588
  await syncOllamaModelCatalog(ctx);
11589
+ if (sessionClosed || chainEpoch !== startEpoch) return;
11339
11590
  // Pi restores the session model BEFORE extension catalogs finish registering
11340
11591
  // unless the factory returned the Cursor setup promise. Re-apply anyway: a
11341
11592
  // cold catalog, a compaction inner session, or a git-reset leftover can still
11342
11593
  // leave getModel(cursor, grok-4.6) empty.
11343
11594
  if (cursorReady) await cursorReady.catch(() => undefined);
11595
+ if (sessionClosed || chainEpoch !== startEpoch) return;
11344
11596
  // Cursor slot catalogs changed too — same stale-hidden-copy repair as the model syncs.
11345
11597
  applyOnlyActiveFilter(ctx);
11346
11598
  if (!subagentChild) {
11347
11599
  await restoreRememberedModel(ctx);
11600
+ if (sessionClosed || chainEpoch !== startEpoch) return;
11348
11601
  await ensureReadyModel(
11349
11602
  ctx,
11350
11603
  "startup preflight: selected account unavailable",
11351
11604
  );
11352
11605
  }
11606
+ if (sessionClosed || chainEpoch !== startEpoch) return;
11353
11607
  startUsageStatusTimer(ctx);
11354
11608
  });
11355
11609
 
@@ -11358,10 +11612,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11358
11612
  // Kill every timer and drop the pending continuation so nothing survives the session.
11359
11613
  safeOn("session_shutdown", async (_event, ctx) => {
11360
11614
  chainEpoch++;
11361
- if (activations[activationKey] === activationOwner) delete activations[activationKey];
11615
+ if (activations[activationKey]?.owner === activationOwner) delete activations[activationKey];
11362
11616
  sessionClosed = true;
11363
11617
  completionRouterContext = undefined;
11364
- rememberUserModel(ctx?.model);
11618
+ if (!supersededRoot) rememberUserModel(ctx?.model);
11365
11619
  if (pendingWakeTimer) {
11366
11620
  clearTimeout(pendingWakeTimer);
11367
11621
  pendingWakeTimer = undefined;
@@ -11373,7 +11627,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11373
11627
  contextGuardCompactionRetryAfter = 0;
11374
11628
  settleContextGuardCompaction();
11375
11629
  endResumeWatch();
11376
- // The published routes point at this process; nothing must be left listening after it.
11630
+ // Release this root's membership; only the final root closes shared child routes.
11377
11631
  stopSlotProxy();
11378
11632
  watchdogAborting = false;
11379
11633
  expectingInjectedContinuation = false;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.23.0",
3
+ "version": "1.23.2",
4
4
  "description": "Automatic multi-account failover & rotation for Pi Agent across Anthropic (Claude), OpenAI/ChatGPT Codex, Kimi For Coding, Cursor, Qwen/Alibaba, and Ollama. Auto-discovers authenticated accounts, grows the rotation on login, and drops accounts on logout, expiry, or quota/rate-limit errors",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,8 +78,7 @@
78
78
  "access": "public"
79
79
  },
80
80
  "peerDependencies": {
81
- "@earendil-works/pi-coding-agent": ">=0.85.1 <0.88.0",
82
- "@earendil-works/pi-ai": ">=0.85.1 <0.88.0"
81
+ "@earendil-works/pi-coding-agent": ">=0.85.1 <0.88.0"
83
82
  },
84
83
  "devDependencies": {
85
84
  "@earendil-works/pi-coding-agent": "0.85.1",
@@ -94,6 +93,7 @@
94
93
  },
95
94
  "dependencies": {
96
95
  "@bufbuild/protobuf": "^2.14.0",
96
+ "@earendil-works/pi-ai": ">=0.85.1 <0.88.0",
97
97
  "proper-lockfile": "4.1.2"
98
98
  },
99
99
  "overrides": {
@@ -24,6 +24,16 @@ export declare const TYPESAFE_KEY_ENV = "TYPESAFE_API_KEY";
24
24
  export declare const DECISIONS_BACKENDS: Record<TypeSafeBackend, BackendConfig>;
25
25
  /** The registry entry for a backend name; a `configuration` error for a name the registry does not know. */
26
26
  export declare function backendConfig(name: TypeSafeBackend): BackendConfig;
27
+ /**
28
+ * The model id to send for a caller's `model` on this backend. OpenRouter routes a bare Jev id under the `typesafe`
29
+ * author: `jev-latest` becomes its alias form `~typesafe/jev-latest`, and a bare `jev-<major>.<minor>` — with or
30
+ * without TypeSafe direct's optional `.<patch>` segment — becomes `typesafe/jev-<major>.<minor>`. An id that already
31
+ * carries an author (`vendor/model`), a bare id this rule does not know, and every model on a backend without a
32
+ * mapping go through unchanged. Every mapped id contains `/`, so mapping an already-mapped id changes nothing.
33
+ */
34
+ export declare function backendModelId(backend: TypeSafeBackend, model: string): string;
35
+ /** The model a client sends when the caller names none: the backend's own default, in the form that backend accepts. */
36
+ export declare function defaultModelId(backend: TypeSafeBackend): string;
27
37
  /**
28
38
  * Whether a backend's key comes from the TypeSafe resolution (`TYPESAFE_API_KEY`, then the login store) or only from
29
39
  * its own environment variable. Only the TypeSafe backend has a login store; every other backend is environment-only.
@@ -24,6 +24,32 @@ export function backendConfig(name) {
24
24
  throw new TypeSafeIntegrationError("configuration", `Unknown judgment backend "${name}". Valid backends: ${Object.keys(DECISIONS_BACKENDS).join(", ")}.`);
25
25
  return backend;
26
26
  }
27
+ /** Each backend's default model id as the caller writes it, before mapping: OpenRouter pins a version, TypeSafe follows latest. */
28
+ const DEFAULT_MODEL = {
29
+ typesafe: "jev-latest",
30
+ openrouter: "typesafe/jev-1.13",
31
+ };
32
+ /**
33
+ * The model id to send for a caller's `model` on this backend. OpenRouter routes a bare Jev id under the `typesafe`
34
+ * author: `jev-latest` becomes its alias form `~typesafe/jev-latest`, and a bare `jev-<major>.<minor>` — with or
35
+ * without TypeSafe direct's optional `.<patch>` segment — becomes `typesafe/jev-<major>.<minor>`. An id that already
36
+ * carries an author (`vendor/model`), a bare id this rule does not know, and every model on a backend without a
37
+ * mapping go through unchanged. Every mapped id contains `/`, so mapping an already-mapped id changes nothing.
38
+ */
39
+ export function backendModelId(backend, model) {
40
+ if (model.includes("/"))
41
+ return model;
42
+ if (backend !== "openrouter")
43
+ return model;
44
+ if (model === "jev-latest")
45
+ return "~typesafe/jev-latest";
46
+ const version = /^jev-(\d+)\.(\d+)(?:\.\d+)?$/.exec(model);
47
+ return version === null ? model : `typesafe/jev-${version[1]}.${version[2]}`;
48
+ }
49
+ /** The model a client sends when the caller names none: the backend's own default, in the form that backend accepts. */
50
+ export function defaultModelId(backend) {
51
+ return backendModelId(backend, DEFAULT_MODEL[backend]);
52
+ }
27
53
  /**
28
54
  * Whether a backend's key comes from the TypeSafe resolution (`TYPESAFE_API_KEY`, then the login store) or only from
29
55
  * its own environment variable. Only the TypeSafe backend has a login store; every other backend is environment-only.
@@ -9,7 +9,7 @@ export interface TypeSafeOptions {
9
9
  apiKey?: string;
10
10
  /** Judgment backend. When omitted, routes to the default TypeSafe host. */
11
11
  backend?: TypeSafeBackend;
12
- /** Defaults to jev-latest. No model is inferred from submitted content. */
12
+ /** Defaults to the backend's own default (`jev-latest`, `typesafe/jev-1.13` on OpenRouter); a bare Jev id is mapped to the backend's id form before sending. No model is inferred from submitted content. */
13
13
  model?: string;
14
14
  /** Per request. Default: 15 seconds. No automatic retries. */
15
15
  timeoutMs?: number;
@@ -1,6 +1,6 @@
1
1
  import { TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  import { recordAuthFailure, recordAuthVerified } from "./auth.js";
3
- import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, backendConfig, usesTypesafeKey } from "./backends.js";
3
+ import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, backendConfig, backendModelId, defaultModelId, usesTypesafeKey } from "./backends.js";
4
4
  import { evaluateAll, evaluateMany } from "./batch.js";
5
5
  import { keySituation } from "./credentials.js";
6
6
  import { TypeSafeIntegrationError, safeError } from "./errors.js";
@@ -146,9 +146,12 @@ export function createTypeSafe(options = {}) {
146
146
  }, capsFromEnvironment());
147
147
  // A backend that serves its own paths gets a transport that rewrites them; the default backend keeps the caller's.
148
148
  const transport = backend.path !== undefined || backend.modelsPath !== undefined ? backendFetch(backend, options.fetch) : options.fetch;
149
- const model = options.model ?? (backendName === "openrouter" ? "typesafe/jev-1.13" : "jev-latest");
150
- if (typeof model !== "string" || !model.trim() || model.length > 100)
149
+ // The caller's input is validated as written, then mapped to the backend's id form; omitting it sends the backend's
150
+ // own default, which the mapping leaves unchanged.
151
+ const requested = options.model ?? defaultModelId(backendName);
152
+ if (typeof requested !== "string" || !requested.trim() || requested.length > 100)
151
153
  throw new TypeSafeIntegrationError("configuration", "model must be a nonempty string of at most 100 characters.");
154
+ const model = backendModelId(backendName, requested);
152
155
  // Do not inherit SDK debug logging or alternate destinations from the environment.
153
156
  const client = new TypeSafeClient({
154
157
  apiKey,
@@ -191,12 +194,13 @@ export function createTypeSafe(options = {}) {
191
194
  return models.map(card => card?.name).filter((name) => typeof name === "string" && name.length > 0 && name.length <= 100);
192
195
  }
193
196
  catch (error) {
194
- throw safeError(error);
197
+ throw safeError(error, backend);
195
198
  }
196
199
  },
197
200
  async evaluate(input, callOptions = {}) {
198
201
  const validated = prepareEvaluationRequest(input, { maxInputBytes });
199
- const body = JSON.stringify({ ...validated, model: validated.model ?? model });
202
+ // A per-request model meets the same mapping as the client default; the schema already limited the caller's own id.
203
+ const body = JSON.stringify({ ...validated, model: validated.model === undefined ? model : backendModelId(backendName, validated.model) });
200
204
  assertWithinByteLimit(body, maxInputBytes);
201
205
  if (callOptions.signal?.aborted)
202
206
  throw new TypeSafeIntegrationError("aborted", "TypeSafe request cancelled before submission.");
@@ -228,7 +232,7 @@ export function createTypeSafe(options = {}) {
228
232
  return { ...result, elapsedMs: Math.round(performance.now() - start) };
229
233
  }
230
234
  catch (error) {
231
- const safe = safeError(error);
235
+ const safe = safeError(error, backend);
232
236
  // The request was submitted, so it counts even when it fails; the reason stays visible in `authState()`.
233
237
  usage.requestsFailed += 1;
234
238
  ledger.recordFailure();