privateer-agent 0.12.8 → 0.12.10

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/src/cli/chat.ts CHANGED
@@ -44,8 +44,10 @@ async function main() {
44
44
  const {
45
45
  accountPosture,
46
46
  rememberAccountCredential,
47
+ persistAccountCredential,
47
48
  dropPersistedAccountCredential,
48
49
  } = await import("../providers/account.ts");
50
+ const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
49
51
  const { agentVersion } = await import("../config/version.ts");
50
52
  const { pickerCatalog, hiddenAccountNotice, hiddenAccountTitleSuffix } = await import("../providers/modelCatalog.ts");
51
53
  const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
@@ -448,7 +450,7 @@ async function main() {
448
450
  try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
449
451
  // Ownership-checked: auth.json is machine-global, so removing an entry another
450
452
  // running terminal minted would strand it (see providers/account.ts).
451
- try { dropPersistedAccountCredential({ modelRegistry: { authStorage: services.authStorage } }); } catch { /* nothing persisted */ }
453
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
452
454
  }
453
455
  const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
454
456
  process.once("SIGINT", onSignal);
@@ -459,14 +461,14 @@ async function main() {
459
461
  if (provider === "privateer") {
460
462
  try {
461
463
  const creds = await priv.acquireAccountCredential();
462
- (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
464
+ await persistAccountCredential(creds);
463
465
  rememberAccountCredential(creds); // claim it, so cleanup drops OUR entry and only ours
464
466
  } catch (e) {
465
467
  console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
466
468
  }
467
469
  }
468
470
 
469
- const model = (services.modelRegistry as any).find(provider, modelId);
471
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
470
472
  if (!model) {
471
473
  console.log(`${RED}Model ${provider}/${modelId} not found.${RESET} Set PRIVATEER_MODEL=provider/id and check the key is in .env.`);
472
474
  rl.close();
@@ -633,7 +635,7 @@ async function main() {
633
635
  // is filtered out until the credential is armed).
634
636
  const SIGN_IN_HINT = "Run /login.";
635
637
  async function modelCatalog() {
636
- return pickerCatalog(services.modelRegistry as any);
638
+ return pickerCatalog(modelRegistryOf(services) as any);
637
639
  }
638
640
 
639
641
  // Switch the live session's model in place (history preserved — see
@@ -644,7 +646,7 @@ async function main() {
644
646
  const at = sp.indexOf("/");
645
647
  if (at < 0) { const m = "Usage: /model provider/id"; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
646
648
  const p = sp.slice(0, at), id = sp.slice(at + 1);
647
- const model = (session.modelRegistry as any).find?.(p, id) ?? (services.modelRegistry as any).find?.(p, id);
649
+ const model = (session.modelRegistry as any).find?.(p, id) ?? (modelRegistryOf(services) as any).find?.(p, id);
648
650
  if (!model) { const m = `Model ${sp} not found — try /models.`; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
649
651
  try {
650
652
  await session.setModel(model);
@@ -17,8 +17,11 @@ import { type GateController } from "../ext/permissionGate.ts";
17
17
  import { moatResourceOptions } from "../config/moat.ts";
18
18
  import {
19
19
  rememberAccountCredential,
20
+ persistAccountCredential,
20
21
  dropPersistedAccountCredential,
21
22
  } from "../providers/account.ts";
23
+ // Pi-free static graph by design — see the header of piAuthStore.ts.
24
+ import { modelRegistryOf } from "../providers/piAuthStore.ts";
22
25
  import { resolveDefaultModel } from "../providers/defaultModel.ts";
23
26
  import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
24
27
  import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
@@ -849,7 +852,7 @@ export class Harbor {
849
852
  rmSync(join(agentDir(), "mcp-cache.json"), { force: true });
850
853
  const services = await this.buildSessionServices(cwd, []);
851
854
  const { provider, modelId } = parseSpec(modelSpec);
852
- const model = (services.modelRegistry as any).find(provider, modelId);
855
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
853
856
  if (!model) return;
854
857
  const { session } = await createAgentSessionFromServices({
855
858
  services,
@@ -882,18 +885,16 @@ export class Harbor {
882
885
  let out = "";
883
886
  let status: "ok" | "error" = "ok";
884
887
  let error: string | undefined;
885
- let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
886
888
  let spawnedAccount = false;
887
889
  const { tools: allowedTools, directToolsEnv, notes } = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
888
890
  try {
889
891
  const services = await this.buildSessionServices(spec.cwd, directToolsEnv);
890
- servicesRef = services as any;
891
892
 
892
893
  const { provider, modelId } = parseSpec(spec.model);
893
894
  if (provider === "privateer") {
894
895
  try {
895
896
  const creds = await acquireAccountCredential();
896
- (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
897
+ await persistAccountCredential(creds);
897
898
  rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
898
899
  spawnedAccount = true;
899
900
  } catch (e) {
@@ -901,7 +902,7 @@ export class Harbor {
901
902
  }
902
903
  }
903
904
 
904
- const model = (services.modelRegistry as any).find(provider, modelId);
905
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
905
906
  if (!model) {
906
907
  status = "error";
907
908
  error = `model ${provider}/${modelId} not found`;
@@ -935,7 +936,7 @@ export class Harbor {
935
936
  try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
936
937
  // Ownership-checked: an interactive terminal on this machine shares auth.json,
937
938
  // and its entry must survive a harbor run's teardown (see providers/account.ts).
938
- try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
939
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
939
940
  }
940
941
  }
941
942
  return { out, status, error, notes };
@@ -28,6 +28,7 @@ import { globalDir } from "../config/paths.ts";
28
28
  import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
29
29
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
30
30
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
31
+ import { piAuthStore } from "./piAuthStore.ts";
31
32
  import {
32
33
  sealedEnabled,
33
34
  sealedProviderFor,
@@ -780,16 +781,11 @@ async function accountCredential(): Promise<AccountCredential> {
780
781
  return slot.inFlight;
781
782
  }
782
783
 
783
- // `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
784
- // path privateer-brand uses to DROP the credential on sign-out).
784
+ // `ctx` is Pi's ExtensionContext. It used to carry the auth store too
785
+ // (ctx.modelRegistry.authStorage) — pi 0.84 removed it, and the surviving
786
+ // ModelRegistry facade has no credential surface at all, so the store is resolved
787
+ // from piAuthStore() instead and `ctx` is now only ever consulted for UI.
785
788
  type SeedContext = {
786
- modelRegistry?: {
787
- authStorage?: {
788
- set?: (provider: string, cred: unknown) => void;
789
- get?: (provider: string) => unknown;
790
- remove?: (provider: string) => void;
791
- };
792
- };
793
789
  model?: { provider?: string; id?: string };
794
790
  hasUI?: boolean;
795
791
  ui?: { notify?: (message: string, level: string) => void };
@@ -809,10 +805,12 @@ export async function armAccountCredential(
809
805
  opts: { notify?: boolean } = {},
810
806
  ): Promise<boolean> {
811
807
  if (!hasCredentials()) return false;
812
- const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
813
- if (typeof store?.set !== "function") return false;
814
808
  try {
815
- store.set("privateer", { type: "oauth", ...(await accountCredential()) });
809
+ // `modify` is the store's only write path: it hands us the current entry and
810
+ // takes the replacement back. We ignore the current one — a freshly minted
811
+ // child credential always supersedes whatever is on disk.
812
+ const cred = await accountCredential();
813
+ await (await piAuthStore()).modify("privateer", async () => ({ type: "oauth", ...cred }));
816
814
  return true;
817
815
  } catch (e) {
818
816
  // The account channel is NOT armed: a dead machine login (401 → credentials cleared
@@ -831,6 +829,17 @@ export async function armAccountCredential(
831
829
  }
832
830
  }
833
831
 
832
+ // Write an already-acquired account credential into Pi's auth store.
833
+ //
834
+ // The entrypoints that mint their own child credential (chat, acp, harbor, the live
835
+ // task session) used to poke `services.authStorage.set(...)` directly. pi 0.84 removed
836
+ // that property and made the store's only write path an async `modify`, so the poke
837
+ // lives here now — one place that knows the shape, next to the ownership rules that
838
+ // govern the same entry.
839
+ export async function persistAccountCredential(creds: AccountCredential): Promise<void> {
840
+ await (await piAuthStore()).modify("privateer", async () => ({ type: "oauth", ...creds }));
841
+ }
842
+
834
843
  // Drop Pi's PERSISTED account credential (the `privateer` entry in auth.json) — but
835
844
  // ONLY when it's the one this process minted. Every exit path pairs a session revoke
836
845
  // with this drop (the contract in auth/privateer.ts): Pi reuses the persisted
@@ -849,31 +858,32 @@ export async function armAccountCredential(
849
858
  // machine's whole token family — every terminal's session included. Those callers have
850
859
  // also already wiped the local credentials, so the ownership memo is gone by then and an
851
860
  // ownership-checked drop would refuse and strand a dead entry on disk.
852
- export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boolean } = {}): boolean {
853
- const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
861
+ export async function dropPersistedAccountCredential(
862
+ opts: { force?: boolean } = {},
863
+ ): Promise<boolean> {
864
+ const store = await piAuthStore();
854
865
  const mine = ownedAccountCredential()?.access;
855
866
  // Every caller revokes this process's session immediately before calling us, so drop
856
867
  // the memo whatever happens to the entry on disk — otherwise a later arm() in this
857
868
  // process (a harbor run's session_start, say) could re-persist the revoked token.
858
869
  armSlot().cred = undefined;
859
- if (typeof store?.remove !== "function") return false;
860
870
  try {
861
- if (opts.force) {
862
- /* whole family revoked — the entry is dead for every terminal, drop it */
863
- } else if (typeof store.get === "function") {
864
- const persisted = store.get("privateer") as { access?: unknown } | undefined;
871
+ if (!opts.force) {
872
+ // Not force: read the entry back and only drop it if it is ours. This read is
873
+ // the whole safety property — it is what sees the entry a CONCURRENT terminal
874
+ // wrote, and auth.json is re-read from disk when its revision changes, so this
875
+ // observes other processes rather than a stale in-process copy.
876
+ const persisted = (await store.read("privateer")) as { access?: unknown } | undefined;
865
877
  if (!persisted) return false; // nothing persisted — nothing to drop
866
878
  // Some other terminal's session. Leave it: it's the key that terminal is using.
867
879
  if (typeof persisted.access === "string" && persisted.access !== mine) return false;
868
- } else if (!mine) {
869
- // Older Pi with no readable auth store AND we never minted anything — we have no
870
- // basis to claim the entry, so leave it alone rather than break another terminal.
871
- return false;
872
880
  }
873
- store.remove("privateer");
881
+ // force: the whole token family is revoked, so the entry is dead for every
882
+ // terminal and ownership is irrelevant.
883
+ await store.delete("privateer");
874
884
  return true;
875
885
  } catch {
876
- return false; // older Pi shape / unwritable store — the server TTL is the fallback
886
+ return false; // unwritable store — the server TTL is the fallback
877
887
  }
878
888
  }
879
889
 
@@ -889,12 +899,10 @@ export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boo
889
899
  // refresh path (refreshToken), and pre-empting it would mint a second session row.
890
900
  export async function ensureAccountArmed(ctx: unknown): Promise<void> {
891
901
  if (!hasCredentials()) return;
892
- const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
893
- if (typeof store?.get !== "function") return; // can't tell — leave it to session_start
894
902
  try {
895
- if (store.get("privateer")) return;
903
+ if (await (await piAuthStore()).read("privateer")) return;
896
904
  } catch {
897
- return;
905
+ return; // can't tell — leave it to the next session_start
898
906
  }
899
907
  await armAccountCredential(ctx, { notify: false });
900
908
  }
@@ -0,0 +1,129 @@
1
+ // Access to Pi's credential store and model registry after the 0.84 refactor.
2
+ //
3
+ // pi-coding-agent 0.84 folded credential storage and the model registry into a single
4
+ // `ModelRuntime` and removed both from `AgentSessionServices`. Two things that used to
5
+ // hang off `services` no longer do:
6
+ //
7
+ // services.authStorage -> gone; the runtime owns a private CredentialStore
8
+ // services.modelRegistry -> gone; superseded by services.modelRuntime
9
+ //
10
+ // This module is the single place that knows about that change, so the account channel
11
+ // and the entrypoints don't each grow their own workaround.
12
+ //
13
+ // ⚠️ NOTHING HERE MAY STATICALLY IMPORT PI. This module is pulled in by
14
+ // providers/account.ts, which is itself statically imported by extensions and by the
15
+ // harbor — and whose static graph is deliberately Pi-free, because Pi must not load
16
+ // before boot.ts has installed the env and the dispatcher (the same contract session.ts
17
+ // documents). So the one place a Pi class is genuinely needed loads it dynamically, and
18
+ // everything else is duck-typed off objects Pi already handed us.
19
+
20
+ import { join } from "node:path";
21
+
22
+ import { agentDir as defaultAgentDir } from "../config/paths.ts";
23
+
24
+ /** The agent dir Pi is actually using — boot pins PI_CODING_AGENT_DIR to ours. */
25
+ function resolveAgentDir(agentDir?: string): string {
26
+ return agentDir ?? process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir();
27
+ }
28
+
29
+ /** The subset of Pi's CredentialStore the account channel uses. All async in 0.84. */
30
+ export type PiCredentialStore = {
31
+ read(providerId: string): Promise<unknown>;
32
+ modify(providerId: string, fn: (current: unknown) => Promise<unknown>): Promise<unknown>;
33
+ delete(providerId: string): Promise<void>;
34
+ };
35
+
36
+ let cached: { path: string; store: Promise<PiCredentialStore> } | undefined;
37
+ let override: Promise<PiCredentialStore> | undefined;
38
+
39
+ /**
40
+ * Pi's credential store for auth.json — the same file, format and class the ModelRuntime
41
+ * uses. `AuthStorage` IS that class: model-runtime.js defaults to
42
+ * `DefaultAuthStorage.create(authPath)`, where DefaultAuthStorage is this exact export,
43
+ * and it already implements the public CredentialStore interface. 0.84 merely stopped
44
+ * re-exporting it, so our pi patch puts the export back — which keeps us on Pi's own
45
+ * store and file format instead of reimplementing either.
46
+ *
47
+ * Why ModelRuntime isn't enough: it exposes login/logout/listCredentials but no way to
48
+ * WRITE an OAuth credential, and pi-ai exports only InMemoryCredentialStore. The account
49
+ * channel must write one, because Pi persists an OAuth credential only for a login IT
50
+ * drove — never for our device-code flow.
51
+ *
52
+ * Holding a second instance over the same path is safe and is not a shortcut: auth.json
53
+ * is machine-global and shared by every terminal on the box, so the store is already
54
+ * built for concurrent access (per-path shared read state, a file-revision check on
55
+ * read, serialized `modify` writes). A second instance in this process is just one more
56
+ * reader/writer, exactly like another terminal — which is the case the ownership check
57
+ * in account.ts exists to handle.
58
+ */
59
+ export function piAuthStore(agentDir?: string): Promise<PiCredentialStore> {
60
+ if (override) return override;
61
+ const path = join(resolveAgentDir(agentDir), "auth.json");
62
+ if (cached?.path !== path) {
63
+ cached = {
64
+ path,
65
+ store: import("@earendil-works/pi-coding-agent").then((pi) => {
66
+ // `AuthStorage` reaches the index through OUR pi patch (0.84 stopped exporting
67
+ // it), and patches are best-effort — `bin/apply-patches.mjs` degrades to stock
68
+ // pi when it cannot write to node_modules, the `sudo npm i -g` case. This is
69
+ // the one place left that genuinely needs the patched export: only pi's own
70
+ // class knows auth.json's locking, revision checks and file format, and pi's
71
+ // package exports map blocks the deep path it still lives at. So say what
72
+ // happened. Unpatched, the alternative is `undefined.create is not a function`
73
+ // at whatever moment the account channel first writes a credential.
74
+ const ctor = (pi as { AuthStorage?: { create(p: string): PiCredentialStore } }).AuthStorage;
75
+ if (!ctor) {
76
+ throw new Error(
77
+ "pi's AuthStorage export is missing — the privateer patch for " +
78
+ "@earendil-works/pi-coding-agent has not been applied to this install. " +
79
+ "Run `privateer` once with write access to node_modules (a root-owned " +
80
+ "global install needs sudo), or reinstall with a release bundle.",
81
+ );
82
+ }
83
+ return ctor.create(path);
84
+ }),
85
+ };
86
+ }
87
+ return cached.store;
88
+ }
89
+
90
+ /**
91
+ * Substitute the store. Tests only.
92
+ *
93
+ * The account channel's ownership rules used to be testable by handing the functions a
94
+ * ctx with a fake store on it; the store is resolved internally now, so this is the seam
95
+ * that replaces it. Pass undefined to restore the real one.
96
+ */
97
+ export function setPiAuthStoreForTests(store: PiCredentialStore | undefined): void {
98
+ override = store ? Promise.resolve(store) : undefined;
99
+ cached = undefined;
100
+ }
101
+
102
+ /** The registry-shaped reads our callers make. Matches the old ModelRegistry facade. */
103
+ export type RegistryLike = {
104
+ find(provider: string, modelId: string): unknown;
105
+ getAll(): readonly unknown[];
106
+ getAvailable(): Promise<readonly unknown[]>;
107
+ };
108
+
109
+ /**
110
+ * A registry-shaped view of a session's services.
111
+ *
112
+ * 0.84 kept `ModelRegistry` as a synchronous facade over `ModelRuntime` (that is how
113
+ * extensions still see it) but stopped putting one on `services`. We deliberately do NOT
114
+ * construct one: importing the class would drag Pi into this module's static graph. The
115
+ * runtime is a live object Pi already gave us, so three delegating methods reproduce
116
+ * exactly the surface our callers used — `find` is `getModel` renamed.
117
+ */
118
+ export function modelRegistryOf(services: { modelRuntime?: unknown } | null | undefined): RegistryLike {
119
+ const runtime = services?.modelRuntime as {
120
+ getModel(p: string, id: string): unknown;
121
+ getModels(): readonly unknown[];
122
+ getAvailable(): Promise<readonly unknown[]>;
123
+ } | undefined;
124
+ return {
125
+ find: (provider, modelId) => runtime?.getModel(provider, modelId),
126
+ getAll: () => runtime?.getModels() ?? [],
127
+ getAvailable: async () => (await runtime?.getAvailable()) ?? [],
128
+ };
129
+ }
@@ -20,7 +20,9 @@ import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
21
  import { isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
22
  import { moatResourceOptions } from "../config/moat.ts";
23
- import { rememberAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
23
+ import { rememberAccountCredential, persistAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
24
+ // Pi-free static graph by design — see the header of piAuthStore.ts.
25
+ import { modelRegistryOf } from "../providers/piAuthStore.ts";
24
26
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
25
27
  import { RemoteBridge } from "./remoteBridge.ts";
26
28
  import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
@@ -70,7 +72,6 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
70
72
  let initialPromptSent = false;
71
73
  let stopped = false;
72
74
  let spawnedAccount = false;
73
- let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
74
75
 
75
76
  let attachTimer: ReturnType<typeof setTimeout> | undefined;
76
77
  let lifeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -117,7 +118,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
117
118
  try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
118
119
  // Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
119
120
  // must not delete an interactive terminal's entry (see providers/account.ts).
120
- try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
121
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
121
122
  }
122
123
  deps.onClosed(termId);
123
124
  deps.log(`live task ${termId} closed`);
@@ -218,13 +219,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
218
219
  })) as any),
219
220
  },
220
221
  });
221
- servicesRef = services as any;
222
222
 
223
223
  const { provider, modelId } = deps.parseSpec(modelSpec);
224
224
  if (provider === "privateer") {
225
225
  try {
226
226
  const creds = await spawnAccountCredentials();
227
- (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
227
+ await persistAccountCredential(creds);
228
228
  rememberAccountCredential(creds); // claim it, so stop() drops OUR entry only
229
229
  spawnedAccount = true;
230
230
  } catch (e) {
@@ -236,7 +236,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
236
236
  // just spawned + closes the relay), or a throw would leak an orphaned account "device"
237
237
  // until its token TTL. Everything post-account-spawn runs under one guard.
238
238
  try {
239
- const model = (services.modelRegistry as any).find(provider, modelId);
239
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
240
240
  if (!model) {
241
241
  throw new Error(`model ${provider}/${modelId} not found`);
242
242
  }
package/src/session.ts CHANGED
@@ -16,8 +16,18 @@ import {
16
16
  SessionManager,
17
17
  SettingsManager,
18
18
  ModelRegistry,
19
- AuthStorage,
19
+ ModelRuntime,
20
20
  } from "@earendil-works/pi-coding-agent";
21
+ // From pi-ai, deliberately, not pi-coding-agent's `AuthStorage.inMemory()`. 0.84
22
+ // stopped exporting AuthStorage and our pi patch puts the export back — which is
23
+ // fine for the one caller that genuinely needs the file-backed store
24
+ // (providers/piAuthStore.ts) and wrong here, because patches are best-effort by
25
+ // design: `bin/apply-patches.mjs` degrades to stock pi when it cannot write to
26
+ // node_modules (the `sudo npm i -g` case it calls out). A static import of a
27
+ // patched-in export turns that graceful degradation into a hard ERR_IMPORT crash
28
+ // on every session start. pi-ai exports this store unpatched and it implements the
29
+ // same CredentialStore interface, so nothing about the session changes.
30
+ import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
21
31
 
22
32
  import { agentDir as defaultAgentDir } from "./config/paths.ts";
23
33
  import { createEngineEventAdapter } from "./bridge/engineAdapter.ts";
@@ -46,15 +56,23 @@ export interface PrivateerSession {
46
56
  export async function createSession(opts: CreateSessionOptions): Promise<PrivateerSession> {
47
57
  const AGENT_DIR = opts.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir();
48
58
 
49
- const authStorage = AuthStorage.inMemory();
50
- const modelRegistry = ModelRegistry.create(authStorage, `${AGENT_DIR}/models.json`);
51
- if (typeof (modelRegistry as any).refresh === "function") await (modelRegistry as any).refresh();
52
- else if (typeof (modelRegistry as any).loadModels === "function")
53
- await (modelRegistry as any).loadModels();
59
+ // pi 0.84: ModelRegistry.create(authStorage, modelsPath) is gone. The registry is now
60
+ // a synchronous facade over a ModelRuntime, and the runtime is what owns credentials —
61
+ // so the store this session used to hand to the registry is injected into the runtime
62
+ // instead. Still in-memory: a headless session must not touch the machine's auth.json.
63
+ const authStorage = new InMemoryCredentialStore();
64
+ const modelRuntime = await ModelRuntime.create({
65
+ credentials: authStorage,
66
+ modelsPath: `${AGENT_DIR}/models.json`,
67
+ });
68
+ const modelRegistry = new ModelRegistry(modelRuntime);
69
+ // Explicit, and awaited: find() below is synchronous, so models.json has to be loaded
70
+ // before it runs or the lookup fails on a cold cache.
71
+ await modelRegistry.refresh();
54
72
 
55
73
  const model = modelRegistry.find(opts.provider, opts.modelId);
56
74
  if (!model) {
57
- const ids = ((modelRegistry as any).getAll?.() ?? []).map(
75
+ const ids = (modelRegistry.getAll() ?? []).map(
58
76
  (m: any) => `${m.provider}/${m.id}`,
59
77
  );
60
78
  throw new Error(
@@ -75,8 +93,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<Private
75
93
  cwd: opts.cwd,
76
94
  agentDir: AGENT_DIR,
77
95
  model,
78
- authStorage,
79
- modelRegistry,
96
+ modelRuntime,
80
97
  settingsManager,
81
98
  sessionManager: SessionManager.inMemory(opts.cwd),
82
99
  resourceLoader: loader,