@centerforagenticai/pi-multi-account 0.1.6 → 0.1.8

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 CHANGED
@@ -43,9 +43,14 @@ The package bundles reviewed Anthropic OAuth and Google Antigravity source under
43
43
  | Provider | `unified` | Optional exact-model logical provider generated from managed catalogs. |
44
44
  | CLI | `multi-account` | Offline cost reports, pricing refresh, period closure, and account plan history. |
45
45
  | Export | exact-model route resolver v1 | Read-only, credential-free route policy for code consumers. |
46
+ | Export | `./public-status` v1 | Read-only live account status for other extensions over `pi.events`. |
46
47
 
47
48
  Every subcommand and argument is in [Commands, tools, and autocomplete](docs/commands.md). The resolver contract is in [Routing and recovery](docs/routing.md).
48
49
 
50
+ ### Public account status subpath
51
+
52
+ Import `@centerforagenticai/pi-multi-account/public-status` and call `discoverPublicAccountStatusReader(pi.events)`. Use the package name your installation resolves: the package is installed under its published name. The subpath is a plain JavaScript module with no imports, so plain Node can load it and it never loads the extension or Pi. The v1 contract uses the query channel `pi-multi-account:public-status-service-query:v1` and returns a `public-status-v1` snapshot, or `owner-unavailable` until `session_start` finishes and after `session_shutdown`, and `source-error` when the owner fails. Version 1 covers only Anthropic and OpenAI Codex accounts; Google Antigravity accounts are omitted. Each account's `label` is the label you configured in `accountLabels`, or else the provider id. It is never read from a token. See [Commands, tools, and autocomplete](docs/commands.md#public-live-account-status-public-status).
53
+
49
54
  ## Configuration
50
55
 
51
56
  The extension reads one machine-global file and no project-local config:
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
- "version": "0.1.6",
2
+ "version": "0.1.8",
3
3
  "description": "Global Anthropic and OpenAI Codex multi-account OAuth routing for Pi.",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-account": "scripts/multi-account.mjs"
7
7
  },
8
- "exports": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./public-status": {
11
+ "types": "./src/public-status.d.ts",
12
+ "default": "./src/public-status.js"
13
+ }
14
+ },
9
15
  "files": [
10
16
  "src",
11
17
  "scripts/multi-account.mjs",
@@ -59,11 +65,11 @@
59
65
  "undici": "^8.10.0"
60
66
  },
61
67
  "devDependencies": {
62
- "@earendil-works/pi-ai": "^1.0.2",
63
- "@earendil-works/pi-coding-agent": "^1.0.2",
64
- "@earendil-works/pi-tui": "^1.0.2",
68
+ "@earendil-works/pi-ai": "^1.0.4",
69
+ "@earendil-works/pi-coding-agent": "^1.0.4",
70
+ "@earendil-works/pi-tui": "^1.0.4",
65
71
  "@types/node": "^24.0.0",
66
- "typebox": "1.3.34",
72
+ "typebox": "1.3.35",
67
73
  "typescript": "^5.9.0",
68
74
  "vitest": "^4.1.11"
69
75
  },
@@ -207,6 +207,33 @@ export function resolveAccountLabelWithinLimit(input: Readonly<{
207
207
  });
208
208
  }
209
209
 
210
+ /**
211
+ * Resolves ONLY an operator-configured label for a managed account, with the
212
+ * same current-limit guard as {@link resolveAccountLabelWithinLimit}.
213
+ *
214
+ * This is the public status surface's label source. It takes no token reader
215
+ * at all, so a token-derived identity (for example a Codex JWT `email`,
216
+ * `preferred_username`, or `name` claim) cannot reach it. Returns undefined when
217
+ * the id is malformed or above the limit, or when no valid label is configured;
218
+ * the caller then falls back to the provider id.
219
+ */
220
+ export function resolveConfiguredAccountLabelWithinLimit(input: Readonly<{
221
+ providerId: string;
222
+ accountLimit: number;
223
+ configured: AccountLabelConfig;
224
+ }>): string | undefined {
225
+ const slot = classifyProviderId({
226
+ providerId: input.providerId,
227
+ credentialType: "unknown",
228
+ });
229
+ if (
230
+ slot === null ||
231
+ !isProviderSlotWithinAccountLimit(slot, input.accountLimit)
232
+ ) return undefined;
233
+ if (!Object.hasOwn(input.configured, input.providerId)) return undefined;
234
+ return normalizeLabel(input.configured[input.providerId]);
235
+ }
236
+
210
237
  /**
211
238
  * Composes the string Pi shows in its login list and provider UI, pairing the
212
239
  * family's product name with the account's own label. When the label carries no
package/src/commands.ts CHANGED
@@ -65,8 +65,11 @@ import { type DiagnosticLog, sanitizedJson } from "./diagnostics.js";
65
65
  import {
66
66
  accountHealth,
67
67
  renderStatus,
68
+ type AccountStatusView,
68
69
  type StatusViewInput,
69
70
  } from "./status-view.js";
71
+ import type { PublicAccountStatusSnapshot } from "./public-status.js";
72
+ import { projectPublicAccountStatus } from "./public-status-projection.js";
70
73
  import type { CredentialType } from "./discovery.js";
71
74
  import { providerTypeFor, type ProviderType } from "./vendor.js";
72
75
  import type { UnsupportedModelPair } from "./model-support.js";
@@ -331,6 +334,11 @@ export interface CommandDependencies {
331
334
  readonly activeModelId?: () => string | undefined;
332
335
  /** Operator-facing label for a managed account, when one resolves. */
333
336
  readonly accountLabel?: (providerId: string) => string | undefined;
337
+ /**
338
+ * Operator-configured label only, for the public status service. Must never
339
+ * derive a label from a credential.
340
+ */
341
+ readonly publicAccountLabel?: (providerId: string) => string | undefined;
334
342
  /** Bounded credential expiry for a managed account, when known. */
335
343
  readonly credentialExpiry?: (providerId: string) => number | undefined;
336
344
  /** Session-local provider/model divergence observations for operator status. */
@@ -341,6 +349,8 @@ export interface CommandDependencies {
341
349
  ) => UsageFetchStatus | undefined;
342
350
  /** Read-only project/account/model cost intelligence for one calendar series. */
343
351
  readonly costReport?: (periodType: PeriodType) => Promise<CostReport>;
352
+ /** Month API-equivalent cost report for the public status service. */
353
+ readonly publicCostReport?: () => Promise<CostReport>;
344
354
  /** Explicit metered last-resort policy; never includes credential material. */
345
355
  readonly meteredFallbackStatus?: () => MeteredFallbackStatus;
346
356
  readonly setModel: (model: Model<Api>) => Promise<boolean>;
@@ -811,71 +821,15 @@ export class MultiAccountCommandController {
811
821
  });
812
822
  }
813
823
  const currentProviderId = this.#dependencies.currentProviderId();
814
- const activeAccountProviderId =
815
- this.#dependencies.activeAccountProviderId?.() ?? currentProviderId;
816
824
  // Label and expiry are resolved HERE, not per-branch, so the JSON and
817
825
  // human-readable views cannot drift: a scripted consumer sees the same
818
826
  // credential freshness the operator does.
819
- const projected = accounts.map((account) => {
820
- const disabled = this.#disabledProviders.has(account.providerId);
821
- const coolingUntilMs = this.#dependencies.state.getCooldown(
822
- account.providerId,
823
- now,
824
- )?.untilMs;
825
- const unavailable =
826
- this.#dependencies.state.getInvalidation(account.providerId) !==
827
- undefined;
828
- const usageUntrusted =
829
- this.#dependencies.state.isUsageSnapshotUntrusted(
830
- account.providerId,
831
- now,
832
- );
833
- const usage = this.#dependencies.usage.get(account.providerId);
834
- const view = {
835
- providerId: account.providerId,
836
- family: account.family,
837
- // Derived live from the discovered credential type; additive field,
838
- // no existing field changes. An absent credentialType defaults to
839
- // the subscription type.
840
- providerType: providerTypeFor(
841
- account.family,
842
- account.credentialType ?? "unknown",
843
- ),
844
- active: account.providerId === activeAccountProviderId,
845
- disabled,
846
- unavailable,
847
- ...(coolingUntilMs === undefined ? {} : { coolingUntilMs }),
848
- ...(usageUntrusted ? { usageUntrusted: true } : {}),
849
- ...(usage === undefined ? {} : { usage }),
850
- };
851
- const health = accountHealth(view, now);
852
- return {
853
- ...view,
854
- api: account.model.api,
855
- healthy: health === "ready" || health === "low-headroom",
856
- ...(account.accountFingerprint === undefined
857
- ? {}
858
- : { accountFingerprint: account.accountFingerprint }),
859
- usageFetch: this.#dependencies.usageFetchStatus?.(account.providerId),
860
- allModelsUnsupported:
861
- account.modelIds.length > 0 &&
862
- account.modelIds.every((modelId) =>
863
- unsupportedModels.some(
864
- (pair) =>
865
- pair.providerId === account.providerId &&
866
- pair.modelId === modelId,
867
- ),
868
- ),
869
- ...this.#optionalField(
870
- "label",
871
- this.#dependencies.accountLabel?.(account.providerId),
872
- ),
873
- ...this.#optionalField(
874
- "expiresAtMs",
875
- this.#dependencies.credentialExpiry?.(account.providerId),
876
- ),
877
- };
878
- });
827
+ const projected = this.#projectAccountViews(
828
+ accounts,
829
+ now,
830
+ unsupportedModels,
831
+ (providerId) => this.#dependencies.accountLabel?.(providerId),
832
+ );
879
833
  const meteredFallback = this.#dependencies.meteredFallbackStatus?.();
880
834
  if (asJson) {
881
835
  return sanitizedJson({
@@ -1459,6 +1413,130 @@ export class MultiAccountCommandController {
1459
1413
  return modelId === undefined ? {} : { activeModelId: modelId };
1460
1414
  }
1461
1415
 
1416
+ /**
1417
+ * The one live per-account projection shared by `status`, `status --json`,
1418
+ * and the public status service. `labelFor` is a parameter because the
1419
+ * public service must publish only operator-configured labels, never the
1420
+ * token-derived label the operator status shows.
1421
+ */
1422
+ #projectAccountViews(
1423
+ accounts: readonly OperatorAccount[],
1424
+ now: number,
1425
+ unsupportedModels: readonly UnsupportedModelPair[],
1426
+ labelFor: (providerId: string) => string | undefined,
1427
+ ) {
1428
+ const activeAccountProviderId =
1429
+ this.#dependencies.activeAccountProviderId?.() ??
1430
+ this.#dependencies.currentProviderId();
1431
+ return accounts.map((account) => {
1432
+ const disabled = this.#disabledProviders.has(account.providerId);
1433
+ const coolingUntilMs = this.#dependencies.state.getCooldown(
1434
+ account.providerId,
1435
+ now,
1436
+ )?.untilMs;
1437
+ const unavailable =
1438
+ this.#dependencies.state.getInvalidation(account.providerId) !==
1439
+ undefined;
1440
+ const usageUntrusted =
1441
+ this.#dependencies.state.isUsageSnapshotUntrusted(
1442
+ account.providerId,
1443
+ now,
1444
+ );
1445
+ const usage = this.#dependencies.usage.get(account.providerId);
1446
+ const view = {
1447
+ providerId: account.providerId,
1448
+ family: account.family,
1449
+ // Derived live from the discovered credential type; additive field,
1450
+ // no existing field changes. An absent credentialType defaults to
1451
+ // the subscription type.
1452
+ providerType: providerTypeFor(
1453
+ account.family,
1454
+ account.credentialType ?? "unknown",
1455
+ ),
1456
+ active: account.providerId === activeAccountProviderId,
1457
+ disabled,
1458
+ unavailable,
1459
+ ...(coolingUntilMs === undefined ? {} : { coolingUntilMs }),
1460
+ ...(usageUntrusted ? { usageUntrusted: true } : {}),
1461
+ ...(usage === undefined ? {} : { usage }),
1462
+ };
1463
+ const health = accountHealth(view, now);
1464
+ return {
1465
+ ...view,
1466
+ api: account.model.api,
1467
+ healthy: health === "ready" || health === "low-headroom",
1468
+ ...(account.accountFingerprint === undefined
1469
+ ? {}
1470
+ : { accountFingerprint: account.accountFingerprint }),
1471
+ usageFetch: this.#dependencies.usageFetchStatus?.(account.providerId),
1472
+ allModelsUnsupported:
1473
+ account.modelIds.length > 0 &&
1474
+ account.modelIds.every((modelId) =>
1475
+ unsupportedModels.some(
1476
+ (pair) =>
1477
+ pair.providerId === account.providerId &&
1478
+ pair.modelId === modelId,
1479
+ ),
1480
+ ),
1481
+ ...this.#optionalField("label", labelFor(account.providerId)),
1482
+ ...this.#optionalField(
1483
+ "expiresAtMs",
1484
+ this.#dependencies.credentialExpiry?.(account.providerId),
1485
+ ),
1486
+ };
1487
+ });
1488
+ }
1489
+
1490
+ /**
1491
+ * Read-only snapshot for the `./public-status` event-bus service.
1492
+ *
1493
+ * Reuses the status projection, but labels come only from
1494
+ * `publicAccountLabel` (operator configuration). A token-derived label, such
1495
+ * as a Codex JWT email claim, is a human account identifier and must never
1496
+ * reach this surface, so the status `accountLabel` resolver is not used.
1497
+ */
1498
+ async publicStatus(): Promise<PublicAccountStatusSnapshot> {
1499
+ const nowMs = this.#now();
1500
+ const projected = this.#projectAccountViews(
1501
+ this.#accounts(),
1502
+ nowMs,
1503
+ this.#dependencies.unsupportedModels?.() ?? [],
1504
+ (providerId) => this.#dependencies.publicAccountLabel?.(providerId),
1505
+ );
1506
+ const accounts = projected.map(
1507
+ (account): AccountStatusView => ({
1508
+ providerId: account.providerId,
1509
+ family: account.family,
1510
+ active: account.active,
1511
+ disabled: account.disabled,
1512
+ unavailable: account.unavailable,
1513
+ ...(account.coolingUntilMs === undefined
1514
+ ? {}
1515
+ : { coolingUntilMs: account.coolingUntilMs }),
1516
+ ...(account.usageUntrusted ? { usageUntrusted: true } : {}),
1517
+ ...(account.usage === undefined ? {} : { usage: account.usage }),
1518
+ ...(account.usageFetch === undefined
1519
+ ? {}
1520
+ : { usageFetch: account.usageFetch }),
1521
+ ...("label" in account ? { label: account.label } : {}),
1522
+ ...("expiresAtMs" in account
1523
+ ? { expiresAtMs: account.expiresAtMs }
1524
+ : {}),
1525
+ }),
1526
+ );
1527
+ let costReport: CostReport | undefined;
1528
+ try {
1529
+ costReport = await this.#dependencies.publicCostReport?.();
1530
+ } catch {
1531
+ costReport = undefined;
1532
+ }
1533
+ return projectPublicAccountStatus({
1534
+ nowMs,
1535
+ accounts,
1536
+ ...(costReport === undefined ? {} : { costReport }),
1537
+ });
1538
+ }
1539
+
1462
1540
  /**
1463
1541
  * Machine-readable account status for the agent-invocable tool.
1464
1542
  *
package/src/index.ts CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  import { parsePiCatalogSnapshot, type PiCatalogSnapshot } from "./api-pricing.js";
76
76
  import type { CostReport } from "./cost-report.js";
77
77
  import { createDefaultCostReportReader } from "./cost-report-reader.js";
78
- import type { PeriodType } from "./period-boundaries.js";
78
+ import { getPeriodBounds, type PeriodType } from "./period-boundaries.js";
79
79
  import {
80
80
  mergeRefreshedCredentials,
81
81
  type CredentialUsability,
@@ -223,7 +223,12 @@ import { restoreManagedAliasModel } from "./session-restore.js";
223
223
  import {
224
224
  accountFingerprint,
225
225
  resolveAccountLabelWithinLimit,
226
+ resolveConfiguredAccountLabelWithinLimit,
226
227
  } from "./account-labels.js";
228
+ import {
229
+ registerPublicAccountStatusService,
230
+ type PublicAccountStatusEventBus,
231
+ } from "./public-status.js";
227
232
  import {
228
233
  createAnthropicAliasProviderConfig,
229
234
  reassertAnthropicBaseRegistration,
@@ -2277,6 +2282,14 @@ export const createMultiAccountExtension =
2277
2282
  const registerLogicalApi = createLogicalApiRegistrarForFactoryGeneration();
2278
2283
 
2279
2284
  let context: ExtensionContext | undefined;
2285
+ // The public status service answers owner-unavailable until session_start
2286
+ // has loaded config and discovered accounts, and again after shutdown, so
2287
+ // a consumer never sees a default-config snapshot as authoritative.
2288
+ let publicStatusStarted = false;
2289
+ let publicStatusDisposed = false;
2290
+ // The UTC day (the cost period closer's day) for which a public read last
2291
+ // scheduled the period closer.
2292
+ let publicStatusCostCloseDayStartMs: number | undefined;
2280
2293
  let attributionStore: AttributionStore | undefined;
2281
2294
  let latestLogicalPhysicalProviderId: string | undefined;
2282
2295
  const logicalTerminalAssociations = createLogicalTerminalAssociationStore();
@@ -3304,6 +3317,20 @@ export const createMultiAccountExtension =
3304
3317
  const label = resolveLabelFor(providerId);
3305
3318
  return label === providerId ? undefined : label;
3306
3319
  },
3320
+ // The public status service publishes configured labels only. It must
3321
+ // not use resolveLabelFor, which derives a label from the stored token
3322
+ // (a Codex JWT email claim is a human account identifier).
3323
+ publicAccountLabel: (providerId) => {
3324
+ try {
3325
+ return resolveConfiguredAccountLabelWithinLimit({
3326
+ providerId,
3327
+ accountLimit: config.accountLimit,
3328
+ configured: config.accountLabels,
3329
+ });
3330
+ } catch {
3331
+ return undefined;
3332
+ }
3333
+ },
3307
3334
  // Re-read live rather than using the discovery snapshot: a long-lived
3308
3335
  // session's snapshot ages out and would report healthy accounts as
3309
3336
  // expired, prompting an unnecessary sign-in.
@@ -3317,6 +3344,12 @@ export const createMultiAccountExtension =
3317
3344
  : usageFetcher.status(providerId, family, config);
3318
3345
  },
3319
3346
  costReport,
3347
+ // The month API-equivalent report, read without the period closer:
3348
+ // a polled public read must not drive retained-state writes.
3349
+ publicCostReport: async () =>
3350
+ options.costReport === undefined
3351
+ ? defaultCostReportReader("month")
3352
+ : options.costReport("month"),
3320
3353
  meteredFallbackStatus: () => {
3321
3354
  const policy = openRouterPolicy();
3322
3355
  const configuredPolicy = resolveOpenRouterEnvironmentPolicy({
@@ -3398,6 +3431,32 @@ export const createMultiAccountExtension =
3398
3431
  }),
3399
3432
  },
3400
3433
  });
3434
+ // Read-only public status service on Pi's shared event bus. Consumers
3435
+ // discover it through the dependency-free `./public-status` subpath.
3436
+ // Hosts without an event bus (minimal embedders and test doubles) simply
3437
+ // do not offer the service; consumers then discover "unsupported".
3438
+ const publicStatusEvents = (pi as { readonly events?: PublicAccountStatusEventBus })
3439
+ .events;
3440
+ const unregisterPublicStatusService =
3441
+ publicStatusEvents === undefined
3442
+ ? () => {}
3443
+ : registerPublicAccountStatusService(publicStatusEvents, async () => {
3444
+ if (!publicStatusStarted || publicStatusDisposed) {
3445
+ return { status: "unavailable", reason: "owner-unavailable" };
3446
+ }
3447
+ schedulePublicStatusCostClose(Date.now());
3448
+ return {
3449
+ status: "available",
3450
+ snapshot: await commands.publicStatus(),
3451
+ };
3452
+ });
3453
+ const disposePublicStatusService = (): void => {
3454
+ // A handler a bus failed to unsubscribe must not keep publishing a
3455
+ // frozen snapshot after shutdown; it reads owner-unavailable instead.
3456
+ publicStatusDisposed = true;
3457
+ publicStatusStarted = false;
3458
+ unregisterPublicStatusService();
3459
+ };
3401
3460
  /**
3402
3461
  * The latest failure classified during the current agent run.
3403
3462
  *
@@ -4362,6 +4421,22 @@ export const createMultiAccountExtension =
4362
4421
  warnCostCloseFailed();
4363
4422
  }
4364
4423
  };
4424
+ // The public read itself stays write-free, but the month estimate reads
4425
+ // closed day digests plus today's raw rows only. On the first public read
4426
+ // of a new day, schedule the same deduplicated, leased closer a provider
4427
+ // response uses (without awaiting it), so yesterday's spend reaches the
4428
+ // month digest even when no provider response has arrived yet today.
4429
+ const schedulePublicStatusCostClose = (nowMs: number): void => {
4430
+ let dayStartMs: number;
4431
+ try {
4432
+ dayStartMs = getPeriodBounds(nowMs, "day").startMs;
4433
+ } catch {
4434
+ return;
4435
+ }
4436
+ if (publicStatusCostCloseDayStartMs === dayStartMs) return;
4437
+ publicStatusCostCloseDayStartMs = dayStartMs;
4438
+ closeCostPeriodsAfterObservation(nowMs);
4439
+ };
4365
4440
  const recordManagedAssistant = async (
4366
4441
  message: AssistantMessage,
4367
4442
  providerId: string,
@@ -4949,6 +5024,9 @@ export const createMultiAccountExtension =
4949
5024
  } catch {
4950
5025
  // The footer cannot fail session restore.
4951
5026
  }
5027
+ // Last: config is loaded and accounts are discovered, so the public
5028
+ // status service may now publish an authoritative snapshot.
5029
+ publicStatusStarted = !publicStatusDisposed;
4952
5030
  }),
4953
5031
  );
4954
5032
  pi.on(
@@ -5894,6 +5972,11 @@ export const createMultiAccountExtension =
5894
5972
  } catch {
5895
5973
  // Footer cleanup cannot block session teardown.
5896
5974
  }
5975
+ try {
5976
+ disposePublicStatusService();
5977
+ } catch {
5978
+ // Unsubscribing is best-effort and cannot block teardown.
5979
+ }
5897
5980
  resolverOwner.dispose();
5898
5981
  });
5899
5982
  lifecycle.register(pi);
@@ -456,6 +456,14 @@ const VISIBLE_FAILURE_CAUSES: Readonly<Record<LogicalFailureEvidence["category"]
456
456
  export const HOST_STALE_INSTALL_MESSAGE =
457
457
  "pi's installed files changed while this session was running; restart the pi session to load the current install.";
458
458
 
459
+ /**
460
+ * Public text when the missing module is a bare package rather than one of
461
+ * pi's own bundle files: the dependency is absent from the install, so a
462
+ * restart loads the same broken tree and only a reinstall helps.
463
+ */
464
+ export const HOST_MISSING_DEPENDENCY_MESSAGE =
465
+ "pi could not load a package it depends on; reinstall pi (or the extension that needs the package), then restart the pi session.";
466
+
459
467
  /**
460
468
  * Node's own phrasing, anchored at the start: pi-ai `lazyStream` keeps
461
469
  * `error.message` unprefixed. An unanchored match would misread a provider or
@@ -491,7 +499,7 @@ function hasStructuredFailureEvidence(failure: ProviderFailureSignal | undefined
491
499
  * Bounded diagnostic cause for a stale install: the Node error code and the
492
500
  * missing file's base name. Directories are dropped so no local path is kept.
493
501
  */
494
- function hostStaleInstallCause(error: unknown): string {
502
+ export function hostLoadFault(error: unknown): { cause: string; missingDependency: boolean } {
495
503
  let text = "";
496
504
  let code: unknown;
497
505
  try {
@@ -504,9 +512,25 @@ function hostStaleInstallCause(error: unknown): string {
504
512
  } catch {
505
513
  // Fall through with whatever was read.
506
514
  }
507
- const file = /['"]?([^'"\s]*\.(?:m?js|cjs|json|node))['"]?/.exec(text)?.[1]?.split(/[\\/]/).at(-1);
508
515
  const kind = code === "ERR_MODULE_NOT_FOUND" || /ERR_MODULE_NOT_FOUND|Cannot find/.test(text) ? "ERR_MODULE_NOT_FOUND" : "dynamic-import-failed";
509
- return file === undefined || file.length === 0 ? kind : `${kind} ${file.slice(0, 120)}`;
516
+ // Node names the missing specifier first ("Cannot find package 'x' imported
517
+ // from /dir/importer.js"); the importer is not what is missing.
518
+ // Node does not escape quotes, so a path may contain one: read up to the
519
+ // quote that ends the specifier (before " imported from", a CJS
520
+ // ". Please verify…"/"Require stack" line, or the end of the text).
521
+ const specifier = /Cannot find (?:module|package) '(.+?)'(?=$| imported from |\.\s|\r?\n)/.exec(text)?.[1];
522
+ if (specifier !== undefined && specifier.length > 0) {
523
+ if (!/^(?:\.{1,2}[\\/]|[\\/]|file:|[A-Za-z]:[\\/])/.test(specifier)) {
524
+ // A bare specifier names a package (keep "@scope/name" or "name").
525
+ const parts = specifier.split("/");
526
+ const name = (specifier.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1)).join("/");
527
+ return { cause: `${kind} package ${name.slice(0, 120)}`, missingDependency: true };
528
+ }
529
+ const base = specifier.split(/[\\/]/).at(-1) ?? "";
530
+ return { cause: base.length === 0 ? kind : `${kind} ${base.slice(0, 120)}`, missingDependency: false };
531
+ }
532
+ const file = /['"]?([^'"\s]*\.(?:m?js|cjs|json|node))['"]?/.exec(text)?.[1]?.split(/[\\/]/).at(-1);
533
+ return { cause: file === undefined || file.length === 0 ? kind : `${kind} ${file.slice(0, 120)}`, missingDependency: false };
510
534
  }
511
535
 
512
536
  type SetupFailureDisposition = "context-overflow" | "retryable" | "host-final";
@@ -1167,13 +1191,15 @@ export function createLogicalProvider(
1167
1191
  account: LogicalPhysicalAccount,
1168
1192
  cause: unknown,
1169
1193
  ): void => {
1194
+ const fault = hostLoadFault(cause);
1170
1195
  box.hostStaleInstall = true;
1196
+ box.hostMissingDependency = fault.missingDependency;
1171
1197
  box.overflow = false;
1172
1198
  box.preStartRetryable = false;
1173
1199
  try {
1174
1200
  deps.onDiagnostic?.(
1175
1201
  `logical dispatch for ${account.providerId} failed loading host code (host_stale_install: ` +
1176
- `${hostStaleInstallCause(cause)}); restart the pi session`,
1202
+ `${fault.cause}); ${fault.missingDependency ? "reinstall the missing package" : "restart the pi session"}`,
1177
1203
  );
1178
1204
  } catch {
1179
1205
  // A diagnostic sink failure cannot replace a provider result.
@@ -1247,6 +1273,8 @@ export function createLogicalProvider(
1247
1273
  preStartRetryable: boolean;
1248
1274
  /** Setup failed because this pi process could not load its own code. */
1249
1275
  hostStaleInstall?: boolean;
1276
+ /** The missing module is a bare package, so a reinstall, not a restart, helps. */
1277
+ hostMissingDependency?: boolean;
1250
1278
  /** Whether the caller or provider shutdown aborted this physical request. */
1251
1279
  cancelled?: () => boolean;
1252
1280
  /**
@@ -1283,7 +1311,10 @@ export function createLogicalProvider(
1283
1311
  let sawTerminal = false;
1284
1312
  // Whether any event other than a leading `start` arrived. A `start`
1285
1313
  // only reports that response headers arrived, so a failure after it is
1286
- // still the stream's first real event.
1314
+ // still the stream's first real event. Every other non-terminal event
1315
+ // is output (it also sets `box.sawOutput`), and a terminal ends the
1316
+ // loop, so on the thrown path `!sawEvent` means "no content yet": this
1317
+ // is the signal the setup-only classifications key on.
1287
1318
  let sawEvent = false;
1288
1319
  const recordFailureOnce = (error: unknown): HostRetryCooldownReceipt => {
1289
1320
  if (box.hostStaleInstall === true) box.receipt ??= hostFaultReceipt();
@@ -1779,7 +1810,7 @@ export function createLogicalProvider(
1779
1810
  return {
1780
1811
  ...syntheticErrorMessage(
1781
1812
  modelId,
1782
- `${stale ? HOST_STALE_INSTALL_MESSAGE : errorMessage} ${evidence}`,
1813
+ `${stale ? (box?.hostMissingDependency === true ? HOST_MISSING_DEPENDENCY_MESSAGE : HOST_STALE_INSTALL_MESSAGE) : errorMessage} ${evidence}`,
1783
1814
  physical === undefined ? undefined : projectTerminalUsage(physical),
1784
1815
  physical === undefined ? undefined : finiteNonNegative(physical.timestamp),
1785
1816
  ),
@@ -0,0 +1,187 @@
1
+ import type { CostReport } from "./cost-report.js";
2
+ import {
3
+ PUBLIC_ACCOUNT_STATUS_VERSION,
4
+ type PublicAccountHealth,
5
+ type PublicAccountStatusRecord,
6
+ type PublicAccountStatusSnapshot,
7
+ type PublicCostEstimate,
8
+ } from "./public-status.js";
9
+ import { accountHealth, type AccountStatusView } from "./status-view.js";
10
+ import type { UsageSnapshot } from "./usage.js";
11
+
12
+ /**
13
+ * Owner-side projection for the `./public-status` contract.
14
+ *
15
+ * Only the extension loads this module. Consumers import the dependency-free
16
+ * contract in `public-status.js` (typed by `public-status.d.ts`) instead.
17
+ */
18
+
19
+ export interface PublicAccountStatusInput {
20
+ readonly nowMs: number;
21
+ /**
22
+ * Live account views. The caller must put only an operator-configured label
23
+ * in `label`; this projection publishes whatever it is given.
24
+ */
25
+ readonly accounts: readonly AccountStatusView[];
26
+ readonly costReport?: CostReport;
27
+ }
28
+
29
+ function publicText(value: string, maximumBytes: number): string {
30
+ if (
31
+ value.length === 0 ||
32
+ Buffer.byteLength(value, "utf8") > maximumBytes ||
33
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
34
+ ) {
35
+ throw new TypeError("Invalid public account status text.");
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function publicTimestamp(value: number | undefined): number | null {
41
+ return value !== undefined && Number.isSafeInteger(value) && value >= 0
42
+ ? value
43
+ : null;
44
+ }
45
+
46
+ function publicCount(value: number | undefined): number | null {
47
+ return value !== undefined && Number.isSafeInteger(value) && value >= 0
48
+ ? value
49
+ : null;
50
+ }
51
+
52
+ function usageHeadroomPercent(usage: UsageSnapshot | undefined): number | null {
53
+ if (usage === undefined) return null;
54
+ const fractions: number[] = [];
55
+ if (usage.utilization !== undefined && Number.isFinite(usage.utilization)) {
56
+ fractions.push(1 - Math.min(1, Math.max(0, usage.utilization)));
57
+ }
58
+ if (
59
+ usage.remainingRequests !== undefined &&
60
+ usage.observedPeakRequests !== undefined &&
61
+ usage.observedPeakRequests > 0
62
+ ) {
63
+ fractions.push(usage.remainingRequests / usage.observedPeakRequests);
64
+ }
65
+ if (
66
+ usage.remainingTokens !== undefined &&
67
+ usage.observedPeakTokens !== undefined &&
68
+ usage.observedPeakTokens > 0
69
+ ) {
70
+ fractions.push(usage.remainingTokens / usage.observedPeakTokens);
71
+ }
72
+ if (fractions.length === 0) return null;
73
+ const headroom = Math.min(...fractions);
74
+ return Math.round(Math.min(1, Math.max(0, headroom)) * 100_000) / 1_000;
75
+ }
76
+
77
+ function recoveryAt(account: AccountStatusView): number | null {
78
+ const candidates = [
79
+ publicTimestamp(account.coolingUntilMs),
80
+ publicTimestamp(account.usage?.recoveryAtMs),
81
+ publicTimestamp(account.usageFetch?.nextAttemptAtMs),
82
+ ].filter((value): value is number => value !== null);
83
+ return candidates.length === 0 ? null : Math.max(...candidates);
84
+ }
85
+
86
+ /**
87
+ * The month's API-equivalent estimate, or nothing. An unpriced report has no
88
+ * estimate, and an invalid one (a non-finite or negative amount, or bad or
89
+ * inverted period bounds) is dropped rather than thrown: a bad cost figure
90
+ * must not turn the whole snapshot, and its account health, into a
91
+ * `source-error`.
92
+ */
93
+ function publicCostEstimate(
94
+ report: CostReport | undefined,
95
+ ): PublicCostEstimate | undefined {
96
+ const current = report?.current;
97
+ if (current?.apiEquivalent.status !== "priced") return undefined;
98
+ const amount = current.apiEquivalent.estimatedUsd;
99
+ const periodStart = publicTimestamp(current.periodStartMs);
100
+ const periodEnd = publicTimestamp(current.periodEndMs);
101
+ const observedAt = publicTimestamp(report?.generatedAtMs);
102
+ if (
103
+ !Number.isFinite(amount) ||
104
+ amount < 0 ||
105
+ periodStart === null ||
106
+ periodEnd === null ||
107
+ periodEnd < periodStart ||
108
+ observedAt === null
109
+ ) {
110
+ return undefined;
111
+ }
112
+ return {
113
+ estimateId: `api-equivalent:${periodStart}:${periodEnd}`,
114
+ accountId: null,
115
+ classification: "estimate-not-billing",
116
+ source: "pi-multi-account:api-equivalent-public-rates",
117
+ currency: "USD",
118
+ amount,
119
+ periodStart,
120
+ periodEnd,
121
+ observedAt,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Copies the status command's live state into the narrow, renderer-independent
127
+ * public read projection. Credential material, fingerprints, diagnostics, and
128
+ * model-routing details have no fields in this result and cannot survive.
129
+ *
130
+ * The v1 consumer rejects a whole snapshot that names any family outside
131
+ * anthropic and openai-codex, so accounts of every other family (for example
132
+ * google-antigravity) are omitted rather than published or thrown on.
133
+ */
134
+ export function projectPublicAccountStatus(
135
+ input: PublicAccountStatusInput,
136
+ ): PublicAccountStatusSnapshot {
137
+ if (!Number.isSafeInteger(input.nowMs) || input.nowMs < 0) {
138
+ throw new TypeError(
139
+ "Public account status observation time must be a timestamp.",
140
+ );
141
+ }
142
+ const accounts: PublicAccountStatusRecord[] = [];
143
+ for (const account of input.accounts) {
144
+ const family = account.family;
145
+ if (family !== "anthropic" && family !== "openai-codex") continue;
146
+ const accountId = publicText(account.providerId, 256);
147
+ const usage = account.usage;
148
+ const usageFetch = account.usageFetch;
149
+ const health: PublicAccountHealth = accountHealth(account, input.nowMs);
150
+ accounts.push({
151
+ accountId,
152
+ label: publicText(account.label ?? accountId, 256),
153
+ family,
154
+ active: account.active,
155
+ health,
156
+ usageHeadroomPercent: usageHeadroomPercent(usage),
157
+ remainingRequests: publicCount(usage?.remainingRequests),
158
+ remainingTokens: publicCount(usage?.remainingTokens),
159
+ recoveryAt: recoveryAt(account),
160
+ credentialExpiresAt: publicTimestamp(account.expiresAtMs),
161
+ fetchObservedAt: publicTimestamp(usage?.snapshotAtMs),
162
+ fetchStale:
163
+ usage === undefined ||
164
+ usage.stale === true ||
165
+ usageFetch?.disabled === true ||
166
+ (usageFetch?.failureCount ?? 0) > 0,
167
+ costEstimateIds: [],
168
+ });
169
+ }
170
+ if (
171
+ new Set(accounts.map((account) => account.accountId)).size !==
172
+ accounts.length
173
+ ) {
174
+ throw new TypeError("Duplicate public account id.");
175
+ }
176
+
177
+ const costEstimates: PublicCostEstimate[] = [];
178
+ const costEstimate = publicCostEstimate(input.costReport);
179
+ if (costEstimate !== undefined) costEstimates.push(costEstimate);
180
+
181
+ return {
182
+ sourceVersion: PUBLIC_ACCOUNT_STATUS_VERSION,
183
+ observedAtMs: input.nowMs,
184
+ accounts,
185
+ costEstimates,
186
+ };
187
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Types for the public, read-only live account status contract
3
+ * (`./public-status` subpath). The runtime is `public-status.js`, plain
4
+ * JavaScript with no imports; this hand-written declaration file must export
5
+ * exactly the same names (test/public-status.test.ts checks this) and, like
6
+ * the runtime, has no imports.
7
+ *
8
+ * The wire shape is version 1 and is frozen: the query channel, the
9
+ * `sourceVersion`, and the record and cost-estimate shapes must not change in
10
+ * place. A new shape needs a new version.
11
+ */
12
+
13
+ export declare const PUBLIC_ACCOUNT_STATUS_VERSION: "public-status-v1";
14
+
15
+ export declare const PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION: 1;
16
+ export declare const PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY: "pi-multi-account:public-status-service-query:v1";
17
+
18
+ /** The families the v1 consumer accepts. Other families are omitted. */
19
+ export type PublicAccountStatusFamily = "anthropic" | "openai-codex";
20
+
21
+ /** Coarse health, ordered most-severe first. */
22
+ export type PublicAccountHealth =
23
+ | "unavailable"
24
+ | "disabled"
25
+ | "cooling"
26
+ | "rate-limited"
27
+ | "exhausted"
28
+ | "low-headroom"
29
+ | "ready";
30
+
31
+ export interface PublicAccountStatusRecord {
32
+ readonly accountId: string;
33
+ /** Operator-configured label, else the provider id. Never token-derived. */
34
+ readonly label: string;
35
+ readonly family: PublicAccountStatusFamily;
36
+ readonly active: boolean;
37
+ readonly health: PublicAccountHealth;
38
+ readonly usageHeadroomPercent: number | null;
39
+ readonly remainingRequests: number | null;
40
+ readonly remainingTokens: number | null;
41
+ readonly recoveryAt: number | null;
42
+ readonly credentialExpiresAt: number | null;
43
+ readonly fetchObservedAt: number | null;
44
+ readonly fetchStale: boolean;
45
+ readonly costEstimateIds: readonly string[];
46
+ }
47
+
48
+ export interface PublicCostEstimate {
49
+ readonly estimateId: string;
50
+ readonly accountId: string | null;
51
+ readonly classification: "estimate-not-billing";
52
+ readonly source: "pi-multi-account:api-equivalent-public-rates";
53
+ readonly currency: "USD";
54
+ readonly amount: number;
55
+ readonly periodStart: number;
56
+ readonly periodEnd: number;
57
+ readonly observedAt: number;
58
+ }
59
+
60
+ export interface PublicAccountStatusSnapshot {
61
+ readonly sourceVersion: typeof PUBLIC_ACCOUNT_STATUS_VERSION;
62
+ readonly observedAtMs: number;
63
+ readonly accounts: readonly PublicAccountStatusRecord[];
64
+ readonly costEstimates: readonly PublicCostEstimate[];
65
+ }
66
+
67
+ export type PublicAccountStatusUnavailableReason =
68
+ | "owner-unavailable"
69
+ | "source-error";
70
+
71
+ export type PublicAccountStatusReadResult =
72
+ | {
73
+ readonly status: "available";
74
+ readonly snapshot: PublicAccountStatusSnapshot;
75
+ }
76
+ | {
77
+ readonly status: "unavailable";
78
+ readonly reason: PublicAccountStatusUnavailableReason;
79
+ };
80
+
81
+ export interface PublicAccountStatusReader {
82
+ read(): Promise<PublicAccountStatusReadResult>;
83
+ }
84
+
85
+ export type PublicAccountStatusReaderDiscovery =
86
+ | {
87
+ readonly status: "available";
88
+ readonly reader: PublicAccountStatusReader;
89
+ }
90
+ | {
91
+ readonly status: "unsupported";
92
+ };
93
+
94
+ /** Structurally compatible with Pi's `pi.events`. */
95
+ export interface PublicAccountStatusEventBus {
96
+ emit(channel: string, payload: unknown): void;
97
+ on(channel: string, handler: (payload: unknown) => void): () => void;
98
+ }
99
+
100
+ /**
101
+ * Register the owner extension's read-only status service on Pi's shared event
102
+ * transport. The callback owns the live state; this module neither stores
103
+ * credentials nor creates a second account manager. A throwing callback reads
104
+ * as `source-error` and its message never reaches the consumer.
105
+ */
106
+ export declare function registerPublicAccountStatusService(
107
+ events: Pick<PublicAccountStatusEventBus, "on">,
108
+ read: () =>
109
+ | PublicAccountStatusReadResult
110
+ | Promise<PublicAccountStatusReadResult>,
111
+ ): () => void;
112
+
113
+ /** Discover the optional live owner without treating absence as owner failure. */
114
+ export declare function discoverPublicAccountStatusReader(
115
+ events: Pick<PublicAccountStatusEventBus, "emit">,
116
+ ): PublicAccountStatusReaderDiscovery;
@@ -0,0 +1,90 @@
1
+ // Public, read-only live account status contract (`./public-status` subpath).
2
+ //
3
+ // This module is the whole runtime a consumer imports. It is plain JavaScript
4
+ // so plain Node can load it from node_modules without a TypeScript loader, and
5
+ // it deliberately has NO imports: loading the subpath must never load the
6
+ // extension, Pi, or any credential-adjacent module. Its types live in the
7
+ // hand-written `public-status.d.ts` beside it; the two must export the same
8
+ // names (test/public-status.test.ts checks this). The owner-side projection
9
+ // lives in `public-status-projection.ts`, which only the extension loads.
10
+ //
11
+ // The wire shape is version 1 and is frozen: the query channel, the
12
+ // `sourceVersion`, and the record and cost-estimate shapes must not change in
13
+ // place. A new shape needs a new version.
14
+
15
+ export const PUBLIC_ACCOUNT_STATUS_VERSION = "public-status-v1";
16
+
17
+ export const PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION = 1;
18
+ export const PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY =
19
+ "pi-multi-account:public-status-service-query:v1";
20
+
21
+ function record(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ function isQuery(value) {
26
+ return (
27
+ record(value) &&
28
+ value["version"] === PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION &&
29
+ Object.keys(value).every((key) => key === "version" || key === "response")
30
+ );
31
+ }
32
+
33
+ function isResponse(value) {
34
+ return (
35
+ record(value) &&
36
+ value["version"] === PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION &&
37
+ typeof value["createReader"] === "function" &&
38
+ Object.keys(value).every(
39
+ (key) => key === "version" || key === "createReader",
40
+ )
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Register the owner extension's read-only status service on Pi's shared event
46
+ * transport. The callback owns the live state; this module neither stores
47
+ * credentials nor creates a second account manager. A throwing callback reads
48
+ * as `source-error` and its message never reaches the consumer.
49
+ */
50
+ export function registerPublicAccountStatusService(events, read) {
51
+ return events.on(PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY, (value) => {
52
+ if (!isQuery(value) || value.response !== undefined) return;
53
+ value.response = {
54
+ version: PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION,
55
+ createReader: () =>
56
+ Object.freeze({
57
+ async read() {
58
+ try {
59
+ return await read();
60
+ } catch {
61
+ return { status: "unavailable", reason: "source-error" };
62
+ }
63
+ },
64
+ }),
65
+ };
66
+ });
67
+ }
68
+
69
+ /** Discover the optional live owner without treating absence as owner failure. */
70
+ export function discoverPublicAccountStatusReader(events) {
71
+ const query = { version: PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION };
72
+ try {
73
+ events.emit(PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY, query);
74
+ } catch {
75
+ return { status: "unsupported" };
76
+ }
77
+ if (!isResponse(query.response)) return { status: "unsupported" };
78
+ try {
79
+ const reader = query.response.createReader();
80
+ if (!record(reader) || typeof reader["read"] !== "function") {
81
+ return { status: "unsupported" };
82
+ }
83
+ return {
84
+ status: "available",
85
+ reader: { read: reader.read.bind(reader) },
86
+ };
87
+ } catch {
88
+ return { status: "unsupported" };
89
+ }
90
+ }