@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,281 @@
1
+ /**
2
+ * What this extension assumes about Pi, written down — and checked.
3
+ *
4
+ * ## Why a ledger, and why it distinguishes two kinds of assumption
5
+ *
6
+ * A Pi update has broken this extension before, and the two cases were not the same shape:
7
+ *
8
+ * - **A method disappeared.** `AuthStorage` dropped `set()` on pi 0.84.x, which is what this
9
+ * extension persisted a refreshed Anthropic token with. The file format never changed; the way
10
+ * to write it did. Symptom: a fresh `/login anthropic` roughly once a day.
11
+ * - **A file schema was stricter than we wrote.** Slot catalogues were written into `models.json`
12
+ * as bare id strings where Pi requires each model to be an object. Pi then rejected the
13
+ * **entire file**, so every custom provider the user had disappeared at once.
14
+ *
15
+ * Both were silent from the outside and expensive to trace. The defence is not to guess better;
16
+ * it is to state the dependency, mark whether Pi actually promised it, and notice when it stops
17
+ * being true.
18
+ *
19
+ * ## Three rules this module exists to hold
20
+ *
21
+ * 1. **Read only the published files.** Pi hands the outside world exactly three:
22
+ * `auth.json`, `models.json`, `settings.json`. A bare `pi -p --no-extensions` child reads
23
+ * those and nothing else — verified by experiment on 2026-08-24. Anything we need from
24
+ * deeper inside Pi is a design smell, not a dependency to formalise.
25
+ * 2. **Never write those files directly.** Writing goes through Pi, which owns locking,
26
+ * concurrent access and migrations. The `AuthStorage` incident is what happens when the
27
+ * write path is treated as ours.
28
+ * 3. **Check the shape, because nothing else will.** `auth.json` and `models.json` carry **no
29
+ * schema version** (checked: neither has a version field; only `settings.json` records a Pi
30
+ * version, which is the app's, not the format's). So a format change cannot announce itself.
31
+ * The only way to notice is to look, and to say so out loud rather than fail later somewhere
32
+ * unrelated.
33
+ *
34
+ * Pure by construction: this module reads nothing and writes nothing. It is handed already-parsed
35
+ * values and returns verdicts, so the whole ledger can be tested without a filesystem.
36
+ */
37
+
38
+ /** Whether Pi actually promises a thing, or we merely observed it holding. */
39
+ export type AssumptionKind = "documented" | "observed";
40
+
41
+ export interface PiAssumption {
42
+ id: string;
43
+ /** The assumption, stated so it can be checked by a person after an update. */
44
+ fact: string;
45
+ kind: AssumptionKind;
46
+ /** Where it is promised, or where it was observed. */
47
+ surface: string;
48
+ /** What stops working here when it stops being true. */
49
+ breaks: string;
50
+ }
51
+
52
+ /**
53
+ * Every load-bearing assumption this extension makes about Pi.
54
+ *
55
+ * The `observed` rows are the re-check list after each Pi upgrade: they are behaviour nobody
56
+ * promised, and they can change without any note in a changelog.
57
+ */
58
+ export const PI_ASSUMPTIONS: readonly PiAssumption[] = Object.freeze([
59
+ Object.freeze({
60
+ id: "auth-file",
61
+ fact: "Credentials live in auth.json, one entry per provider id, each carrying a `type` of `oauth` or `api_key`.",
62
+ kind: "documented" as const,
63
+ surface: "docs/providers.md, docs/models.md",
64
+ breaks: "Account discovery: the rotation is built from these entries.",
65
+ }),
66
+ Object.freeze({
67
+ id: "models-file",
68
+ fact: "models.json holds `providers`, and each provider's `models` is an array of OBJECTS; a bare id string invalidates the whole file.",
69
+ kind: "documented" as const,
70
+ surface: "docs/custom-provider.md, docs/models.md",
71
+ breaks: "Every custom provider the user has, not only ours — Pi rejects the file wholesale.",
72
+ }),
73
+ Object.freeze({
74
+ id: "settings-default-model-key",
75
+ fact: "settings.json carries `defaultProvider` and `defaultModel`.",
76
+ kind: "documented" as const,
77
+ surface: "docs/settings.md",
78
+ breaks: "Nothing directly; it is the key the next row writes to.",
79
+ }),
80
+ Object.freeze({
81
+ id: "settings-default-model-versioned-persistence",
82
+ fact: "Pi <=0.84.2 writes defaultProvider/defaultModel on every model switch; Pi >=0.84.3 keeps ordinary model selection session-scoped and writes the global default only for an explicit persistent selection.",
83
+ kind: "documented" as const,
84
+ surface: "Pi 0.84.3 changelog and AgentSession.setModel(model, { persist }): ordinary selection no longer rewrites the global default; Ctrl+S remains the explicit persistence action.",
85
+ breaks: "A bare child launched without an explicit --model inherits only the saved global default, not pi-multi-account's live rotation slot. Explicitly model-pinned broker/subagent children are unaffected.",
86
+ }),
87
+ Object.freeze({
88
+ id: "child-reads-published-files-only",
89
+ fact: "A `pi -p --no-extensions` child resolves providers from models.json plus built-ins, and credentials from auth.json — it cannot see extension-registered providers.",
90
+ kind: "documented" as const,
91
+ surface: "docs/usage.md (--no-extensions); confirmed by experiment 2026-08-24",
92
+ breaks: "Every claim about what a bare child can run on.",
93
+ }),
94
+ Object.freeze({
95
+ id: "oauth-needs-provider-declared-flow",
96
+ fact: "Pi honours an OAuth credential only for a provider definition that declares the flow; a models.json entry declares none, so an OAuth token under that key is never used.",
97
+ kind: "observed" as const,
98
+ surface: "Measured 2026-08-24: a published alias slot with an OAuth credential fails with \"No API key found\"; the built-in provider with the same credential authenticates.",
99
+ breaks: "Publishing OAuth rotation slots for children — the reason a parent-owned proxy with a placeholder key is required rather than optional.",
100
+ }),
101
+ Object.freeze({
102
+ id: "credential-writes-go-through-pi",
103
+ fact: "Credential writes must use Pi's own locked storage API, never a direct file write.",
104
+ kind: "documented" as const,
105
+ surface: "AuthStorage; learned the hard way when pi 0.84.x dropped set() and a refreshed token was thrown away daily.",
106
+ breaks: "Token refresh: a rotated credential that cannot be persisted is a burned credential.",
107
+ }),
108
+ ]);
109
+
110
+ /** The three files Pi publishes to the outside world. Nothing else is an interface. */
111
+ export const PUBLISHED_FILES = Object.freeze(["auth.json", "models.json", "settings.json"] as const);
112
+
113
+ export interface ShapeVerdict {
114
+ file: string;
115
+ ok: boolean;
116
+ /** Populated when `ok` is false: what looked wrong, in terms a person can act on. */
117
+ problems: string[];
118
+ }
119
+
120
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
122
+ }
123
+
124
+ const SESSION_SCOPED_MODEL_SELECTION_SINCE = Object.freeze([0, 84, 3] as const);
125
+
126
+ /**
127
+ * Whether an ordinary Pi model switch is expected to rewrite the saved global default.
128
+ *
129
+ * Pi 0.84.3 deliberately made model/thinking selection session-scoped unless the caller passes
130
+ * `{ persist: true }` (the TUI's explicit Ctrl+S action). Unknown/non-semver hosts are treated as
131
+ * session-scoped: emitting a loud compatibility warning from an assumption we cannot establish is
132
+ * worse than omitting an optional legacy diagnostic.
133
+ */
134
+ export function piAutoPersistsSelectedModel(version: string): boolean {
135
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
136
+ if (!match) return false;
137
+ const actual = match.slice(1, 4).map(Number) as [number, number, number];
138
+ for (let index = 0; index < SESSION_SCOPED_MODEL_SELECTION_SINCE.length; index++) {
139
+ const delta = actual[index] - SESSION_SCOPED_MODEL_SELECTION_SINCE[index];
140
+ if (delta !== 0) return delta < 0;
141
+ }
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * `auth.json`: a flat map of provider id → credential record carrying a `type`.
147
+ *
148
+ * Deliberately shallow. This must never touch, log or return a secret — it reports the shape of
149
+ * the container and nothing about what is inside it.
150
+ */
151
+ export function checkAuthShape(raw: unknown): ShapeVerdict {
152
+ const problems: string[] = [];
153
+ if (!isPlainObject(raw)) {
154
+ return { file: "auth.json", ok: false, problems: ["not a JSON object"] };
155
+ }
156
+ for (const [provider, entry] of Object.entries(raw)) {
157
+ if (!isPlainObject(entry)) {
158
+ problems.push(`${provider}: entry is not an object`);
159
+ continue;
160
+ }
161
+ const type = entry.type;
162
+ if (type !== "oauth" && type !== "api_key") {
163
+ // A new credential kind is not necessarily a break — but it is exactly the kind of quiet
164
+ // change that makes account discovery skip an account without saying why.
165
+ problems.push(`${provider}: unfamiliar credential type ${JSON.stringify(type)}`);
166
+ }
167
+ }
168
+ return { file: "auth.json", ok: problems.length === 0, problems };
169
+ }
170
+
171
+ /**
172
+ * `models.json`: `providers` → each with a `models` ARRAY OF OBJECTS.
173
+ *
174
+ * The array-of-objects rule is the one that has already cost a user every custom provider they
175
+ * had: Pi validates the whole file and rejects all of it when one entry is a bare string.
176
+ */
177
+ export function checkModelsShape(raw: unknown): ShapeVerdict {
178
+ const problems: string[] = [];
179
+ if (!isPlainObject(raw)) {
180
+ return { file: "models.json", ok: false, problems: ["not a JSON object"] };
181
+ }
182
+ const providers = raw.providers;
183
+ if (providers === undefined) {
184
+ // An absent registry is legitimate — the user may have no custom providers at all.
185
+ return { file: "models.json", ok: true, problems: [] };
186
+ }
187
+ if (!isPlainObject(providers)) {
188
+ return { file: "models.json", ok: false, problems: ["`providers` is not an object"] };
189
+ }
190
+ for (const [provider, definition] of Object.entries(providers)) {
191
+ if (!isPlainObject(definition)) {
192
+ problems.push(`${provider}: definition is not an object`);
193
+ continue;
194
+ }
195
+ const models = definition.models;
196
+ if (models === undefined) continue;
197
+ if (!Array.isArray(models)) {
198
+ problems.push(`${provider}: \`models\` is not an array`);
199
+ continue;
200
+ }
201
+ const stringEntries = models.filter((model) => typeof model === "string").length;
202
+ if (stringEntries > 0) {
203
+ problems.push(
204
+ `${provider}: ${stringEntries} model entr${stringEntries === 1 ? "y is" : "ies are"} a bare string; Pi requires objects and rejects the ENTIRE file, taking every other custom provider with it`,
205
+ );
206
+ }
207
+ }
208
+ return { file: "models.json", ok: problems.length === 0, problems };
209
+ }
210
+
211
+ /**
212
+ * `settings.json`: the saved default keys are optional, but must be strings when present.
213
+ *
214
+ * Since Pi 0.84.3 their absence is ordinary: a live model selection is session-scoped unless the
215
+ * user explicitly persists it. Whether a saved value must track the live model is therefore a
216
+ * versioned behavioural check, not part of the file's shape.
217
+ */
218
+ export function checkSettingsShape(raw: unknown): ShapeVerdict {
219
+ const problems: string[] = [];
220
+ if (!isPlainObject(raw)) {
221
+ return { file: "settings.json", ok: false, problems: ["not a JSON object"] };
222
+ }
223
+ const provider = raw.defaultProvider;
224
+ const model = raw.defaultModel;
225
+ if (provider !== undefined && typeof provider !== "string") problems.push("`defaultProvider` is not a string");
226
+ if (model !== undefined && typeof model !== "string") problems.push("`defaultModel` is not a string");
227
+ if ((provider === undefined) !== (model === undefined)) {
228
+ problems.push("`defaultProvider` and `defaultModel` must either both be strings or both be absent");
229
+ }
230
+ return { file: "settings.json", ok: problems.length === 0, problems };
231
+ }
232
+
233
+ /**
234
+ * One message for the user when a published file no longer looks the way we depend on it looking.
235
+ * Returns `undefined` when everything matches, so a healthy install stays silent.
236
+ */
237
+ export function describeContractDrift(verdicts: readonly ShapeVerdict[]): string | undefined {
238
+ const broken = verdicts.filter((verdict) => !verdict.ok);
239
+ if (broken.length === 0) return undefined;
240
+ const lines = broken.flatMap((verdict) => [
241
+ ` ${verdict.file}:`,
242
+ ...verdict.problems.map((problem) => ` - ${problem}`),
243
+ ]);
244
+ return [
245
+ "pi-multi-account: a file Pi publishes no longer matches what this extension depends on.",
246
+ "This is usually a Pi upgrade changing a format that carries no version, so nothing announced it.",
247
+ ...lines,
248
+ "Rotation may behave oddly until this is resolved; run /multi-account status for the current view.",
249
+ ].join("\n");
250
+ }
251
+
252
+ /**
253
+ * Legacy-only behavioural check: on Pi <=0.84.2, does `settings.json` name the model running now?
254
+ *
255
+ * Pi >=0.84.3 intentionally separates the live session selection from the saved global default,
256
+ * so callers MUST gate this check with `piAutoPersistsSelectedModel()`. A bare child without an
257
+ * explicit `--model` then inherits the saved default by design; a broker/subagent child with an
258
+ * explicit provider/model remains isolated from that global value.
259
+ */
260
+ export function checkSettingsTracksActive(
261
+ raw: unknown,
262
+ active: { provider: string; id: string },
263
+ ): ShapeVerdict {
264
+ const base = checkSettingsShape(raw);
265
+ if (!base.ok) return base;
266
+ const settings = raw as Record<string, unknown>;
267
+ const problems: string[] = [];
268
+ const recorded = `${settings.defaultProvider ?? "(unset)"}/${settings.defaultModel ?? "(unset)"}`;
269
+ const live = `${active.provider}/${active.id}`;
270
+ if (recorded !== live) {
271
+ problems.push(
272
+ `records ${recorded} while the session is running ${live}; Pi <=0.84.2 is expected to rewrite both keys on every model switch, while a bare extension-free child without --model reads the saved value — so that child would run on ${recorded}, not on the account the rotation selected`,
273
+ );
274
+ }
275
+ return { file: "settings.json", ok: problems.length === 0, problems };
276
+ }
277
+
278
+ /** The assumptions to re-check after a Pi upgrade — the ones nobody promised. */
279
+ export function observedAssumptions(): readonly PiAssumption[] {
280
+ return PI_ASSUMPTIONS.filter((assumption) => assumption.kind === "observed");
281
+ }
@@ -0,0 +1,44 @@
1
+ import { createRequire } from "node:module";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ type Shape = (payload: any, model: any, options: any) => any;
5
+ type Api = { streamSimple: (model: any, context: any, options: any) => any };
6
+ const requireLocal = createRequire(import.meta.url);
7
+ let registry: { getApiProvider: (api: string) => Api | undefined } | undefined;
8
+
9
+ function nativeApi(api: string): Api {
10
+ if (!registry) {
11
+ // Pi >= 0.80 moved the API registry to /compat. Resolve lazily so merely
12
+ // listing OAuth providers does not require loading a transport.
13
+ for (const entry of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
14
+ try {
15
+ const candidate = requireLocal(fileURLToPath(import.meta.resolve(entry)));
16
+ if (typeof candidate.getApiProvider === "function") { registry = candidate; break; }
17
+ } catch { /* Try the older public entry point. */ }
18
+ }
19
+ }
20
+ const provider = registry?.getApiProvider(api);
21
+ if (!provider) throw new Error(`Pi native API is unavailable: ${api}`);
22
+ return provider;
23
+ }
24
+
25
+ /** Provider-level shaping applies to every public Pi client, including calls
26
+ * outside the interactive agent's before_provider_request event lifecycle. */
27
+ export function createPayloadStream(shape: Shape, resolveApi = nativeApi) {
28
+ return (model: any, context: any, options: any = {}) => resolveApi(model.api).streamSimple(model, context, {
29
+ ...options,
30
+ onPayload: async (payload: any, actualModel: any) => {
31
+ const replacement = await options.onPayload?.(payload, actualModel);
32
+ const current = replacement === undefined ? payload : replacement;
33
+ const shaped = await shape(current, actualModel, options);
34
+ return shaped === undefined ? current : shaped;
35
+ },
36
+ });
37
+ }
38
+
39
+ export const cursorPayloadStream = createPayloadStream((payload, _model, options) => {
40
+ if (payload && typeof payload === "object" && typeof options.sessionId === "string" && options.sessionId.trim()) {
41
+ payload.pi_session_id = options.sessionId;
42
+ }
43
+ return payload;
44
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Where work goes once the account it was on cannot take it.
3
+ *
4
+ * ## What was actually happening
5
+ *
6
+ * Rotation already gets the first step right: of 602 automatic failovers in this machine's black
7
+ * box, 588 stayed inside the same provider family — the same subscription, the same model, just
8
+ * another account. That is the behaviour to protect, and nothing here weakens it.
9
+ *
10
+ * The other 14 are the problem. Those are the hops taken once every account of the current family
11
+ * was spent, and they went nowhere in particular:
12
+ *
13
+ * kimi-coding → openai-codex 3 cursor → openai-codex 1
14
+ * openai-codex → anthropic 3 anthropic → cursor 1
15
+ * kimi-coding → anthropic 2 ollama → kimi-coding 2
16
+ * kimi-coding → cursor 2
17
+ *
18
+ * There was no ladder being followed, because nothing expressed one. The comparator ordered
19
+ * cross-family candidates by liveness telemetry — measured-free first, then predicted-free, then
20
+ * whoever refused longest ago — and only fell through to the configured family order as a last
21
+ * tiebreak, by which point it almost never spoke. So the answer to "the whole family is spent,
22
+ * where now?" was whatever the telemetry happened to say that second.
23
+ *
24
+ * The second half of the same gap: `providerOrder` could only ever name the six specially-managed
25
+ * families. Accounts outside them — openrouter, zai, minimax, opencode-go-api, openai — could not
26
+ * be placed in the order at all, so they sat permanently last by construction. In 602 automatic
27
+ * failovers not one ever reached them. That is right as a default (they bill per token, while the
28
+ * managed families are flat-rate subscriptions), but it should be a stated policy the user can
29
+ * change, not an accident of the type system.
30
+ *
31
+ * ## The ladder
32
+ *
33
+ * A flat, ordered list of provider GROUPS. A group is a managed family (`anthropic`,
34
+ * `openai-codex`, `kimi-coding`, `cursor`, `qwen`, `ollama`) or the base id of anything else the
35
+ * user is logged in to (`openrouter`, `zai`, `minimax`, …) — numbered rotation slots of the same
36
+ * account (`openai-codex-account-3`) all collapse to one group, because preferring one slot over
37
+ * its sibling is rotation's job, not the ladder's.
38
+ *
39
+ * Three rules, and they are deliberately narrow:
40
+ *
41
+ * 1. **The ladder never overrides same-family.** Staying on the family preserves the model the
42
+ * user picked, and it is the step that already works. The ladder only decides what happens
43
+ * after that step has run out.
44
+ * 2. **The ladder never overrides availability.** An account on a real cooldown is not chosen
45
+ * while a free one exists, whatever the ladder says. The ladder reorders candidates that are
46
+ * all selectable right now; it does not resurrect spent ones.
47
+ * 3. **A group nobody ranked sorts after every group somebody did**, keeping its existing order
48
+ * among its unranked peers. Silence is not a preference, so it must not act like one.
49
+ *
50
+ * Within those bounds the ladder outranks the liveness heuristics (`confirmed`, `predictedBusy`),
51
+ * and that is the point. Those heuristics answer "did the provider recently tell us it is free?",
52
+ * whose honest answer for an unmeasurable provider is always "no" — absence of measurement, not
53
+ * evidence of exhaustion. A stated preference is better evidence than a missing measurement. The
54
+ * cost when the ladder is wrong is bounded and self-correcting: one request, one refusal, a
55
+ * cooldown, and the account drops out of the candidate list on its own.
56
+ */
57
+
58
+ /** Groups this extension manages directly: OAuth refresh, quota telemetry, live catalogue. */
59
+ export const MANAGED_GROUPS = [
60
+ "anthropic",
61
+ "openai-codex",
62
+ "kimi-coding",
63
+ "cursor",
64
+ "qwen",
65
+ "ollama",
66
+ ] as const;
67
+
68
+ /**
69
+ * The ladder shipped by default.
70
+ *
71
+ * Flat-rate subscriptions with real quota telemetry first, strongest coding models earliest;
72
+ * then the cheap/local tier; then — by omission — everything else, which is where per-token
73
+ * billing lands. It is the same sequence rotation already used as its final tiebreak, promoted
74
+ * to a stated policy so the cross-family hop stops being decided by whatever the telemetry
75
+ * happened to say at that moment.
76
+ */
77
+ export const DEFAULT_PROVIDER_PRIORITY: string[] = [
78
+ "anthropic",
79
+ "openai-codex",
80
+ "kimi-coding",
81
+ "cursor",
82
+ "qwen",
83
+ "ollama",
84
+ ];
85
+
86
+ /** What people actually type. Kept small and obvious rather than clever. */
87
+ const ALIASES: Record<string, string> = {
88
+ claude: "anthropic",
89
+ opus: "anthropic",
90
+ sonnet: "anthropic",
91
+ codex: "openai-codex",
92
+ chatgpt: "openai-codex",
93
+ gpt: "openai-codex",
94
+ openaicodex: "openai-codex",
95
+ kimi: "kimi-coding",
96
+ k3: "kimi-coding",
97
+ moonshot: "kimi-coding",
98
+ qwen: "qwen",
99
+ alibaba: "qwen",
100
+ dashscope: "qwen",
101
+ glm: "zai",
102
+ zhipu: "zai",
103
+ router: "openrouter",
104
+ };
105
+
106
+ /**
107
+ * Reduce anything the user or the runtime might say to one group name.
108
+ *
109
+ * Handles a bare family, a numbered rotation slot (`kimi-coding-account-2`), a provider/model
110
+ * pair (`openai-codex/gpt-5.6-sol`), and the common nicknames above. Unknown names pass through
111
+ * normalised rather than being rejected: a provider this build has never heard of is exactly the
112
+ * case the old `ProviderFamily[]` type made unrankable, and it must be rankable now.
113
+ */
114
+ export function normalizeGroup(raw: string): string {
115
+ const first = String(raw ?? "")
116
+ .trim()
117
+ .split("/")[0]
118
+ .trim()
119
+ .toLowerCase();
120
+ if (!first) return "";
121
+ const base = first.replace(/-account-\d+$/, "");
122
+ if ((MANAGED_GROUPS as readonly string[]).includes(base)) return base;
123
+ const alias = ALIASES[base.replace(/[^a-z0-9]/g, "")];
124
+ return alias ?? base;
125
+ }
126
+
127
+ /**
128
+ * Clean a user-supplied ladder: normalise each entry, drop blanks, drop repeats (first mention
129
+ * wins, because that is what the person meant by putting it there).
130
+ */
131
+ export function normalizePriority(raw: unknown): string[] {
132
+ const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\s,]+/) : [];
133
+ const out: string[] = [];
134
+ for (const item of list) {
135
+ if (typeof item !== "string") continue;
136
+ const group = normalizeGroup(item);
137
+ if (!group || out.includes(group)) continue;
138
+ out.push(group);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * Position of a group on the ladder. `Number.MAX_SAFE_INTEGER` for anything unranked, so an
145
+ * unranked group sorts after every ranked one without needing a second comparison.
146
+ */
147
+ export function priorityRank(group: string, ladder: readonly string[]): number {
148
+ const normalized = normalizeGroup(group);
149
+ const index = ladder.indexOf(normalized);
150
+ return index < 0 ? Number.MAX_SAFE_INTEGER : index;
151
+ }
152
+
153
+ /**
154
+ * Comparator contribution for two candidates.
155
+ *
156
+ * Returns 0 — "no opinion, let the next tiebreak decide" — whenever neither side is ranked, and
157
+ * also when both sit at the same position. Only an actual difference in stated preference moves
158
+ * anything, which is what keeps rule 3 honest: an unranked pair is left exactly as it was.
159
+ */
160
+ export function comparePriority(
161
+ groupA: string,
162
+ groupB: string,
163
+ ladder: readonly string[],
164
+ ): number {
165
+ if (ladder.length === 0) return 0;
166
+ const a = priorityRank(groupA, ladder);
167
+ const b = priorityRank(groupB, ladder);
168
+ if (a === b) return 0;
169
+ return a - b;
170
+ }
171
+
172
+ /** One line per rung, for `/multi-account priority` and the status panel. */
173
+ export function describePriority(
174
+ ladder: readonly string[],
175
+ present: readonly string[] = [],
176
+ ): string[] {
177
+ const seen = new Set(present.map(normalizeGroup));
178
+ const lines = ladder.map((group, index) => {
179
+ const mark = seen.size === 0 ? "" : seen.has(group) ? "" : " (not logged in)";
180
+ return ` ${index + 1}. ${group}${mark}`;
181
+ });
182
+ const rest = [...seen].filter((group) => !ladder.includes(group)).sort();
183
+ if (rest.length > 0) {
184
+ lines.push(` ${ladder.length + 1}. everything else — ${rest.join(", ")}`);
185
+ } else {
186
+ lines.push(` ${ladder.length + 1}. everything else`);
187
+ }
188
+ return lines;
189
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * How an OAuth slot is shown to an extension-free child without dropping the
3
+ * subscription token the parent still needs.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Pi's `resolveProviderAuth` keys off the stored credential *type* first. For a models.json-only
8
+ * provider (numbered `*-account-N` slots, and Cursor — which is not a Pi built-in) that means:
9
+ *
10
+ * stored OAuth + no OAuth method on the provider → undefined → "No API key found"
11
+ *
12
+ * A published placeholder in models.json is never consulted. The empty-auth.json canary hid this,
13
+ * because there was no stored blob to win. On a real machine the blob is there — that is the
14
+ * subprocess failure for memory review/consolidation. Measured again 2026-08-30 after restart:
15
+ * the parent session was on `cursor/cursor-grok-4.6`, and
16
+ * `pi -p --no-extensions --model cursor/cursor-grok-4.6` died with `No API key found for cursor`
17
+ * while `anthropic/claude-opus-5` through the new loopback returned OK.
18
+ *
19
+ * The parent still needs the OAuth blob (refresh, identity headers, upstream). So while the
20
+ * matching parent-owned proxy is listening the child-facing `auth.json` entry becomes a
21
+ * non-secret api_key placeholder, and the OAuth blob lives in a sidecar only the parent reads.
22
+ * On stop, the blob is written back. Base `anthropic` is not shadowed: Pi already has an OAuth
23
+ * method for it, and the child presents the real token to the loopback, which admits it.
24
+ */
25
+ import {
26
+ CURSOR_PROXY_PLACEHOLDER_KEY,
27
+ isCursorProviderId,
28
+ } from "./cursor-bridge.ts";
29
+ import {
30
+ needsChildFacingApiKey,
31
+ placeholderKeyFor,
32
+ proxyFamilyFor,
33
+ type ProxyFamily,
34
+ } from "./slot-proxy.ts";
35
+
36
+ export type AuthBlob = {
37
+ type?: string;
38
+ access?: string;
39
+ refresh?: string;
40
+ expires?: number;
41
+ key?: string;
42
+ accountId?: string;
43
+ };
44
+
45
+ /** The non-secret api_key a child must see, or undefined if this slot is not shadowed. */
46
+ export function childFacingPlaceholderKey(slotId: string): string | undefined {
47
+ if (isCursorProviderId(slotId)) return CURSOR_PROXY_PLACEHOLDER_KEY;
48
+ if (!needsChildFacingApiKey(slotId)) return undefined;
49
+ const family = proxyFamilyFor(slotId);
50
+ return family ? placeholderKeyFor(family) : undefined;
51
+ }
52
+
53
+ export function needsAuthShadow(slotId: string): boolean {
54
+ return childFacingPlaceholderKey(slotId) !== undefined;
55
+ }
56
+
57
+ export function childFacingAuthEntry(family: ProxyFamily): AuthBlob {
58
+ return { type: "api_key", key: placeholderKeyFor(family) };
59
+ }
60
+
61
+ export function childFacingAuthEntryForSlot(slotId: string): AuthBlob | undefined {
62
+ const key = childFacingPlaceholderKey(slotId);
63
+ return key ? { type: "api_key", key } : undefined;
64
+ }
65
+
66
+ export function isChildFacingPlaceholder(
67
+ entry: AuthBlob | undefined,
68
+ family: ProxyFamily,
69
+ ): boolean {
70
+ return entry?.type === "api_key" && entry.key === placeholderKeyFor(family);
71
+ }
72
+
73
+ export function isChildFacingPlaceholderForSlot(
74
+ entry: AuthBlob | undefined,
75
+ slotId: string,
76
+ ): boolean {
77
+ const key = childFacingPlaceholderKey(slotId);
78
+ return !!key && entry?.type === "api_key" && entry.key === key;
79
+ }
80
+
81
+ /** What the parent should use for refresh/upstream: sidecar OAuth wins over a child-facing placeholder. */
82
+ export function mergeParentAuth(
83
+ auth: Readonly<Record<string, AuthBlob>>,
84
+ sidecar: Readonly<Record<string, AuthBlob>>,
85
+ ): Record<string, AuthBlob> {
86
+ const out: Record<string, AuthBlob> = { ...auth };
87
+ for (const [slotId, hidden] of Object.entries(sidecar)) {
88
+ if (hidden?.type !== "oauth") continue;
89
+ if (isChildFacingPlaceholderForSlot(out[slotId], slotId)) out[slotId] = hidden;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export function applyShadowPlan(
95
+ slotId: string,
96
+ auth: Readonly<Record<string, AuthBlob>>,
97
+ sidecar: Readonly<Record<string, AuthBlob>>,
98
+ ): { auth: Record<string, AuthBlob>; sidecar: Record<string, AuthBlob>; changed: boolean } {
99
+ const facing = childFacingAuthEntryForSlot(slotId);
100
+ if (!facing) {
101
+ return { auth: { ...auth }, sidecar: { ...sidecar }, changed: false };
102
+ }
103
+ const entry = auth[slotId];
104
+ if (entry?.type === "oauth" && typeof entry.access === "string" && entry.access.length > 0) {
105
+ return {
106
+ auth: { ...auth, [slotId]: facing },
107
+ sidecar: { ...sidecar, [slotId]: entry },
108
+ changed: true,
109
+ };
110
+ }
111
+ return { auth: { ...auth }, sidecar: { ...sidecar }, changed: false };
112
+ }
113
+
114
+ export function applyRestorePlan(
115
+ slotId: string,
116
+ auth: Readonly<Record<string, AuthBlob>>,
117
+ sidecar: Readonly<Record<string, AuthBlob>>,
118
+ ): { auth: Record<string, AuthBlob>; sidecar: Record<string, AuthBlob>; changed: boolean } {
119
+ const hidden = sidecar[slotId];
120
+ const nextSidecar = { ...sidecar };
121
+ if (!hidden || hidden.type !== "oauth") {
122
+ return { auth: { ...auth }, sidecar: nextSidecar, changed: false };
123
+ }
124
+ delete nextSidecar[slotId];
125
+ if (isChildFacingPlaceholderForSlot(auth[slotId], slotId)) {
126
+ return {
127
+ auth: { ...auth, [slotId]: hidden },
128
+ sidecar: nextSidecar,
129
+ changed: true,
130
+ };
131
+ }
132
+ // Auth already holds a real OAuth blob (re-login while shadowed). Drop the stale copy.
133
+ return { auth: { ...auth }, sidecar: nextSidecar, changed: true };
134
+ }
135
+
136
+ export function applyShadowAll(
137
+ slotIds: readonly string[],
138
+ auth: Readonly<Record<string, AuthBlob>>,
139
+ sidecar: Readonly<Record<string, AuthBlob>>,
140
+ ): { auth: Record<string, AuthBlob>; sidecar: Record<string, AuthBlob>; changed: boolean } {
141
+ let nextAuth = { ...auth };
142
+ let nextSidecar = { ...sidecar };
143
+ let changed = false;
144
+ for (const slotId of slotIds) {
145
+ const step = applyShadowPlan(slotId, nextAuth, nextSidecar);
146
+ nextAuth = step.auth;
147
+ nextSidecar = step.sidecar;
148
+ changed = changed || step.changed;
149
+ }
150
+ return { auth: nextAuth, sidecar: nextSidecar, changed };
151
+ }
152
+
153
+ export function applyRestoreAll(
154
+ auth: Readonly<Record<string, AuthBlob>>,
155
+ sidecar: Readonly<Record<string, AuthBlob>>,
156
+ ): { auth: Record<string, AuthBlob>; sidecar: Record<string, AuthBlob>; changed: boolean } {
157
+ let nextAuth = { ...auth };
158
+ let nextSidecar = { ...sidecar };
159
+ let changed = false;
160
+ for (const slotId of Object.keys(sidecar)) {
161
+ const step = applyRestorePlan(slotId, nextAuth, nextSidecar);
162
+ nextAuth = step.auth;
163
+ nextSidecar = step.sidecar;
164
+ changed = changed || step.changed;
165
+ }
166
+ return { auth: nextAuth, sidecar: nextSidecar, changed };
167
+ }