@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.
- package/README.md +1 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/node_modules/@herbertgao/pi-bark/package.json +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -2
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +12 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +427 -120
- package/node_modules/@herbertgao/pi-subagents/docs/rpc.md +184 -0
- package/node_modules/@herbertgao/pi-subagents/docs/workflows.md +466 -0
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +6 -6
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/compose.js +52 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/fan-out-audit.js +56 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/gated-fix.js +60 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/lib/count-child.js +30 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/review-panel.js +68 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/structured-findings.js +81 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +12 -10
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +52 -12
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +837 -146
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +213 -39
- package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +73 -14
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +101 -47
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +2249 -914
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +13 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +215 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention.ts +147 -0
- package/node_modules/@herbertgao/pi-subagents/src/model-resolver.ts +9 -1
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +40 -26
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +18 -8
- package/node_modules/@herbertgao/pi-subagents/src/prompts.ts +46 -9
- package/node_modules/@herbertgao/pi-subagents/src/schedule.ts +21 -16
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +137 -7
- package/node_modules/@herbertgao/pi-subagents/src/structured-output.ts +136 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +126 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-mention.ts +274 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +20 -5
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +10 -4
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +167 -22
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-card.ts +555 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-dialog.ts +1304 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-menu.ts +226 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/collisions.ts +122 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/entry.ts +47 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/host.ts +463 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/journal.ts +164 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/json-schema.ts +142 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/meta.ts +401 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/progress.ts +622 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/runtime.ts +1399 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/saved.ts +230 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/task.ts +333 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/tool-description.ts +200 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/worker-source.ts +781 -0
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +97 -95
- package/node_modules/@herbertgao/pi-subagents/src/xml.ts +13 -0
- package/node_modules/@herbertgao/resume-from/package.json +1 -1
- package/node_modules/@herbertgao/sol-pi/README.md +3 -3
- package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
- package/node_modules/@herbertgao/sol-pi/agents-install.md +4 -4
- package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +6 -6
- package/node_modules/@herbertgao/sol-pi/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/dist/index.ts +39 -89
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/btw.ts +28 -87
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +8 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +40 -37
- package/node_modules/@narumitw/pi-caffeinate/README.md +21 -66
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +10 -41
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +2 -2
- package/node_modules/@narumitw/pi-caffeinate/package.json +50 -51
- package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +637 -663
- package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +114 -120
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +29 -29
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +108 -126
- package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +124 -128
- package/node_modules/pi-multi-account/CHANGELOG.md +1209 -0
- package/node_modules/pi-multi-account/CONTRIBUTING.md +61 -0
- package/node_modules/pi-multi-account/LICENSE +21 -0
- package/node_modules/pi-multi-account/README.md +197 -0
- package/node_modules/pi-multi-account/SECURITY.md +27 -0
- package/node_modules/pi-multi-account/auth-file-transaction.ts +56 -0
- package/node_modules/pi-multi-account/child-usability.ts +233 -0
- package/node_modules/pi-multi-account/compaction-summary.ts +32 -0
- package/node_modules/pi-multi-account/completion-route-planner.ts +224 -0
- package/node_modules/pi-multi-account/context-guard.ts +420 -0
- package/node_modules/pi-multi-account/cursor/LICENSE +21 -0
- package/node_modules/pi-multi-account/cursor/NOTICE +2 -0
- package/node_modules/pi-multi-account/cursor/auth.ts +165 -0
- package/node_modules/pi-multi-account/cursor/bridge-handle.ts +155 -0
- package/node_modules/pi-multi-account/cursor/conversation-registry.ts +104 -0
- package/node_modules/pi-multi-account/cursor/cursor-models-raw.json +611 -0
- package/node_modules/pi-multi-account/cursor/cursor-shared.ts +192 -0
- package/node_modules/pi-multi-account/cursor/h2-bridge.mjs +175 -0
- package/node_modules/pi-multi-account/cursor/index.ts +572 -0
- package/node_modules/pi-multi-account/cursor/message-parsing.ts +323 -0
- package/node_modules/pi-multi-account/cursor/prompt-usage.ts +53 -0
- package/node_modules/pi-multi-account/cursor/proto/agent_pb.ts +15294 -0
- package/node_modules/pi-multi-account/cursor/proxy.ts +2510 -0
- package/node_modules/pi-multi-account/cursor/session-lifecycle.ts +40 -0
- package/node_modules/pi-multi-account/cursor/sse-keepalive.ts +24 -0
- package/node_modules/pi-multi-account/cursor/stream-lifecycle.ts +193 -0
- package/node_modules/pi-multi-account/cursor/upstream-watchdog.ts +88 -0
- package/node_modules/pi-multi-account/cursor-bridge.ts +240 -0
- package/node_modules/pi-multi-account/cursor-model-name.ts +12 -0
- package/node_modules/pi-multi-account/index.ts +11825 -0
- package/node_modules/pi-multi-account/model-catalog.ts +354 -0
- package/node_modules/pi-multi-account/package.json +101 -0
- package/node_modules/pi-multi-account/pi-contract.ts +281 -0
- package/node_modules/pi-multi-account/provider-payload-stream.ts +44 -0
- package/node_modules/pi-multi-account/provider-priority.ts +189 -0
- package/node_modules/pi-multi-account/slot-proxy-auth.ts +167 -0
- package/node_modules/pi-multi-account/slot-proxy.ts +344 -0
- package/node_modules/pi-multi-account/state-file-transaction.ts +67 -0
- package/node_modules/pi-multi-account/usage.ts +1099 -0
- package/node_modules/pi-typesafe/README.md +6 -2
- package/node_modules/pi-typesafe/dist/client.d.ts +11 -0
- package/node_modules/pi-typesafe/dist/client.js +45 -10
- package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
- package/node_modules/pi-typesafe/dist/index.js +1 -1
- package/node_modules/pi-typesafe/package.json +2 -2
- package/node_modules/pi-web-access/CHANGELOG.md +36 -0
- package/node_modules/pi-web-access/README.md +75 -18
- package/node_modules/pi-web-access/anysearch.ts +4 -15
- package/node_modules/pi-web-access/bocha.ts +3 -22
- package/node_modules/pi-web-access/brave.ts +3 -21
- package/node_modules/pi-web-access/brightdata.ts +5 -32
- package/node_modules/pi-web-access/content-find.ts +168 -53
- package/node_modules/pi-web-access/curator-page.ts +4 -1
- package/node_modules/pi-web-access/curator-run.ts +2 -1
- package/node_modules/pi-web-access/curator-server.ts +1 -0
- package/node_modules/pi-web-access/dist/index.js +24620 -0
- package/node_modules/pi-web-access/domain-filter-normalization.ts +14 -0
- package/node_modules/pi-web-access/duckduckgo.ts +3 -21
- package/node_modules/pi-web-access/extract.ts +3 -1
- package/node_modules/pi-web-access/firecrawl.ts +5 -29
- package/node_modules/pi-web-access/gemini-search.ts +81 -32
- package/node_modules/pi-web-access/index.ts +149 -148
- package/node_modules/pi-web-access/jina-search.ts +4 -15
- package/node_modules/pi-web-access/kagi.ts +4 -13
- package/node_modules/pi-web-access/kimi-search.ts +5 -30
- package/node_modules/pi-web-access/mistral-search.ts +1 -15
- package/node_modules/pi-web-access/ollama.ts +2 -7
- package/node_modules/pi-web-access/openai-search.ts +174 -36
- package/node_modules/pi-web-access/opencode-session-headers.ts +24 -0
- package/node_modules/pi-web-access/package.json +10 -4
- package/node_modules/pi-web-access/page-query.ts +10 -2
- package/node_modules/pi-web-access/parallel.ts +1 -15
- package/node_modules/pi-web-access/pdf-extract.ts +3 -0
- package/node_modules/pi-web-access/querit.ts +5 -29
- package/node_modules/pi-web-access/search-answer-formatting.ts +11 -0
- package/node_modules/pi-web-access/search-result-count-normalization.ts +4 -0
- package/node_modules/pi-web-access/search1api.ts +5 -29
- package/node_modules/pi-web-access/searchinfinity.ts +5 -29
- package/node_modules/pi-web-access/searxng.ts +3 -21
- package/node_modules/pi-web-access/serpapi.ts +5 -28
- package/node_modules/pi-web-access/serpbase.ts +3 -22
- package/node_modules/pi-web-access/serpdive.ts +3 -21
- package/node_modules/pi-web-access/serper.ts +5 -28
- package/node_modules/pi-web-access/serply.ts +197 -0
- package/node_modules/pi-web-access/source-check.ts +11 -47
- package/node_modules/pi-web-access/summary-review.ts +7 -3
- package/node_modules/pi-web-access/tavily.ts +3 -21
- package/node_modules/pi-web-access/tinyfish.ts +5 -29
- package/node_modules/pi-web-access/utils.ts +9 -1
- package/node_modules/pi-web-access/valyu.ts +5 -28
- package/node_modules/pi-web-access/xai-search.ts +1 -15
- package/node_modules/pi-web-access/xcrawl.ts +5 -32
- 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
|
+
}
|