@oh-my-pi/pi-coding-agent 17.1.2 → 17.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3806 -3798
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  30. package/dist/types/tools/bash.d.ts +5 -4
  31. package/dist/types/tools/computer/exposure.d.ts +8 -0
  32. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  33. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  34. package/dist/types/tools/computer/worker.d.ts +1 -1
  35. package/dist/types/tools/computer.d.ts +6 -0
  36. package/dist/types/tools/memory-render.d.ts +1 -4
  37. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  38. package/dist/types/tools/todo.d.ts +7 -1
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +80 -10
  41. package/src/advisor/runtime.ts +27 -1
  42. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  43. package/src/cli/auth-gateway-cli.ts +63 -16
  44. package/src/cli/config-cli.ts +25 -7
  45. package/src/cli/update-cli.ts +229 -29
  46. package/src/cli/usage-cli.ts +144 -15
  47. package/src/cli.ts +21 -15
  48. package/src/commands/update.ts +6 -0
  49. package/src/config/__tests__/model-registry.test.ts +42 -7
  50. package/src/config/model-registry.ts +67 -4
  51. package/src/config/settings-schema.ts +39 -8
  52. package/src/config/settings.ts +24 -2
  53. package/src/cursor.ts +153 -0
  54. package/src/dap/session.ts +70 -11
  55. package/src/edit/hashline/filesystem.ts +1 -1
  56. package/src/edit/modes/patch.ts +1 -1
  57. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  58. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  59. package/src/eval/js/process-entry.ts +9 -5
  60. package/src/eval/js/worker-core.ts +6 -2
  61. package/src/exec/bash-executor.ts +4 -2
  62. package/src/extensibility/extensions/runner.ts +23 -8
  63. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  64. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  65. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  66. package/src/extensibility/shared-events.ts +2 -0
  67. package/src/main.ts +22 -1
  68. package/src/modes/acp/acp-agent.ts +6 -0
  69. package/src/modes/components/assistant-message.ts +88 -11
  70. package/src/modes/components/chat-transcript-builder.ts +2 -0
  71. package/src/modes/components/custom-editor.test.ts +170 -0
  72. package/src/modes/components/custom-editor.ts +79 -29
  73. package/src/modes/components/settings-defs.ts +5 -1
  74. package/src/modes/controllers/event-controller.ts +96 -4
  75. package/src/modes/controllers/selector-controller.ts +14 -0
  76. package/src/modes/interactive-mode.ts +34 -8
  77. package/src/modes/utils/context-usage.ts +19 -2
  78. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  79. package/src/plan-mode/approved-plan.ts +18 -10
  80. package/src/prompts/system/custom-system-prompt.md +1 -1
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/prompts/tools/ast-edit.md +1 -1
  83. package/src/sdk.ts +4 -0
  84. package/src/secrets/obfuscator.ts +36 -126
  85. package/src/session/agent-session.ts +69 -60
  86. package/src/session/prewalk.ts +8 -3
  87. package/src/session/session-advisors.ts +67 -3
  88. package/src/session/session-metadata.ts +53 -0
  89. package/src/session/session-provider-boundary.ts +0 -1
  90. package/src/session/session-tools.ts +35 -1
  91. package/src/session/stream-guards.ts +1 -1
  92. package/src/session/turn-recovery.ts +36 -19
  93. package/src/slash-commands/builtin-registry.ts +49 -7
  94. package/src/stt/asr-worker.ts +2 -37
  95. package/src/stt/sherpa-runtime.ts +71 -0
  96. package/src/task/executor.ts +6 -5
  97. package/src/thinking.ts +39 -0
  98. package/src/tools/auto-generated-guard.ts +18 -5
  99. package/src/tools/bash.ts +43 -15
  100. package/src/tools/computer/exposure.ts +38 -0
  101. package/src/tools/computer/supervisor.ts +61 -13
  102. package/src/tools/computer/worker-entry.ts +28 -19
  103. package/src/tools/computer/worker.ts +3 -7
  104. package/src/tools/computer.ts +65 -10
  105. package/src/tools/gh-cache-invalidation.ts +2 -82
  106. package/src/tools/memory-render.ts +11 -2
  107. package/src/tools/render-utils.ts +8 -3
  108. package/src/tools/shell-tokenize.ts +83 -0
  109. package/src/tools/todo.ts +44 -17
  110. package/src/tools/write.ts +1 -1
@@ -8,7 +8,9 @@
8
8
  * always covers the full credential pool.
9
9
  */
10
10
  import {
11
+ ANTHROPIC_OAUTH_GRANT_TTL_MS,
11
12
  type AuthStorage,
13
+ type DisabledCredentialSummary,
12
14
  resolveUsedFraction,
13
15
  type UsageHistoryEntry,
14
16
  type UsageLimit,
@@ -44,6 +46,8 @@ export interface UsageAccountIdentity {
44
46
  /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
45
47
  orgId?: string;
46
48
  orgName?: string;
49
+ /** Epoch ms of the interactive login that minted the OAuth grant (see `OAuthCredentials.authorizedAt`). */
50
+ authorizedAt?: number;
47
51
  }
48
52
 
49
53
  /**
@@ -126,7 +130,11 @@ function findDistinguishingInfix(value: string, peers: string[]): string | undef
126
130
  }
127
131
 
128
132
  /** Every identity string the output could surface — input for {@link buildRedactionMap}. */
129
- function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountIdentity[]): string[] {
133
+ function collectIdentityStrings(
134
+ reports: UsageReport[],
135
+ accounts: UsageAccountIdentity[],
136
+ disabled: DisabledCredentialSummary[] = [],
137
+ ): string[] {
130
138
  const values: string[] = [];
131
139
  const add = (value: unknown): void => {
132
140
  if (typeof value === "string" && value) values.push(value);
@@ -152,6 +160,12 @@ function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountId
152
160
  add(account.orgName);
153
161
  add(account.enterpriseUrl);
154
162
  }
163
+ for (const summary of disabled) {
164
+ add(summary.email);
165
+ add(summary.accountId);
166
+ add(summary.orgId);
167
+ add(summary.orgName);
168
+ }
155
169
  return values;
156
170
  }
157
171
 
@@ -312,13 +326,15 @@ export function collectUnreportedAccounts(
312
326
  // multi-subscription): two orgs share every other identifier, so an
313
327
  // org-scoped account is covered only by its own org's report, and an
314
328
  // org-less legacy account is never covered by an org-attributed sibling
315
- // report — its own fetch failing must surface as "no usage data". The
316
- // shared org is a GATE, not a match: two Team members share the org id
317
- // while drawing on per-user pools, so coverage also requires the
318
- // account's own base identity inside the same-org subset (an org-only
319
- // account, with no base identifiers, is covered by any same-org
320
- // report). The email/account fallback below applies only when both
321
- // sides are org-less.
329
+ // report — its own fetch failing must surface as "no usage data". Its
330
+ // own ORG-LESS report still covers it, though: a mixed pool (fresh
331
+ // org-scoped logins beside pre-org-capture rows) must not duplicate
332
+ // every legacy account. The shared org is a GATE, not a match: two Team
333
+ // members share the org id while drawing on per-user pools, so coverage
334
+ // also requires the account's own base identity inside the same-org
335
+ // subset (an org-only account, with no base identifiers, is covered by
336
+ // any same-org report). The email/account fallback below applies only
337
+ // when both sides are org-less.
322
338
  const accountOrg = account.orgId?.toLowerCase();
323
339
  const ids = [account.email, account.accountId, account.projectId]
324
340
  .filter((value): value is string => typeof value === "string" && value.length > 0)
@@ -333,9 +349,15 @@ export function collectUnreportedAccounts(
333
349
  }
334
350
  }
335
351
  if (accountOrg || sawReportOrg) {
336
- if (!accountOrg || sameOrgReports.length === 0) return true;
352
+ const candidates = accountOrg
353
+ ? sameOrgReports
354
+ : providerReports.filter(report => {
355
+ const metaOrg = report.metadata?.orgId;
356
+ return !(typeof metaOrg === "string" && metaOrg);
357
+ });
358
+ if (candidates.length === 0) return true;
337
359
  if (ids.length === 0) return false;
338
- return !sameOrgReports.some(report => {
360
+ return !candidates.some(report => {
339
361
  const identifiers = reportIdentifiers(report);
340
362
  return ids.some(id => identifiers.has(id));
341
363
  });
@@ -509,6 +531,58 @@ export function computeProviderWindowStats(reports: UsageReport[]): ProviderWind
509
531
  });
510
532
  }
511
533
 
534
+ /** Re-login warnings render once remaining grant life drops below this. */
535
+ const RELOGIN_WARN_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
536
+
537
+ /**
538
+ * Re-login deadline line for providers whose OAuth grants expire a fixed
539
+ * period after the interactive login (today: Anthropic, ~30 days regardless
540
+ * of refresh rotation). Silent until the deadline is under a week out — a
541
+ * nudge before the broker auto-disables the row, not a permanent countdown.
542
+ */
543
+ function formatReloginDeadline(
544
+ account: UsageAccountIdentity,
545
+ nowMs: number,
546
+ redaction?: Map<string, string>,
547
+ ): string | undefined {
548
+ if (account.provider !== "anthropic" || account.type !== "oauth" || !account.authorizedAt) return undefined;
549
+ const remaining = account.authorizedAt + ANTHROPIC_OAUTH_GRANT_TTL_MS - nowMs;
550
+ if (remaining > RELOGIN_WARN_WINDOW_MS) return undefined;
551
+ const label = accountIdentityLabel(account, redaction);
552
+ if (remaining <= 0) {
553
+ return ` ${chalk.red(`⚠ ${label} — grant is past Anthropic's ~30d lifetime; re-login now`)}`;
554
+ }
555
+ return ` ${chalk.yellow(`⚠ ${label} — re-login within ${formatDuration(remaining)} (Anthropic expires OAuth grants ~30d after login)`)}`;
556
+ }
557
+
558
+ /**
559
+ * Tombstones worth a row in `omp usage`: OAuth credentials torn down
560
+ * automatically (refresh failure, upstream invalidation). Rows the user
561
+ * replaced or deleted deliberately are lifecycle noise, not lost capacity.
562
+ */
563
+ function isActionableDisable(summary: DisabledCredentialSummary): boolean {
564
+ if (summary.type !== "oauth") return false;
565
+ return !/^(replaced by|deleted by user)/i.test(summary.cause);
566
+ }
567
+
568
+ /** Human-sized disable cause: the upstream `error_description` when embedded, else the first clause. */
569
+ function shortDisableCause(cause: string): string {
570
+ const description = cause.match(/\\?"error_description\\?"\s*:\s*\\?"([^"\\]+)/)?.[1];
571
+ if (description) return description;
572
+ const stripped = cause.replace(/^oauth refresh failed:\s*/i, "");
573
+ const clause = stripped.split(/[;\n]/, 1)[0] ?? stripped;
574
+ return clause.length > 80 ? `${clause.slice(0, 77)}…` : clause;
575
+ }
576
+
577
+ /** Label for a disabled tombstone, masking each identity part under `--redact`. */
578
+ function disabledIdentityLabel(summary: DisabledCredentialSummary, redaction?: Map<string, string>): string {
579
+ const base = summary.email ?? summary.accountId ?? "OAuth account";
580
+ const masked = redaction?.get(base) ?? base;
581
+ const org = summary.orgName ?? summary.orgId;
582
+ if (!org || org === base) return masked;
583
+ return `${masked} · ${redaction?.get(org) ?? org}`;
584
+ }
585
+
512
586
  /**
513
587
  * Render the full text breakdown: per provider, per account, every limit
514
588
  * with a bar, amounts, and reset times; unattributed credentials trail
@@ -519,6 +593,7 @@ export function formatUsageBreakdown(
519
593
  accounts: UsageAccountIdentity[],
520
594
  nowMs: number,
521
595
  redaction?: Map<string, string>,
596
+ disabled: DisabledCredentialSummary[] = [],
522
597
  ): string {
523
598
  const reportsByProvider = new Map<string, UsageReport[]>();
524
599
  for (const report of reports) {
@@ -533,10 +608,17 @@ export function formatUsageBreakdown(
533
608
  list.push(account);
534
609
  unreportedByProvider.set(account.provider, list);
535
610
  }
611
+ const disabledByProvider = new Map<string, DisabledCredentialSummary[]>();
612
+ for (const summary of disabled) {
613
+ if (!isActionableDisable(summary)) continue;
614
+ const list = disabledByProvider.get(summary.provider) ?? [];
615
+ list.push(summary);
616
+ disabledByProvider.set(summary.provider, list);
617
+ }
536
618
 
537
- const providers = [...new Set([...reportsByProvider.keys(), ...unreportedByProvider.keys()])].sort((a, b) =>
538
- a.localeCompare(b),
539
- );
619
+ const providers = [
620
+ ...new Set([...reportsByProvider.keys(), ...unreportedByProvider.keys(), ...disabledByProvider.keys()]),
621
+ ].sort((a, b) => a.localeCompare(b));
540
622
 
541
623
  const lines: string[] = [];
542
624
  const latestFetchedAt = Math.max(0, ...reports.map(report => report.fetchedAt ?? 0));
@@ -582,6 +664,20 @@ export function formatUsageBreakdown(
582
664
  lines.push(` ${chalk.dim("○")} ${chalk.dim(`${label} — no usage data`)}`);
583
665
  }
584
666
 
667
+ for (const summary of disabledByProvider.get(provider) ?? []) {
668
+ const label = disabledIdentityLabel(summary, redaction);
669
+ const ago = summary.disabledAtMs !== undefined ? ` ${formatDuration(nowMs - summary.disabledAtMs)} ago` : "";
670
+ lines.push(
671
+ ` ${chalk.red(`✗ ${label} — disabled${ago}: ${sanitizeText(shortDisableCause(summary.cause))}`)} ${chalk.dim("(re-login to restore)")}`,
672
+ );
673
+ }
674
+
675
+ for (const account of accounts) {
676
+ if (account.provider !== provider) continue;
677
+ const warning = formatReloginDeadline(account, nowMs, redaction);
678
+ if (warning) lines.push(warning);
679
+ }
680
+
585
681
  const stats = computeProviderWindowStats(providerReports);
586
682
  if (stats.length > 0) {
587
683
  const parts = stats.map(
@@ -750,6 +846,7 @@ function collectStoredAccounts(authStorage: AuthStorage): UsageAccountIdentity[]
750
846
  enterpriseUrl: credential.enterpriseUrl,
751
847
  orgId: credential.orgId,
752
848
  orgName: credential.orgName,
849
+ authorizedAt: credential.authorizedAt,
753
850
  });
754
851
  } else {
755
852
  accounts.push({ provider, type: "api_key" });
@@ -862,20 +959,41 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
862
959
  (await authStorage.fetchUsageReports({
863
960
  baseUrlResolver: provider => modelRegistry.getProviderBaseUrl(provider),
864
961
  })) ?? [];
962
+ // Reports are always fresh (broker-side fetch) but the account list can
963
+ // come from a disk-cached snapshot up to an hour old — revalidate so a
964
+ // just-logged-in (or just-rotated-identity) credential isn't rendered
965
+ // as a stale duplicate. Best-effort: offline broker keeps the cache.
966
+ try {
967
+ await authStorage.revalidateCredentials();
968
+ } catch {
969
+ // Stale identities beat no output.
970
+ }
865
971
  const storedAccounts = collectStoredAccounts(authStorage);
866
972
  let accounts = selectReportableAccounts(
867
973
  storedAccounts,
868
974
  provider => authStorage.usageProviderFor(provider) !== undefined,
869
975
  cmd.provider,
870
976
  );
977
+ // Tombstones ride alongside the live pool so an auto-disabled account
978
+ // (e.g. an expired Anthropic grant) is loudly visible instead of just
979
+ // missing. Best-effort: a broker predating the endpoint yields [].
980
+ let disabled: DisabledCredentialSummary[] = [];
981
+ try {
982
+ disabled = await authStorage.listDisabledCredentials();
983
+ } catch {
984
+ // Usage output must not fail because tombstone listing did.
985
+ }
871
986
  let filteredReports = reports;
872
987
  if (cmd.provider) {
873
988
  const wanted = cmd.provider.toLowerCase();
874
989
  filteredReports = reports.filter(report => report.provider.toLowerCase() === wanted);
875
990
  accounts = accounts.filter(account => account.provider.toLowerCase() === wanted);
991
+ disabled = disabled.filter(summary => summary.provider.toLowerCase() === wanted);
876
992
  }
877
993
 
878
- const redaction = cmd.redact ? buildRedactionMap(collectIdentityStrings(filteredReports, accounts)) : undefined;
994
+ const redaction = cmd.redact
995
+ ? buildRedactionMap(collectIdentityStrings(filteredReports, accounts, disabled))
996
+ : undefined;
879
997
 
880
998
  if (cmd.json) {
881
999
  // Drop the heavy provider-specific `raw` payload — same shape as the
@@ -900,10 +1018,21 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
900
1018
  const stats = computeProviderWindowStats(filteredReports.filter(peer => peer.provider === report.provider));
901
1019
  if (stats.length > 0) capacity[report.provider] = stats;
902
1020
  }
1021
+ let disabledForJson = disabled.filter(isActionableDisable);
1022
+ if (redaction) {
1023
+ disabledForJson = disabledForJson.map(summary => ({
1024
+ ...summary,
1025
+ email: maskIdentity(redaction, summary.email),
1026
+ accountId: maskIdentity(redaction, summary.accountId),
1027
+ orgId: maskIdentity(redaction, summary.orgId),
1028
+ orgName: maskIdentity(redaction, summary.orgName),
1029
+ }));
1030
+ }
903
1031
  const payload = {
904
1032
  generatedAt: Date.now(),
905
1033
  reports: trimmed,
906
1034
  accountsWithoutUsage: unreportedAccounts,
1035
+ disabledCredentials: disabledForJson,
907
1036
  capacity,
908
1037
  };
909
1038
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -923,7 +1052,7 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
923
1052
  return;
924
1053
  }
925
1054
 
926
- process.stdout.write(`${formatUsageBreakdown(filteredReports, accounts, Date.now(), redaction)}\n`);
1055
+ process.stdout.write(`${formatUsageBreakdown(filteredReports, accounts, Date.now(), redaction, disabled)}\n`);
927
1056
  } finally {
928
1057
  authStorage.close();
929
1058
  }
package/src/cli.ts CHANGED
@@ -24,10 +24,16 @@ import {
24
24
  setProfile,
25
25
  VERSION,
26
26
  } from "@oh-my-pi/pi-utils/dirs";
27
- import { declareWorkerHostEntry, installWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
27
+ import { interceptUnhandledRejections } from "@oh-my-pi/pi-utils/postmortem";
28
+ import { declareWorkerHostEntry, installWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host";
28
29
  import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
29
30
  import { extractProfileFlags } from "./cli/profile-bootstrap";
31
+ import { startJsEvalProcess } from "./eval/js/process-entry";
32
+ import type { WorkerInbound as JsWorkerInbound, WorkerOutbound as JsWorkerOutbound } from "./eval/js/worker-protocol";
30
33
  import { DAEMON_BROKER_WORKER_ARG } from "./launch/protocol";
34
+ import { COMPUTER_WORKER_ARG } from "./tools/computer/protocol";
35
+ import { smokeTestComputerWorker } from "./tools/computer/supervisor";
36
+ import { startComputerWorker } from "./tools/computer/worker-entry";
31
37
 
32
38
  if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
33
39
  process.stderr.write(
@@ -79,8 +85,7 @@ async function runSmokeTest(): Promise<void> {
79
85
  const { smokeTestTtsWorker } = await import("./tts/tts-client");
80
86
  const { smokeTestMnemopiEmbedWorker } = await import("./mnemopi/embed-client");
81
87
  const { smokeTestJsEvalWorker } = await import("./eval/js/context-manager");
82
- const { smokeTestComputerWorker } = await import("./tools/computer/supervisor");
83
- // Smoke dependencies stay lazy so normal CLI startup does not load worker clients.
88
+ // Other smoke dependencies stay lazy so normal CLI startup does not load their worker clients.
84
89
  const { smokeTestDaemonBroker } = await import("./launch/client");
85
90
  await smokeTestSyncWorker();
86
91
 
@@ -109,7 +114,6 @@ async function runSmokeTest(): Promise<void> {
109
114
  const TINY_WORKER_ARG = "__omp_worker_tiny_inference";
110
115
  const STATS_SYNC_WORKER_ARG = "__omp_worker_stats_sync";
111
116
  const TAB_WORKER_ARG = "__omp_worker_tab";
112
- const COMPUTER_WORKER_ARG = "__omp_worker_computer";
113
117
  const JS_EVAL_WORKER_ARG = "__omp_worker_js_eval";
114
118
  const JS_EVAL_PROCESS_ARG = "__omp_worker_js_eval_process";
115
119
  const STT_WORKER_ARG = "__omp_worker_stt";
@@ -129,7 +133,7 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
129
133
  // spawning (the smoke ping, the first parse request) would be dropped.
130
134
  // Park early events and replay them once the module's handler is live.
131
135
  // Worker-thread entries using `parentPort` need the same sync-prefix
132
- // buffering; the tab/eval cases install that inbox below before import.
136
+ // buffering; the computer/tab/eval cases install that inbox below.
133
137
  const scope = globalThis as unknown as { onmessage: ((event: MessageEvent) => void) | null };
134
138
  const pending: MessageEvent[] = [];
135
139
  const buffer = (event: MessageEvent): void => {
@@ -144,12 +148,10 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
144
148
  return true;
145
149
  }
146
150
  // Bun flushes messages the parent posted before spawn once this entry's
147
- // top-level evaluation completes, delivering them only to listeners present
148
- // at that moment. These worker modules are imported dynamically below, so
149
- // their own `parentPort.on("message")` lands after the flush and the parent's
150
- // synchronous `init` is dropped. Install a buffering inbox synchronously here
151
- // (still inside the entry's sync prefix) so the handshake survives; the worker
152
- // module binds the real handler once loaded.
151
+ // top-level evaluation completes. Install a buffering inbox synchronously
152
+ // before binding the selected worker's real handler so the parent's
153
+ // synchronous `init` survives. The dynamically imported tab/eval modules
154
+ // consume the same inbox after their module evaluation begins.
153
155
  if (arg === TAB_WORKER_ARG) {
154
156
  if (parentPort) installWorkerInbox(parentPort);
155
157
  await import("./tools/browser/tab-worker-entry");
@@ -157,7 +159,7 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
157
159
  }
158
160
  if (arg === COMPUTER_WORKER_ARG) {
159
161
  if (parentPort) installWorkerInbox(parentPort);
160
- await import("./tools/computer/worker-entry");
162
+ startComputerWorker();
161
163
  return true;
162
164
  }
163
165
  if (arg === JS_EVAL_WORKER_ARG) {
@@ -166,11 +168,15 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
166
168
  return true;
167
169
  }
168
170
  if (arg === JS_EVAL_PROCESS_ARG) {
169
- const { startJsEvalProcess } = await import("./eval/js/process-entry");
171
+ // The bootstrap-safe interceptor seam is linked statically so this selector
172
+ // cannot load profile-scoped environment state after dispatch has begun.
170
173
  // The JS evaluator forwards user-controlled payloads (tool-call args,
171
174
  // display outputs); a non-serializable one must fail that cell, not
172
175
  // SIGKILL the kernel and erase the eval session's state.
173
- await runIpcSubprocessWorker(startJsEvalProcess, { rethrowConnectedSendErrors: true });
176
+ await runIpcSubprocessWorker<JsWorkerInbound, JsWorkerOutbound>(
177
+ transport => startJsEvalProcess(transport, interceptUnhandledRejections),
178
+ { rethrowConnectedSendErrors: true },
179
+ );
174
180
  return true;
175
181
  }
176
182
  if (arg === STT_WORKER_ARG) {
@@ -345,7 +351,7 @@ export async function runCli(argv: string[]): Promise<void> {
345
351
  // synchronous prefix of `runWorkerEntrypoint`, and Bun flushes the
346
352
  // worker's parked initial messages as soon as the entry module's
347
353
  // top-level evaluation finishes.
348
- if (resolvedArgv[0]?.startsWith("__omp_worker_")) {
354
+ if (isWorkerHostSelector(resolvedArgv[0])) {
349
355
  const dispatched = await runWorkerEntrypoint(resolvedArgv[0]);
350
356
  if (!dispatched) {
351
357
  process.stderr.write(`Error: unknown worker selector: ${resolvedArgv[0]}\n`);
@@ -15,6 +15,12 @@ export default class Update extends Command {
15
15
  plugins: Flags.boolean({ char: "l", description: "Update installed plugins", default: false }),
16
16
  };
17
17
 
18
+ static examples = [
19
+ "omp update",
20
+ "omp update --check",
21
+ "# If GitHub rate-limits release metadata, set GITHUB_TOKEN or GH_TOKEN\n GITHUB_TOKEN=... omp update",
22
+ ];
23
+
18
24
  async run(): Promise<void> {
19
25
  const { flags } = await this.parse(Update);
20
26
  await initTheme();
@@ -3,24 +3,34 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
6
+ import { buildModel } from "@oh-my-pi/pi-catalog/build";
6
7
  import { ModelRegistry } from "../model-registry";
7
8
 
8
- /**
9
- * Stub AuthStorage that satisfies the surface used by ModelRegistry's
10
- * constructor (#loadModels → clearConfigApiKeys, constructor →
11
- * setFallbackResolver). The awaiter under test never reaches auth-gated code
12
- * paths, so no real credential store is required.
13
- */
9
+ /** Stub auth storage for registry lifecycle and missing-credential coverage. */
14
10
  function createStubAuthStorage(): AuthStorage {
15
11
  const stub = {
16
12
  setFallbackResolver: () => {},
17
13
  clearConfigApiKeys: () => {},
18
14
  hasAuth: () => false,
15
+ getApiKey: async () => undefined,
19
16
  };
20
17
  return stub as unknown as AuthStorage;
21
18
  }
22
19
 
23
- describe("ModelRegistry.awaitBackgroundRefresh", () => {
20
+ const testModel = buildModel({
21
+ id: "test-model",
22
+ name: "Test Model",
23
+ api: "openai-completions",
24
+ provider: "test",
25
+ baseUrl: "https://example.test",
26
+ reasoning: false,
27
+ input: ["text"],
28
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
29
+ contextWindow: 1000,
30
+ maxTokens: 100,
31
+ });
32
+
33
+ describe("ModelRegistry", () => {
24
34
  let tmpDir: string;
25
35
  let registry: ModelRegistry;
26
36
 
@@ -144,4 +154,29 @@ describe("ModelRegistry.awaitBackgroundRefresh", () => {
144
154
  secondResolve();
145
155
  await registry.awaitBackgroundRefresh();
146
156
  });
157
+ test("resolves API keys and provider headers for legacy extensions", async () => {
158
+ const model = testModel;
159
+ vi.spyOn(registry, "getApiKey").mockResolvedValue("test-key");
160
+ vi.spyOn(registry, "getProviderHeaders").mockReturnValue({ "x-test": "value" });
161
+
162
+ expect(await registry.getApiKeyAndHeaders(model)).toEqual({
163
+ ok: true,
164
+ apiKey: "test-key",
165
+ headers: { "x-test": "value" },
166
+ });
167
+ });
168
+
169
+ test("returns an error when authentication resolves without a credential", async () => {
170
+ expect(await registry.getApiKeyAndHeaders(testModel)).toEqual({
171
+ ok: false,
172
+ error: 'No API key found for "test"',
173
+ });
174
+ });
175
+
176
+ test("maps legacy extension auth failures into the result contract", async () => {
177
+ const model = testModel;
178
+ vi.spyOn(registry, "getApiKey").mockRejectedValue(new Error("auth failed"));
179
+
180
+ expect(await registry.getApiKeyAndHeaders(model)).toEqual({ ok: false, error: "auth failed" });
181
+ });
147
182
  });
@@ -763,6 +763,16 @@ function getDisabledProviderIdsFromSettings(): Set<string> {
763
763
  }
764
764
  }
765
765
 
766
+ /** Authentication material returned to legacy extensions for one model request. */
767
+ export type ResolvedRequestAuth =
768
+ | {
769
+ ok: true;
770
+ apiKey?: string;
771
+ headers?: Record<string, string>;
772
+ env?: Record<string, string>;
773
+ }
774
+ | { ok: false; error: string };
775
+
766
776
  /**
767
777
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
768
778
  */
@@ -793,6 +803,7 @@ export class ModelRegistry {
793
803
  // Runtime model managers registered by extensions via fetchDynamicModels.
794
804
  // Keyed by provider name; use the same SQLite cache path as builtins.
795
805
  #runtimeModelManagers: Map<string, { options: ModelManagerOptions<Api>; sourceId: string }> = new Map();
806
+ #ignoreLocalModelConfig: boolean;
796
807
  #fetch: FetchImpl;
797
808
 
798
809
  #resolveCommandBackedApiKey(provider: string): CommandApiKeyResolution {
@@ -829,8 +840,18 @@ export class ModelRegistry {
829
840
  constructor(
830
841
  readonly authStorage: AuthStorage,
831
842
  modelsPath?: string,
832
- options?: { fetch?: FetchImpl },
843
+ options?: {
844
+ /**
845
+ * Gateway mode: ignore local `models.yml` entirely (provider overrides,
846
+ * config API keys, custom models, custom discovery). A broker-backed
847
+ * gateway serves only bundled + broker-discovered catalog metadata and
848
+ * must never apply client-side credential or routing overrides.
849
+ */
850
+ ignoreLocalModelConfig?: boolean;
851
+ fetch?: FetchImpl;
852
+ },
833
853
  ) {
854
+ this.#ignoreLocalModelConfig = options?.ignoreLocalModelConfig ?? false;
834
855
  this.#fetch =
835
856
  options?.fetch ??
836
857
  (isBunTestRuntime()
@@ -1367,6 +1388,24 @@ export class ModelRegistry {
1367
1388
  }
1368
1389
 
1369
1390
  #loadCustomModels(): CustomModelsResult {
1391
+ // Gateway mode: serve bundled + broker-discovered catalog metadata only.
1392
+ // Local models.yml provider overrides (baseUrl/apiKey/headers/transport),
1393
+ // custom models, custom discovery, and config API keys are all client-side
1394
+ // routing that MUST NOT reach a broker-backed gateway — applying them would
1395
+ // send broker bearers to a configured endpoint, install config keys that
1396
+ // shadow broker credentials (bypassing account pooling/refresh/accounting),
1397
+ // or route a pi-native gateway back into itself.
1398
+ if (this.#ignoreLocalModelConfig) {
1399
+ return {
1400
+ models: [],
1401
+ overrides: new Map(),
1402
+ modelOverrides: new Map(),
1403
+ keylessProviders: new Set(),
1404
+ discoverableProviders: [],
1405
+ configuredProviders: new Set(),
1406
+ found: false,
1407
+ };
1408
+ }
1370
1409
  const { value, error, status } = this.#modelsConfigFile.tryLoad();
1371
1410
 
1372
1411
  if (status === "error") {
@@ -1958,12 +1997,22 @@ export class ModelRegistry {
1958
1997
  remoteCompaction: mergeProviderRemoteCompactionConfig(entry.remoteCompaction, override.remoteCompaction),
1959
1998
  };
1960
1999
  }
2000
+ #applyProviderTransportOverrideToModel(
2001
+ model: Model<Api>,
2002
+ override: Pick<
2003
+ ProviderOverride,
2004
+ "baseUrl" | "headers" | "authHeader" | "apiKey" | "remoteCompaction" | "transport"
2005
+ >,
2006
+ ): Model<Api> {
2007
+ return buildModel(this.#applyProviderTransportOverride(toModelSpec(model), override));
2008
+ }
2009
+
1961
2010
  #applyRuntimeProviderOverrides(models: Model<Api>[]): Model<Api>[] {
1962
2011
  if (this.#runtimeProviderOverrides.size === 0) return models;
1963
2012
  return models.map(model => {
1964
2013
  const override = this.#runtimeProviderOverrides.get(model.provider);
1965
2014
  if (!override) return model;
1966
- return this.#applyProviderTransportOverride(model, override);
2015
+ return this.#applyProviderTransportOverrideToModel(model, override);
1967
2016
  });
1968
2017
  }
1969
2018
  #resolveLiveModelOverride(model: Model<Api>): ModelOverride | undefined {
@@ -2194,6 +2243,20 @@ export class ModelRegistry {
2194
2243
  });
2195
2244
  }
2196
2245
 
2246
+ /** Resolve request authentication through the historical Pi extension facade. */
2247
+ async getApiKeyAndHeaders(model: Model<Api>): Promise<ResolvedRequestAuth> {
2248
+ try {
2249
+ const apiKey = await this.getApiKey(model);
2250
+ if (apiKey === undefined) {
2251
+ return { ok: false, error: `No API key found for "${model.provider}"` };
2252
+ }
2253
+ const headers = this.getProviderHeaders(model.provider);
2254
+ return { ok: true, apiKey, headers };
2255
+ } catch (error) {
2256
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
2257
+ }
2258
+ }
2259
+
2197
2260
  /**
2198
2261
  * Get API key for a provider (e.g., "openai").
2199
2262
  *
@@ -2404,7 +2467,7 @@ export class ModelRegistry {
2404
2467
  const withRuntimeTransportOverride = runtimeTransportOverride
2405
2468
  ? nextModels.map(model => {
2406
2469
  if (model.provider !== providerName) return model;
2407
- return this.#applyProviderTransportOverride(model, runtimeTransportOverride);
2470
+ return this.#applyProviderTransportOverrideToModel(model, runtimeTransportOverride);
2408
2471
  })
2409
2472
  : nextModels;
2410
2473
 
@@ -2488,7 +2551,7 @@ export class ModelRegistry {
2488
2551
  this.#models = this.#applyLlamaCppQwenThinkingToModels(
2489
2552
  this.#models.map(m => {
2490
2553
  if (m.provider !== providerName) return m;
2491
- return this.#applyProviderTransportOverride(m, transportOverride);
2554
+ return this.#applyProviderTransportOverrideToModel(m, transportOverride);
2492
2555
  }),
2493
2556
  );
2494
2557
  }