@herbertgao/pi-extensions 2026.9.9 → 2026.9.11

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 (170) hide show
  1. package/README.md +1 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
  5. package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -2
  7. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +12 -0
  8. package/node_modules/@herbertgao/pi-subagents/README.md +427 -120
  9. package/node_modules/@herbertgao/pi-subagents/docs/rpc.md +184 -0
  10. package/node_modules/@herbertgao/pi-subagents/docs/workflows.md +466 -0
  11. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +6 -6
  12. package/node_modules/@herbertgao/pi-subagents/examples/workflows/compose.js +52 -0
  13. package/node_modules/@herbertgao/pi-subagents/examples/workflows/fan-out-audit.js +56 -0
  14. package/node_modules/@herbertgao/pi-subagents/examples/workflows/gated-fix.js +60 -0
  15. package/node_modules/@herbertgao/pi-subagents/examples/workflows/lib/count-child.js +30 -0
  16. package/node_modules/@herbertgao/pi-subagents/examples/workflows/review-panel.js +68 -0
  17. package/node_modules/@herbertgao/pi-subagents/examples/workflows/structured-findings.js +81 -0
  18. package/node_modules/@herbertgao/pi-subagents/package.json +12 -10
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +52 -12
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +837 -146
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +213 -39
  22. package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +73 -14
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +101 -47
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +2249 -914
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +13 -0
  26. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +215 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/mention.ts +147 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/model-resolver.ts +9 -1
  29. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +40 -26
  30. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +18 -8
  31. package/node_modules/@herbertgao/pi-subagents/src/prompts.ts +46 -9
  32. package/node_modules/@herbertgao/pi-subagents/src/schedule.ts +21 -16
  33. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +137 -7
  34. package/node_modules/@herbertgao/pi-subagents/src/structured-output.ts +136 -0
  35. package/node_modules/@herbertgao/pi-subagents/src/types.ts +126 -8
  36. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-mention.ts +274 -0
  37. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +20 -5
  38. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +10 -4
  39. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +167 -22
  40. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-card.ts +555 -0
  41. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-dialog.ts +1304 -0
  42. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-menu.ts +226 -0
  43. package/node_modules/@herbertgao/pi-subagents/src/workflow/collisions.ts +122 -0
  44. package/node_modules/@herbertgao/pi-subagents/src/workflow/entry.ts +47 -0
  45. package/node_modules/@herbertgao/pi-subagents/src/workflow/host.ts +463 -0
  46. package/node_modules/@herbertgao/pi-subagents/src/workflow/journal.ts +164 -0
  47. package/node_modules/@herbertgao/pi-subagents/src/workflow/json-schema.ts +142 -0
  48. package/node_modules/@herbertgao/pi-subagents/src/workflow/meta.ts +401 -0
  49. package/node_modules/@herbertgao/pi-subagents/src/workflow/progress.ts +622 -0
  50. package/node_modules/@herbertgao/pi-subagents/src/workflow/runtime.ts +1399 -0
  51. package/node_modules/@herbertgao/pi-subagents/src/workflow/saved.ts +230 -0
  52. package/node_modules/@herbertgao/pi-subagents/src/workflow/task.ts +333 -0
  53. package/node_modules/@herbertgao/pi-subagents/src/workflow/tool-description.ts +200 -0
  54. package/node_modules/@herbertgao/pi-subagents/src/workflow/worker-source.ts +781 -0
  55. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +97 -95
  56. package/node_modules/@herbertgao/pi-subagents/src/xml.ts +13 -0
  57. package/node_modules/@herbertgao/resume-from/package.json +1 -1
  58. package/node_modules/@herbertgao/sol-pi/README.md +3 -3
  59. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
  60. package/node_modules/@herbertgao/sol-pi/agents-install.md +4 -4
  61. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +6 -6
  62. package/node_modules/@herbertgao/sol-pi/package.json +2 -2
  63. package/node_modules/@narumitw/pi-btw/dist/index.ts +39 -89
  64. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  65. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  66. package/node_modules/@narumitw/pi-btw/src/btw.ts +28 -87
  67. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +8 -0
  68. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +40 -37
  69. package/node_modules/@narumitw/pi-caffeinate/README.md +21 -66
  70. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +10 -41
  71. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +2 -2
  72. package/node_modules/@narumitw/pi-caffeinate/package.json +50 -51
  73. package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +637 -663
  74. package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +114 -120
  75. package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +29 -29
  76. package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +108 -126
  77. package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +124 -128
  78. package/node_modules/pi-multi-account/CHANGELOG.md +1209 -0
  79. package/node_modules/pi-multi-account/CONTRIBUTING.md +61 -0
  80. package/node_modules/pi-multi-account/LICENSE +21 -0
  81. package/node_modules/pi-multi-account/README.md +197 -0
  82. package/node_modules/pi-multi-account/SECURITY.md +27 -0
  83. package/node_modules/pi-multi-account/auth-file-transaction.ts +56 -0
  84. package/node_modules/pi-multi-account/child-usability.ts +233 -0
  85. package/node_modules/pi-multi-account/compaction-summary.ts +32 -0
  86. package/node_modules/pi-multi-account/completion-route-planner.ts +224 -0
  87. package/node_modules/pi-multi-account/context-guard.ts +420 -0
  88. package/node_modules/pi-multi-account/cursor/LICENSE +21 -0
  89. package/node_modules/pi-multi-account/cursor/NOTICE +2 -0
  90. package/node_modules/pi-multi-account/cursor/auth.ts +165 -0
  91. package/node_modules/pi-multi-account/cursor/bridge-handle.ts +155 -0
  92. package/node_modules/pi-multi-account/cursor/conversation-registry.ts +104 -0
  93. package/node_modules/pi-multi-account/cursor/cursor-models-raw.json +611 -0
  94. package/node_modules/pi-multi-account/cursor/cursor-shared.ts +192 -0
  95. package/node_modules/pi-multi-account/cursor/h2-bridge.mjs +175 -0
  96. package/node_modules/pi-multi-account/cursor/index.ts +572 -0
  97. package/node_modules/pi-multi-account/cursor/message-parsing.ts +323 -0
  98. package/node_modules/pi-multi-account/cursor/prompt-usage.ts +53 -0
  99. package/node_modules/pi-multi-account/cursor/proto/agent_pb.ts +15294 -0
  100. package/node_modules/pi-multi-account/cursor/proxy.ts +2510 -0
  101. package/node_modules/pi-multi-account/cursor/session-lifecycle.ts +40 -0
  102. package/node_modules/pi-multi-account/cursor/sse-keepalive.ts +24 -0
  103. package/node_modules/pi-multi-account/cursor/stream-lifecycle.ts +193 -0
  104. package/node_modules/pi-multi-account/cursor/upstream-watchdog.ts +88 -0
  105. package/node_modules/pi-multi-account/cursor-bridge.ts +240 -0
  106. package/node_modules/pi-multi-account/cursor-model-name.ts +12 -0
  107. package/node_modules/pi-multi-account/index.ts +11825 -0
  108. package/node_modules/pi-multi-account/model-catalog.ts +354 -0
  109. package/node_modules/pi-multi-account/package.json +101 -0
  110. package/node_modules/pi-multi-account/pi-contract.ts +281 -0
  111. package/node_modules/pi-multi-account/provider-payload-stream.ts +44 -0
  112. package/node_modules/pi-multi-account/provider-priority.ts +189 -0
  113. package/node_modules/pi-multi-account/slot-proxy-auth.ts +167 -0
  114. package/node_modules/pi-multi-account/slot-proxy.ts +344 -0
  115. package/node_modules/pi-multi-account/state-file-transaction.ts +67 -0
  116. package/node_modules/pi-multi-account/usage.ts +1099 -0
  117. package/node_modules/pi-typesafe/README.md +6 -2
  118. package/node_modules/pi-typesafe/dist/client.d.ts +11 -0
  119. package/node_modules/pi-typesafe/dist/client.js +45 -10
  120. package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
  121. package/node_modules/pi-typesafe/dist/index.js +1 -1
  122. package/node_modules/pi-typesafe/package.json +2 -2
  123. package/node_modules/pi-web-access/CHANGELOG.md +36 -0
  124. package/node_modules/pi-web-access/README.md +75 -18
  125. package/node_modules/pi-web-access/anysearch.ts +4 -15
  126. package/node_modules/pi-web-access/bocha.ts +3 -22
  127. package/node_modules/pi-web-access/brave.ts +3 -21
  128. package/node_modules/pi-web-access/brightdata.ts +5 -32
  129. package/node_modules/pi-web-access/content-find.ts +168 -53
  130. package/node_modules/pi-web-access/curator-page.ts +4 -1
  131. package/node_modules/pi-web-access/curator-run.ts +2 -1
  132. package/node_modules/pi-web-access/curator-server.ts +1 -0
  133. package/node_modules/pi-web-access/dist/index.js +24620 -0
  134. package/node_modules/pi-web-access/domain-filter-normalization.ts +14 -0
  135. package/node_modules/pi-web-access/duckduckgo.ts +3 -21
  136. package/node_modules/pi-web-access/extract.ts +3 -1
  137. package/node_modules/pi-web-access/firecrawl.ts +5 -29
  138. package/node_modules/pi-web-access/gemini-search.ts +81 -32
  139. package/node_modules/pi-web-access/index.ts +149 -148
  140. package/node_modules/pi-web-access/jina-search.ts +4 -15
  141. package/node_modules/pi-web-access/kagi.ts +4 -13
  142. package/node_modules/pi-web-access/kimi-search.ts +5 -30
  143. package/node_modules/pi-web-access/mistral-search.ts +1 -15
  144. package/node_modules/pi-web-access/ollama.ts +2 -7
  145. package/node_modules/pi-web-access/openai-search.ts +174 -36
  146. package/node_modules/pi-web-access/opencode-session-headers.ts +24 -0
  147. package/node_modules/pi-web-access/package.json +10 -4
  148. package/node_modules/pi-web-access/page-query.ts +10 -2
  149. package/node_modules/pi-web-access/parallel.ts +1 -15
  150. package/node_modules/pi-web-access/pdf-extract.ts +3 -0
  151. package/node_modules/pi-web-access/querit.ts +5 -29
  152. package/node_modules/pi-web-access/search-answer-formatting.ts +11 -0
  153. package/node_modules/pi-web-access/search-result-count-normalization.ts +4 -0
  154. package/node_modules/pi-web-access/search1api.ts +5 -29
  155. package/node_modules/pi-web-access/searchinfinity.ts +5 -29
  156. package/node_modules/pi-web-access/searxng.ts +3 -21
  157. package/node_modules/pi-web-access/serpapi.ts +5 -28
  158. package/node_modules/pi-web-access/serpbase.ts +3 -22
  159. package/node_modules/pi-web-access/serpdive.ts +3 -21
  160. package/node_modules/pi-web-access/serper.ts +5 -28
  161. package/node_modules/pi-web-access/serply.ts +197 -0
  162. package/node_modules/pi-web-access/source-check.ts +11 -47
  163. package/node_modules/pi-web-access/summary-review.ts +7 -3
  164. package/node_modules/pi-web-access/tavily.ts +3 -21
  165. package/node_modules/pi-web-access/tinyfish.ts +5 -29
  166. package/node_modules/pi-web-access/utils.ts +9 -1
  167. package/node_modules/pi-web-access/valyu.ts +5 -28
  168. package/node_modules/pi-web-access/xai-search.ts +1 -15
  169. package/node_modules/pi-web-access/xcrawl.ts +5 -32
  170. package/package.json +17 -11
@@ -0,0 +1,344 @@
1
+ /**
2
+ * A parent-owned loopback route that lets a child run on the account the rotation chose.
3
+ *
4
+ * ## The problem this exists for
5
+ *
6
+ * A process spawned without this extension — a memory consolidation, external CLI, or bare
7
+ * `pi -p --no-extensions` call — either receives an explicit model or inherits Pi's saved global
8
+ * default. Since Pi 0.84.3 that saved value intentionally does not track every live session
9
+ * switch. In either case, publishing a rotation slot's *name* into `models.json` is not enough:
10
+ * measured 2026-08-24, a
11
+ * published slot carrying an OAuth credential fails with `No API key found`, because Pi honours
12
+ * an OAuth credential only for a provider definition that declares the flow, and a `models.json`
13
+ * entry declares none. The child then falls through to Pi's own first-available provider — a
14
+ * different account, usually a different vendor, silently.
15
+ *
16
+ * The Cursor slots already solve this, and the shape is the answer for every OAuth family:
17
+ * publish the slot against a route the parent serves on `127.0.0.1`, with a deliberately
18
+ * non-secret placeholder as its `apiKey`. Pi sees a credential and admits the provider; the child
19
+ * authenticates to this machine with a string worth nothing; the parent swaps in the real token
20
+ * and adds whatever the family needs. The credential never leaves the parent.
21
+ *
22
+ * ## What lives here
23
+ *
24
+ * Only the decisions — routing, admission, and the exact shape of the upstream request. No
25
+ * socket, no credential store, no file. That keeps the security-relevant rules (the placeholder is
26
+ * never forwarded, an unknown caller is never served, a token never reaches a diagnostic) testable
27
+ * exhaustively and without a network.
28
+ */
29
+
30
+ /**
31
+ * The `apiKey` published into `models.json` for a proxied slot.
32
+ *
33
+ * Deliberately not a secret and deliberately recognisable: it exists so Pi will admit the
34
+ * provider, and so this proxy can tell "a child we published a route for" from anything else that
35
+ * found the port. It is also the admission token — see {@link admitRequest}.
36
+ */
37
+ export const PROXY_PLACEHOLDER_KEY = "pi-multi-account-proxy";
38
+
39
+ /**
40
+ * The `apiKey` published for a **Codex** slot.
41
+ *
42
+ * Pi's Codex API does not merely pass the key along: before any request it splits it on `.`,
43
+ * base64-decodes the middle segment and reads `chatgpt_account_id` out of it, failing with
44
+ * "Failed to extract accountId from token" if that does not work (measured 2026-08-24 against a
45
+ * real `pi -p --no-extensions` child). So the placeholder for that family has to be shaped like a
46
+ * token even though it is not one.
47
+ *
48
+ * Nothing here is secret and nothing here is real: the algorithm is `none`, there is no signature,
49
+ * and the account id is the placeholder string rather than the user's — the proxy replaces both
50
+ * the authorization header and the account id with the genuine values on the way out, so the
51
+ * published file never carries either.
52
+ */
53
+ export const PROXY_PLACEHOLDER_JWT = [
54
+ Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64"),
55
+ Buffer.from(
56
+ JSON.stringify({
57
+ "https://api.openai.com/auth": { chatgpt_account_id: PROXY_PLACEHOLDER_KEY },
58
+ }),
59
+ ).toString("base64"),
60
+ "",
61
+ ].join(".");
62
+
63
+ /** Either published placeholder — neither is a credential, both mark "a child we published for". */
64
+ export function isPublishedPlaceholder(value: string | undefined): boolean {
65
+ return value === PROXY_PLACEHOLDER_KEY || value === PROXY_PLACEHOLDER_JWT;
66
+ }
67
+
68
+ export type ProxyFamily = "anthropic" | "codex";
69
+
70
+ /**
71
+ * Which family, if any, this parent-owned loopback can serve.
72
+ *
73
+ * Base `anthropic` is included because a bare child on a subscription token is refused as a
74
+ * third-party app (measured 2026-08-30). Base `openai-codex` is not: the same child reached the
75
+ * network on Pi's built-in provider. Numbered Codex/Anthropic slots are included because a
76
+ * models.json entry has no OAuth method, so Pi never consults a published placeholder while an
77
+ * OAuth blob sits under the same key.
78
+ */
79
+ export function proxyFamilyFor(slotId: string): ProxyFamily | undefined {
80
+ if (slotId === "anthropic" || /^anthropic-account-\d+$/.test(slotId)) return "anthropic";
81
+ if (/^openai-codex-account-\d+$/.test(slotId)) return "codex";
82
+ return undefined;
83
+ }
84
+
85
+ /** Numbered slots have no built-in OAuth method; the child-facing credential must be an API key. */
86
+ export function needsChildFacingApiKey(slotId: string): boolean {
87
+ return /(?:-account-\d+)$/.test(slotId) && proxyFamilyFor(slotId) !== undefined;
88
+ }
89
+
90
+ /** Where each family's traffic really goes. */
91
+ export const UPSTREAM_BASE: Readonly<Record<ProxyFamily, string>> = Object.freeze({
92
+ anthropic: "https://api.anthropic.com",
93
+ codex: "https://chatgpt.com/backend-api",
94
+ });
95
+
96
+ export interface ProxyRoute {
97
+ slotId: string;
98
+ family: ProxyFamily;
99
+ }
100
+
101
+ export interface ProxyCredential {
102
+ type?: string;
103
+ access?: string;
104
+ key?: string;
105
+ accountId?: string;
106
+ }
107
+
108
+ /**
109
+ * Headers a proxy must never copy from the incoming request to the outgoing one.
110
+ *
111
+ * The hop-by-hop set is per RFC 9110; `host` and `content-length` are recomputed by the client;
112
+ * `authorization`/`x-api-key` are dropped because replacing them is the entire point of this
113
+ * proxy and forwarding the placeholder would authenticate nothing.
114
+ */
115
+ const DROPPED_HEADERS = new Set([
116
+ "authorization",
117
+ "x-api-key",
118
+ "host",
119
+ "content-length",
120
+ "connection",
121
+ "keep-alive",
122
+ "transfer-encoding",
123
+ "te",
124
+ "trailer",
125
+ "upgrade",
126
+ "proxy-authorization",
127
+ "proxy-authenticate",
128
+ "accept-encoding",
129
+ ]);
130
+
131
+ /**
132
+ * Split `/slot-id/v1/messages` into the slot and the path to forward.
133
+ *
134
+ * Returns `undefined` for anything that is not that shape, including a path that tries to climb
135
+ * out of its slot. A loopback port is reachable by every process on the machine, so the parsing
136
+ * here is the first of the two gates — the second is {@link admitRequest}.
137
+ */
138
+ export function parseProxyPath(
139
+ rawUrl: string,
140
+ ): { slotId: string; rest: string } | undefined {
141
+ if (typeof rawUrl !== "string" || !rawUrl.startsWith("/")) return undefined;
142
+ const [pathname] = rawUrl.split("#");
143
+ const separator = pathname.indexOf("/", 1);
144
+ const slotId = decodeURIComponent(
145
+ separator === -1 ? pathname.slice(1) : pathname.slice(1, separator),
146
+ ).split("?")[0];
147
+ if (!slotId || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slotId)) return undefined;
148
+ const rest = separator === -1 ? "/" : pathname.slice(separator);
149
+ // `..` in any form is refused rather than normalised: there is no legitimate caller that needs
150
+ // it, and normalising is where path-traversal bugs live.
151
+ if (rest.includes("..") || rest.includes("%2e%2e") || rest.includes("%2E%2E")) {
152
+ return undefined;
153
+ }
154
+ return { slotId, rest };
155
+ }
156
+
157
+ /** The credential the caller presented, from either header a Pi provider might use. */
158
+ export function presentedCredential(
159
+ headers: Readonly<Record<string, string | string[] | undefined>>,
160
+ ): string | undefined {
161
+ const pick = (name: string) => {
162
+ const value = headers[name];
163
+ const first = Array.isArray(value) ? value[0] : value;
164
+ return typeof first === "string" ? first.trim() : undefined;
165
+ };
166
+ const bearer = pick("authorization");
167
+ if (bearer) return bearer.replace(/^Bearer\s+/i, "").trim();
168
+ return pick("x-api-key");
169
+ }
170
+
171
+ export type Admission =
172
+ | { ok: true; route: ProxyRoute }
173
+ | { ok: false; status: number; message: string };
174
+
175
+ /**
176
+ * Decide whether to serve a request at all.
177
+ *
178
+ * Two gates, and both matter. The slot has to be one we published — otherwise the port becomes a
179
+ * way to reach any upstream through the user's tokens. And the caller has to present either the
180
+ * published placeholder or the current access token for that slot — otherwise every process on
181
+ * this machine can spend the user's subscription just by finding the port. The placeholder is not
182
+ * a secret; the access token is, and a refusal never echoes either. The access-token path exists
183
+ * because a built-in Anthropic child still presents OAuth: Pi ignores a models.json apiKey when
184
+ * the stored credential is OAuth, and the request only becomes shapable if this proxy accepts it.
185
+ */
186
+ export function admitRequest(args: {
187
+ rawUrl: string;
188
+ headers: Readonly<Record<string, string | string[] | undefined>>;
189
+ routes: ReadonlyMap<string, ProxyRoute>;
190
+ /** Current access tokens for this slot. Never logged; compared only. */
191
+ acceptedSecrets?: readonly string[];
192
+ }): Admission & { rest?: string } {
193
+ const parsed = parseProxyPath(args.rawUrl);
194
+ if (!parsed) return { ok: false, status: 404, message: "not a slot route" };
195
+ const route = args.routes.get(parsed.slotId);
196
+ if (!route) return { ok: false, status: 404, message: "unknown slot" };
197
+ const presented = presentedCredential(args.headers);
198
+ const accepted =
199
+ isPublishedPlaceholder(presented) ||
200
+ (!!presented && !!args.acceptedSecrets?.includes(presented));
201
+ if (!accepted) {
202
+ // Never echo what was presented: it may be a real credential belonging to whoever called.
203
+ return { ok: false, status: 401, message: "this route serves published slots only" };
204
+ }
205
+ return { ok: true, route, rest: parsed.rest };
206
+ }
207
+
208
+ export type UpstreamRequest =
209
+ | { ok: true; url: string; headers: Record<string, string> }
210
+ | { ok: false; status: number; message: string };
211
+
212
+ /**
213
+ * Build the request that actually goes out.
214
+ *
215
+ * The real credential enters here and leaves only in the returned headers — never in a message,
216
+ * because these messages are logged and shown.
217
+ */
218
+ export function shapeUpstreamRequest(args: {
219
+ route: ProxyRoute;
220
+ rest: string;
221
+ headers: Readonly<Record<string, string | string[] | undefined>>;
222
+ credential: ProxyCredential | undefined;
223
+ }): UpstreamRequest {
224
+ const { route, rest, credential } = args;
225
+ const base = UPSTREAM_BASE[route.family];
226
+ if (!base) return { ok: false, status: 500, message: `no upstream for ${route.family}` };
227
+ if (!credential) {
228
+ return {
229
+ ok: false,
230
+ status: 401,
231
+ message: `no credential held for ${route.slotId} — run /login for that account`,
232
+ };
233
+ }
234
+
235
+ const headers: Record<string, string> = {};
236
+ for (const [name, value] of Object.entries(args.headers)) {
237
+ const lower = name.toLowerCase();
238
+ if (DROPPED_HEADERS.has(lower)) continue;
239
+ const first = Array.isArray(value) ? value.join(", ") : value;
240
+ if (typeof first === "string") headers[lower] = first;
241
+ }
242
+
243
+ if (credential.type === "api_key" || (!credential.access && credential.key)) {
244
+ const key = credential.key;
245
+ if (!key) {
246
+ return {
247
+ ok: false,
248
+ status: 401,
249
+ message: `no API key held for ${route.slotId} — run /login for that account`,
250
+ };
251
+ }
252
+ if (route.family === "anthropic") headers["x-api-key"] = key;
253
+ else headers.authorization = `Bearer ${key}`;
254
+ } else {
255
+ if (!credential.access) {
256
+ return {
257
+ ok: false,
258
+ status: 401,
259
+ message: `the credential for ${route.slotId} has no access token — run /login for that account`,
260
+ };
261
+ }
262
+ headers.authorization = `Bearer ${credential.access}`;
263
+ if (route.family === "anthropic") {
264
+ // The header Anthropic requires before it will accept a subscription token at all.
265
+ headers["anthropic-beta"] = mergeBeta(headers["anthropic-beta"], "oauth-2025-04-20");
266
+ } else if (route.family === "codex") {
267
+ // Codex routes a subscription request by account. Pi fills this header in from the
268
+ // placeholder it was given, so it must be overwritten here — not defaulted — or the
269
+ // request goes upstream naming an account that does not exist.
270
+ if (credential.accountId) headers["chatgpt-account-id"] = credential.accountId;
271
+ else delete headers["chatgpt-account-id"];
272
+ headers.originator = headers.originator ?? "pi";
273
+ }
274
+ }
275
+
276
+ return { ok: true, url: `${base}${rest}`, headers };
277
+ }
278
+
279
+ /** Keep any beta flags the caller asked for, and add ours once. */
280
+ function mergeBeta(existing: string | undefined, required: string): string {
281
+ if (!existing) return required;
282
+ const parts = existing
283
+ .split(",")
284
+ .map((part) => part.trim())
285
+ .filter(Boolean);
286
+ if (!parts.includes(required)) parts.push(required);
287
+ return parts.join(",");
288
+ }
289
+
290
+ /** The `apiKey` to publish for a slot of this family. */
291
+ export function placeholderKeyFor(family: ProxyFamily): string {
292
+ return family === "codex" ? PROXY_PLACEHOLDER_JWT : PROXY_PLACEHOLDER_KEY;
293
+ }
294
+
295
+ /** The `models.json` route to publish for a slot served by this proxy. */
296
+ export function publishedRouteFor(port: number, slotId: string): string {
297
+ return `http://127.0.0.1:${port}/${slotId}`;
298
+ }
299
+
300
+ /** True when this models.json entry is a loopback we published, not a user's own provider. */
301
+ export function isOwnLoopbackPublication(
302
+ existing: unknown,
303
+ family: ProxyFamily,
304
+ ): boolean {
305
+ if (!existing || typeof existing !== "object") return false;
306
+ const rec = existing as Record<string, unknown>;
307
+ return (
308
+ rec.apiKey === placeholderKeyFor(family) &&
309
+ typeof rec.baseUrl === "string" &&
310
+ rec.baseUrl.startsWith("http://127.0.0.1:")
311
+ );
312
+ }
313
+
314
+ /** Return the user-authored model override layer from a provider entry, if present. */
315
+ export function preservedModelOverrides(
316
+ existing: unknown,
317
+ ): Record<string, unknown> | undefined {
318
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) return undefined;
319
+ const overrides = (existing as Record<string, unknown>).modelOverrides;
320
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) return undefined;
321
+ if (Object.keys(overrides).length === 0) return undefined;
322
+ return overrides as Record<string, unknown>;
323
+ }
324
+
325
+ /**
326
+ * Drop loopback publications this proxy wrote. Cursor and user entries stay. A user's
327
+ * modelOverrides remain as a valid built-in-provider overlay after the generated route is gone.
328
+ * Pass slot ids to drop only those; omit to drop every own loopback (a dead port left behind).
329
+ */
330
+ export function dropOwnLoopbackPublications(
331
+ providers: Readonly<Record<string, unknown>>,
332
+ slotIds?: readonly string[],
333
+ ): Record<string, unknown> {
334
+ const next: Record<string, unknown> = { ...providers };
335
+ const ids = slotIds ?? Object.keys(next);
336
+ for (const id of ids) {
337
+ const family = proxyFamilyFor(id);
338
+ if (!family || !isOwnLoopbackPublication(next[id], family)) continue;
339
+ const modelOverrides = preservedModelOverrides(next[id]);
340
+ if (modelOverrides) next[id] = { modelOverrides };
341
+ else delete next[id];
342
+ }
343
+ return next;
344
+ }
@@ -0,0 +1,67 @@
1
+ /** Shared cooldown/cache state: lock before reading, merge local deltas, publish atomically. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { createRequire } from "node:module";
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const pause = new Int32Array(new SharedArrayBuffer(4));
9
+ const MAP_FIELDS = ["exhaustedUntilByProvider", "exhaustedUntilByModel", "invalidatedByProvider",
10
+ "usageUntrustedUntilByProvider", "usageByProvider", "codexModelCatalogByProvider", "lastProbeAtByProvider"];
11
+ type State = Record<string, any>;
12
+
13
+ export function mergeStateDeltas<T extends State>(base: T, local: T, disk: T): T {
14
+ const merged = { ...local };
15
+ for (const field of MAP_FIELDS) {
16
+ const before = base[field] ?? {}, after = local[field] ?? {};
17
+ const values = { ...(disk[field] ?? {}) };
18
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
19
+ if (JSON.stringify(before[key]) === JSON.stringify(after[key])) continue;
20
+ if (Object.hasOwn(after, key)) {
21
+ // A slower fetch must not overwrite a newer observation of the same credential.
22
+ const newer = values[key], candidate = after[key];
23
+ if ((field === "usageByProvider" || field === "codexModelCatalogByProvider") &&
24
+ newer?.credentialHash === candidate?.credentialHash && newer?.fetchedAt > candidate?.fetchedAt) continue;
25
+ values[key] = candidate;
26
+ } else delete values[key];
27
+ }
28
+ (merged as State)[field] = values;
29
+ }
30
+ return merged;
31
+ }
32
+
33
+ export function mutateStateFile<T extends State>(path: string, transform: (disk: T) => T): T {
34
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
+ if (!existsSync(path)) {
36
+ try { writeFileSync(path, "{}\n", { mode: 0o600, flag: "wx" }); }
37
+ catch (error: any) { if (error?.code !== "EEXIST") throw error; }
38
+ }
39
+ const lockfile = require("proper-lockfile") as { lockSync(path: string, options: object): () => void };
40
+ let release: (() => void) | undefined;
41
+ // Writes are synchronous and short. Bound contention rather than hanging the Pi event loop.
42
+ const deadline = Date.now() + 250;
43
+ while (!release) {
44
+ try { release = lockfile.lockSync(path, { realpath: false }); }
45
+ catch (error: any) {
46
+ if (error?.code !== "ELOCKED" || Date.now() >= deadline) throw error;
47
+ Atomics.wait(pause, 0, 0, 5);
48
+ }
49
+ }
50
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
51
+ try {
52
+ let disk: T;
53
+ try {
54
+ disk = JSON.parse(readFileSync(path, "utf8"));
55
+ if (!disk || typeof disk !== "object" || Array.isArray(disk)) disk = {} as T;
56
+ } catch (error) {
57
+ if (!(error instanceof SyntaxError)) throw error;
58
+ disk = {} as T; // Derived state is recoverable; credentials never enter this file.
59
+ }
60
+ const next = transform(disk);
61
+ writeFileSync(temp, `${JSON.stringify(next, null, "\t")}\n`, { mode: 0o600, flag: "wx" });
62
+ renameSync(temp, path);
63
+ return next;
64
+ } finally {
65
+ try { rmSync(temp, { force: true }); } finally { release(); }
66
+ }
67
+ }