@bitkyc08/opencodex 2.37.0 → 2.38.0

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 (66) hide show
  1. package/bin/ocx.mjs +69 -10
  2. package/gui/dist/assets/{index-CowztZdo.js → index-C14iCj_Q.js} +13 -13
  3. package/gui/dist/assets/index-D7PIz7_g.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  7. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  8. package/gui/dist/provider-icons/openclaw.svg +54 -0
  9. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  10. package/gui/dist/provider-icons/zcode.svg +219 -0
  11. package/package.json +1 -1
  12. package/src/adapters/cursor/protobuf-request.ts +4 -1
  13. package/src/adapters/cursor/tool-definitions.ts +36 -4
  14. package/src/cli/capabilities.ts +14 -0
  15. package/src/cli/codex-cli-update.ts +96 -0
  16. package/src/cli/codex-shim-autorestore.ts +3 -0
  17. package/src/cli/export-command.ts +18 -17
  18. package/src/cli/help.ts +2 -2
  19. package/src/cli/index.ts +3 -2
  20. package/src/cli/launcher-context.ts +53 -2
  21. package/src/cli/opencode.ts +126 -33
  22. package/src/cli/registry.ts +16 -10
  23. package/src/cli/system-command.ts +6 -1
  24. package/src/clients/config-export.ts +293 -28
  25. package/src/codex/account-store.ts +10 -4
  26. package/src/codex/autostart-health.ts +3 -3
  27. package/src/codex/catalog/provider-fetch.ts +20 -1
  28. package/src/codex/catalog/sync.ts +4 -3
  29. package/src/codex/cli-install-provenance.ts +795 -0
  30. package/src/codex/convergence.ts +4 -3
  31. package/src/codex/credential-mutation-epoch.ts +11 -0
  32. package/src/codex/main-account.ts +2 -0
  33. package/src/codex/model-entitlements.ts +430 -27
  34. package/src/codex/native-profile-manager.ts +4 -0
  35. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  36. package/src/codex/reset-credit-recovery.ts +20 -2
  37. package/src/codex/shim.ts +204 -18
  38. package/src/codex/user-identity.ts +2 -1
  39. package/src/config/paths.ts +18 -3
  40. package/src/config.ts +23 -0
  41. package/src/generated/compatibility-version.json +81 -45
  42. package/src/integrations/registry.ts +112 -0
  43. package/src/integrations/state.ts +67 -5
  44. package/src/integrations/writer.ts +25 -9
  45. package/src/lib/bounded-subprocess.ts +36 -0
  46. package/src/lib/strict-semver.ts +47 -0
  47. package/src/lib/windows-elevation.ts +32 -1
  48. package/src/lib/windows-secret-acl.ts +47 -25
  49. package/src/lib/windows-service-mutation-lock.ts +133 -0
  50. package/src/lib/windows-user-principal.ts +15 -17
  51. package/src/responses/spill-store.ts +334 -29
  52. package/src/responses/state.ts +488 -7
  53. package/src/server/index.ts +4 -3
  54. package/src/server/lifecycle.ts +5 -1
  55. package/src/server/management/model-rows.ts +11 -2
  56. package/src/server/management/provider-routes.ts +4 -0
  57. package/src/server/management/system-restart.ts +5 -5
  58. package/src/server/management-api.ts +7 -2
  59. package/src/server/startup-action-control.ts +3 -2
  60. package/src/service.ts +594 -33
  61. package/src/sidecar/candidates.ts +1 -1
  62. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  63. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  64. package/src/update/index.ts +3 -2
  65. package/src/update/job.ts +10 -11
  66. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -20,12 +20,12 @@
20
20
  * targeting it is the caller's explicit act.
21
21
  */
22
22
  import { homedir } from "node:os";
23
- import { existsSync } from "node:fs";
23
+ import { existsSync, readFileSync } from "node:fs";
24
24
  import { isAbsolute, join, resolve } from "node:path";
25
25
  import { shouldInjectApiAuthHeader } from "../codex/inject";
26
26
  import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
27
27
  import { providerCodexAccountMode } from "../providers/registry";
28
- import { sanitizeCodexReasoningEfforts } from "../reasoning-effort";
28
+ import { canonicalizeReasoningEfforts, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
29
29
  import { probeHostname } from "../server/proxy-liveness";
30
30
  import type { OcxConfig } from "../types";
31
31
 
@@ -65,6 +65,14 @@ export interface OpencodeCatalogModel {
65
65
  id?: string;
66
66
  contextWindow?: number;
67
67
  displayName?: string;
68
+ /** Declared effort ladder. Exported as opencode model variants where the client reads them. */
69
+ reasoningEfforts?: readonly string[];
70
+ /**
71
+ * Declared default effort. Carried so every client export reads one deduped, visibility-
72
+ * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a
73
+ * model-level setting — see {@link opencodeEffortVariants} for why.
74
+ */
75
+ defaultReasoningEffort?: string;
68
76
  }
69
77
 
70
78
  export interface OpencodeModelEntry {
@@ -72,20 +80,61 @@ export interface OpencodeModelEntry {
72
80
  limit?: { context: number; output: number };
73
81
  }
74
82
 
83
+ /**
84
+ * One selectable reasoning effort.
85
+ *
86
+ * opencode V2 applies these only from the `providers` block: a `variants` array under the
87
+ * legacy `provider` block is parsed and then dropped, so the V1 block stays variant-free
88
+ * rather than carrying fields that look configured but never reach a request.
89
+ */
90
+ export interface OpencodeModelVariant {
91
+ id: string;
92
+ settings: { reasoningEffort: string };
93
+ }
94
+
95
+ export interface OpencodeV2ModelEntry extends OpencodeModelEntry {
96
+ variants?: OpencodeModelVariant[];
97
+ }
98
+
99
+ /** Endpoint and admission, spelled once and shared by both block generations. */
100
+ export interface OpencodeProviderConnection {
101
+ baseURL: string;
102
+ apiKey?: string;
103
+ headers?: Record<string, string>;
104
+ }
105
+
106
+ /** opencode V1 provider block: `npm` + `options`. */
75
107
  export interface OpencodeProviderBlock {
76
108
  npm: string;
77
109
  name: string;
78
- options: {
79
- baseURL: string;
80
- apiKey?: string;
81
- headers?: Record<string, string>;
82
- };
110
+ options: OpencodeProviderConnection;
83
111
  models: Record<string, OpencodeModelEntry>;
84
112
  }
85
113
 
114
+ /** opencode V2 provider block: `package` + `settings`. The only form whose variants apply. */
115
+ export interface OpencodeV2ProviderBlock {
116
+ package: string;
117
+ name: string;
118
+ settings: OpencodeProviderConnection;
119
+ models: Record<string, OpencodeV2ModelEntry>;
120
+ }
121
+
122
+ /**
123
+ * Both generations, always built together: they are one document's two fragments and must
124
+ * agree on the model set, the names, and the connection. Building them in one pass is what
125
+ * makes that a fact rather than a convention.
126
+ */
127
+ export interface OpencodeProviderBlocks {
128
+ v1: OpencodeProviderBlock;
129
+ v2: OpencodeV2ProviderBlock;
130
+ }
131
+
86
132
  export interface OpencodeGeneratedConfig {
87
133
  $schema: string;
134
+ /** Legacy block. Kept so opencode V1 installs keep working; V2 merges both and this one loses. */
88
135
  provider: Record<string, OpencodeProviderBlock>;
136
+ /** opencode V2 block. */
137
+ providers: Record<string, OpencodeV2ProviderBlock>;
89
138
  }
90
139
 
91
140
  /** Provider key owned by this project; the only key any exporter ever emits. */
@@ -99,6 +148,21 @@ export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
99
148
  */
100
149
  const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible";
101
150
 
151
+ /**
152
+ * opencode V2's spelling of the same runtime. V2 resolves providers through its own
153
+ * package table and ignores the V1 `npm` field, so a V2 block has to name this package
154
+ * or the provider is not loaded at all.
155
+ *
156
+ * Verified end-to-end against opencode 0.0.0-beta-18684: `GET /api/model` resolves this
157
+ * package for the provider and applies the per-model `variants`. opencode changes its
158
+ * provider package table between releases, so re-verify the supported range whenever it
159
+ * moves; a stale string breaks only the V2 block, silently.
160
+ */
161
+ const OPENCODE_V2_PROVIDER_PACKAGE = "@opencode-ai/ai/providers/openai-compatible";
162
+
163
+ /** Display name for the provider block, identical in both generations. */
164
+ const OPENCODE_PROVIDER_NAME = "OpenCodex";
165
+
102
166
  /**
103
167
  * Env var carrying the proxy admission key to opencode. The config only ever holds the
104
168
  * `{env:...}` reference, so the secret never lands on disk. opencode substitutes it at
@@ -474,6 +538,84 @@ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
474
538
  return join(primeAgentDir(env, home), "models.json");
475
539
  }
476
540
 
541
+ /**
542
+ * Aside's state root. Unlike every other client here, Aside ships NO variable
543
+ * that relocates it: its CLI carries `ASIDE_DAEMON_BASE_URL`,
544
+ * `ASIDE_PRODUCT_VARIANT` and similar, and the only `.aside` path baked into the
545
+ * binary is its own update-check file under `~/.aside/cli`. So there is no
546
+ * client-owned override to mirror, and this registry does not invent one.
547
+ */
548
+ export function asideHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
549
+ return join(home, ".aside");
550
+ }
551
+
552
+ /**
553
+ * Which account's catalog we write.
554
+ *
555
+ * Aside is per-ACCOUNT: state lives under `~/.aside/u/<id>/` and the id comes
556
+ * from `accounts.json`, which Aside maintains. That makes this the only path
557
+ * resolver here that parses file CONTENTS rather than probing existence — the
558
+ * module already does the latter at four sites.
559
+ *
560
+ * It throws rather than defaulting. A machine can hold several accounts (both
561
+ * `u/0` and `u/1` existed on the machine this was developed against), so
562
+ * guessing `0` when the manifest cannot be read would name a real config file
563
+ * belonging to a DIFFERENT account, pass the installed-directory check, and
564
+ * write into somebody else's catalog. An unresolvable account is reported the
565
+ * same way an unresolvable `DSH_HOME` is.
566
+ *
567
+ * Callers that need BOTH the config path and the detect directory must derive
568
+ * them from ONE call to `asideAccountDir` rather than calling the two exported
569
+ * helpers in sequence: `resolveIntegrationPaths` in the integration registry is
570
+ * that seam. Caching here cannot substitute for it — a cache keyed on the
571
+ * manifest's mtime re-reads exactly when the manifest changes, which is the
572
+ * case the consistency is needed for.
573
+ */
574
+ function asideCurrentAccountId(root: string): number {
575
+ const manifest = join(root, "accounts.json");
576
+ let raw: string;
577
+ try {
578
+ raw = readFileSync(manifest, "utf8");
579
+ } catch {
580
+ throw new ClientPathError(
581
+ `Aside's account manifest is missing or unreadable at ${manifest}, so opencodex cannot tell which `
582
+ + "account's model catalog to write. Launch Aside once to create it.",
583
+ );
584
+ }
585
+ let parsed: unknown;
586
+ try {
587
+ parsed = JSON.parse(raw);
588
+ } catch {
589
+ throw new ClientPathError(
590
+ `Aside's account manifest at ${manifest} is not readable JSON, so the account it names cannot be `
591
+ + "trusted. Writing a guessed account would target a different account's catalog.",
592
+ );
593
+ }
594
+ const id = (parsed as { currentAccountId?: unknown } | null)?.currentAccountId;
595
+ if (typeof id !== "number" || !Number.isInteger(id) || id < 0) {
596
+ throw new ClientPathError(
597
+ `Aside's account manifest at ${manifest} declares no usable currentAccountId, so opencodex cannot `
598
+ + "tell which account is current.",
599
+ );
600
+ }
601
+ return id;
602
+ }
603
+
604
+ /**
605
+ * The signed-in account's directory. This is also the install signal: the CLI
606
+ * creates `~/.aside/cli` for its own update check before any account exists, so
607
+ * the OUTER directory can be present on a machine that never signed in.
608
+ */
609
+ export function asideAccountDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
610
+ const root = asideHomeDir(env, home);
611
+ return join(root, "u", String(asideCurrentAccountId(root)));
612
+ }
613
+
614
+ /** Aside's custom-provider catalog for the current account. */
615
+ export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
616
+ return join(asideAccountDir(env, home), "models.json");
617
+ }
618
+
477
619
  /**
478
620
  * One proxy-routed model destined for a client config. Deliberately narrower than
479
621
  * `CatalogModel` so a serializer cannot reach for a field that does not survive the
@@ -516,7 +658,8 @@ export type ExportClientId =
516
658
  | "dsh"
517
659
  | "mcode"
518
660
  | "zcode"
519
- | "prime";
661
+ | "prime"
662
+ | "aside";
520
663
 
521
664
  export interface ExportClientSpec {
522
665
  id: ExportClientId;
@@ -651,8 +794,9 @@ function exportModelLabel(model: OpencodeCatalogModel): string {
651
794
  return `${id} (${providerLabel})`;
652
795
  }
653
796
 
654
- function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodeProviderBlock["options"] {
655
- const options: OpencodeProviderBlock["options"] = { baseURL };
797
+ /** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */
798
+ function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection {
799
+ const options: OpencodeProviderConnection = { baseURL };
656
800
  // Non-loopback binds accept proxy admission only via x-opencodex-api-key so Authorization
657
801
  // stays free for Codex Direct upstream credentials when applicable.
658
802
  if (shouldInjectApiAuthHeader(config)) {
@@ -664,37 +808,99 @@ function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodePr
664
808
  }
665
809
 
666
810
  /**
667
- * `opencodex` provider block for a resolved base URL.
811
+ * Selectable reasoning efforts for one model, in canonical ladder order.
812
+ *
813
+ * No model-level `settings.reasoningEffort` default is emitted: the proxy already applies
814
+ * its own configured default when a request carries no effort, and pinning one here would
815
+ * override a default the user controls in opencodex. Variants are opt-in per selection,
816
+ * which is the same reason we never emit `defaultModel` for MCode.
817
+ *
818
+ * `none` is dropped even when a ladder declares it. It is a valid *declared* effort, but the
819
+ * chat ingress filters wire efforts against `OUTPUT_CONFIG_EFFORTS`, which has no `none`, so
820
+ * selecting it would send no effort at all and silently fall back to the proxy default — a
821
+ * selectable value that cannot do what its label says. Same call MCode makes for its picker.
822
+ */
823
+ function opencodeEffortVariants(model: OpencodeCatalogModel): OpencodeModelVariant[] | undefined {
824
+ if (model.reasoningEfforts === undefined) return undefined;
825
+ // Canonical order (none, minimal, then low..ultra) and dedupe, so the picker order does
826
+ // not depend on whatever order a provider listed its efforts in.
827
+ const efforts = canonicalizeReasoningEfforts(model.reasoningEfforts).filter(effort => effort !== "none");
828
+ if (efforts.length === 0) return undefined;
829
+ return efforts.map(effort => ({ id: effort, settings: { reasoningEffort: effort } }));
830
+ }
831
+
832
+ /**
833
+ * Both provider generations for one resolved base URL.
668
834
  *
669
835
  * `limit.context` is emitted ONLY from an authoritative context window — never guessed.
670
836
  * When none is available the whole `limit` block is dropped and opencode keeps its own
671
837
  * defaults; when one is present, `limit.output` rides along (opencode's schema requires
672
838
  * the pair) clamped to the context window.
839
+ *
840
+ * Two blocks instead of one because opencode V2 reads the `providers` map and V1 reads
841
+ * `provider`, and only the V2 form applies `variants`. Emitting both keeps V1 installs
842
+ * working: V2 merges them by provider id and model id, so a model listed in both blocks
843
+ * appears once, with the V2 entry's name, connection, and variants.
673
844
  */
674
- function opencodeProviderBlock(
845
+ export function opencodeProviderBlocks(
675
846
  baseURL: string,
676
847
  catalogModels: readonly OpencodeCatalogModel[],
677
848
  config: OcxConfig,
678
- ): OpencodeProviderBlock {
679
- const models: Record<string, OpencodeModelEntry> = {};
849
+ ): OpencodeProviderBlocks {
850
+ const v1Models: Record<string, OpencodeModelEntry> = {};
851
+ const v2Models: Record<string, OpencodeV2ModelEntry> = {};
680
852
  for (const model of catalogModels) {
681
853
  const key = model.namespaced;
682
- if (models[key]) continue; // first entry wins; native rows lead /api/models
854
+ if (v1Models[key]) continue; // first entry wins; native rows lead /api/models
683
855
  const entry: OpencodeModelEntry = { name: exportModelLabel(model) };
684
856
  const context = authoritativeContextWindow(model.contextWindow);
685
857
  if (context !== undefined) {
686
858
  entry.limit = { context, output: outputBudgetFor(context) };
687
859
  }
688
- models[key] = entry;
860
+ v1Models[key] = entry;
861
+ const variants = opencodeEffortVariants(model);
862
+ // Own `limit` object, not a shared reference: the two blocks are serialized and reasoned
863
+ // about separately, and an in-place edit of one must never move the other.
864
+ v2Models[key] = {
865
+ ...entry,
866
+ ...(entry.limit ? { limit: { ...entry.limit } } : {}),
867
+ ...(variants ? { variants } : {}),
868
+ };
689
869
  }
690
870
  return {
691
- npm: OPENCODE_PROVIDER_NPM,
692
- name: "OpenCodex",
693
- options: opencodeProviderOptions(baseURL, config),
694
- models,
871
+ v1: {
872
+ npm: OPENCODE_PROVIDER_NPM,
873
+ name: OPENCODE_PROVIDER_NAME,
874
+ options: opencodeProviderConnection(baseURL, config),
875
+ models: v1Models,
876
+ },
877
+ v2: {
878
+ package: OPENCODE_V2_PROVIDER_PACKAGE,
879
+ name: OPENCODE_PROVIDER_NAME,
880
+ settings: opencodeProviderConnection(baseURL, config),
881
+ models: v2Models,
882
+ },
695
883
  };
696
884
  }
697
885
 
886
+ /** `opencodex` provider block for a resolved base URL (opencode V1 shape). */
887
+ function opencodeProviderBlock(
888
+ baseURL: string,
889
+ catalogModels: readonly OpencodeCatalogModel[],
890
+ config: OcxConfig,
891
+ ): OpencodeProviderBlock {
892
+ return opencodeProviderBlocks(baseURL, catalogModels, config).v1;
893
+ }
894
+
895
+ /** `opencodex` provider block for a resolved base URL (opencode V2 shape, carries variants). */
896
+ export function opencodeV2ProviderBlock(
897
+ baseURL: string,
898
+ catalogModels: readonly OpencodeCatalogModel[],
899
+ config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG,
900
+ ): OpencodeV2ProviderBlock {
901
+ return opencodeProviderBlocks(baseURL, catalogModels, config).v2;
902
+ }
903
+
698
904
  /**
699
905
  * Build the `opencodex` provider block from proxy catalog rows keyed by each row's
700
906
  * canonical `namespaced` selector. Used by the `ocx opencode` launcher, which injects
@@ -726,14 +932,23 @@ export function normalizeExportModels(models: readonly ExportModel[]): ExportMod
726
932
  return unique.sort((a, b) => (a.namespaced < b.namespaced ? -1 : a.namespaced > b.namespaced ? 1 : 0));
727
933
  }
728
934
 
729
- /** OpenCode V1 document: our provider block plus `$schema`, and nothing else. */
935
+ /**
936
+ * OpenCode document: both provider generations plus `$schema`, and nothing else.
937
+ *
938
+ * The order below fixes the order of the emitted keys and nothing else: the two blocks are
939
+ * disjoint top-level keys, and which generation opencode prefers when it merges them is
940
+ * opencode's decision, not a consequence of where we write it. Both blocks are generated in
941
+ * one pass so they cannot disagree about the model set, the names, or the connection.
942
+ */
730
943
  function buildOpencodeClientConfig(ctx: ExportContext): OpencodeGeneratedConfig {
731
- const block = opencodeProviderBlock(
732
- ctx.baseUrl,
733
- normalizeExportModels(ctx.models),
734
- ctx.config ?? OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG,
735
- );
736
- return { $schema: OPENCODE_CONFIG_SCHEMA, provider: { [OPENCODE_PROVIDER_ID]: block } };
944
+ const models = normalizeExportModels(ctx.models);
945
+ const config = ctx.config ?? OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG;
946
+ const blocks = opencodeProviderBlocks(ctx.baseUrl, models, config);
947
+ return {
948
+ $schema: OPENCODE_CONFIG_SCHEMA,
949
+ provider: { [OPENCODE_PROVIDER_ID]: blocks.v1 },
950
+ providers: { [OPENCODE_PROVIDER_ID]: blocks.v2 },
951
+ };
737
952
  }
738
953
 
739
954
  export interface PiModelEntry {
@@ -1435,7 +1650,16 @@ function singleFragment(clientId: ExportClientId, path: readonly string[], value
1435
1650
 
1436
1651
  function buildOpencodeContribution(ctx: ExportContext): ManagedContribution {
1437
1652
  const doc = buildOpencodeClientConfig(ctx);
1438
- return singleFragment("opencode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]);
1653
+ return {
1654
+ clientId: "opencode",
1655
+ fragments: [
1656
+ // Legacy block first, so the emitted JSON reads the way a config migration does.
1657
+ // opencode V1 reads only `provider`, V2 reads both, and the generation that wins the
1658
+ // merge is decided by opencode — what we control is that both name the same models.
1659
+ { path: ["provider", OPENCODE_PROVIDER_ID], value: doc.provider[OPENCODE_PROVIDER_ID] },
1660
+ { path: ["providers", OPENCODE_PROVIDER_ID], value: doc.providers[OPENCODE_PROVIDER_ID] },
1661
+ ],
1662
+ };
1439
1663
  }
1440
1664
 
1441
1665
  function buildPiContribution(ctx: ExportContext): ManagedContribution {
@@ -1512,6 +1736,29 @@ function buildPrimeContribution(ctx: ExportContext): ManagedContribution {
1512
1736
  return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1513
1737
  }
1514
1738
 
1739
+ /**
1740
+ * Aside is the strongest case yet for reusing Pi's builder, because the
1741
+ * evidence is a live file rather than a package manifest.
1742
+ *
1743
+ * The machine this landed on already had opencodex wired into Aside BY HAND:
1744
+ * `~/.aside/u/0/models.json` carried a `providers.opencodex` block with the same
1745
+ * four keys, the same `openai-completions` dialect, the same
1746
+ * `opencodex-loopback` placeholder, and 24 models using the same
1747
+ * `thinkingLevelMap` levels this builder emits. A user reproduced Pi's document
1748
+ * from scratch because that is what Aside reads.
1749
+ *
1750
+ * Key ORDER differs (the hand-written file has `apiKey` before `api`), which is
1751
+ * why the devlog claims compatibility rather than byte equality: JSON key order
1752
+ * is not semantic and Aside parses this file rather than diffing it.
1753
+ *
1754
+ * As with prime, only the ownership stamp is Aside's own, so a disable removes
1755
+ * the fragment this client recorded and not one another client wrote.
1756
+ */
1757
+ function buildAsideContribution(ctx: ExportContext): ManagedContribution {
1758
+ const doc = buildPiClientConfig(ctx);
1759
+ return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1760
+ }
1761
+
1515
1762
  export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1516
1763
  opencode: {
1517
1764
  id: "opencode",
@@ -1665,6 +1912,24 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1665
1912
  // from this initial loopback-only integration — same stance as OMP's.
1666
1913
  loopbackOnly: true,
1667
1914
  },
1915
+ aside: {
1916
+ id: "aside",
1917
+ // Not a bare `models.json`: a download lands in the user's Downloads folder,
1918
+ // where pi's and prime's files would collide with it. Prime set this
1919
+ // precedent with `prime-models.json`.
1920
+ filename: "aside-models.json",
1921
+ destination: env => asideConfigPath(env),
1922
+ apiKeyEnv: "",
1923
+ exportHint: "Aside reads a non-secret placeholder from models.json; loopback needs no key.",
1924
+ build: buildPiClientConfig,
1925
+ format: "json",
1926
+ summarize: summarizePi,
1927
+ buildContribution: buildAsideContribution,
1928
+ // The observed provider block has exactly four keys and none is `headers`,
1929
+ // so the dedicated admission header has nowhere to live and a non-loopback
1930
+ // bind would generate a config that 401s.
1931
+ loopbackOnly: true,
1932
+ },
1668
1933
  };
1669
1934
 
1670
1935
  export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
@@ -12,6 +12,7 @@ import {
12
12
  } from "../config";
13
13
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
14
14
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
15
+ import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
15
16
 
16
17
  type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
17
18
  type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
@@ -111,6 +112,11 @@ function persist(store: CodexAccountStore): void {
111
112
  atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
112
113
  }
113
114
 
115
+ function persistCredentialMutation(store: CodexAccountStore): void {
116
+ persist(store);
117
+ advanceCodexCredentialMutationEpoch();
118
+ }
119
+
114
120
  function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
115
121
  CodexAccountCredentialRecord,
116
122
  "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
@@ -142,7 +148,7 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
142
148
  replacedAt: current ? Date.now() : undefined,
143
149
  ...preservedValidationMetadata(current),
144
150
  };
145
- persist(store);
151
+ persistCredentialMutation(store);
146
152
  });
147
153
  }
148
154
 
@@ -213,7 +219,7 @@ export function saveCodexAccountCredentialIfGeneration(
213
219
  replacedAt: current.replacedAt,
214
220
  ...preservedValidationMetadata(current),
215
221
  };
216
- persist(store);
222
+ persistCredentialMutation(store);
217
223
  return true;
218
224
  });
219
225
  }
@@ -304,7 +310,7 @@ export function commitRefreshedCodexCredentialWithAliases(
304
310
  propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
305
311
  }
306
312
  }
307
- persist(store);
313
+ persistCredentialMutation(store);
308
314
  return { committed: true, propagatedAliases };
309
315
  });
310
316
  }
@@ -315,7 +321,7 @@ export function tombstoneCodexAccount(id: string): number {
315
321
  const current = store[id];
316
322
  const generation = (current?.generation ?? 0) + 1;
317
323
  store[id] = { generation, deletedAt: Date.now() };
318
- persist(store);
324
+ persistCredentialMutation(store);
319
325
  return generation;
320
326
  });
321
327
  }
@@ -90,9 +90,9 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth
90
90
  : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown"
91
91
  ? COMMANDS.restoreNative
92
92
  : inputs.serviceSupported
93
- // An already-registered service is refreshed in place: `repair` rewrites its assets
94
- // and restarts it without re-registering, so it needs no elevation on Windows and
95
- // cannot switch a WinSW install to Task Scheduler the way `install` would. Only a
93
+ // An already-registered service is refreshed in place: `repair` reuses healthy Windows
94
+ // scheduler definitions, while stale ones may be re-registered and require elevation.
95
+ // It still cannot switch a WinSW install to Task Scheduler the way `install` would. Only a
96
96
  // genuinely absent (or conflicting, which needs uninstall-then-install) service
97
97
  // gets the registering command.
98
98
  ? (inputs.serviceInstalled && !inputs.serviceConflict ? COMMANDS.repairService : COMMANDS.installService)
@@ -42,6 +42,7 @@ import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryT
42
42
  import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models";
43
43
  import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
44
44
  import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
45
+ import { effectiveModelAliases } from "../../providers/default-aliases";
45
46
  import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
46
47
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
47
48
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -2151,6 +2152,21 @@ async function gatherRoutedModelsUncached(
2151
2152
  // Custom rows override discovered rows that encode to the same Codex-facing slug.
2152
2153
  const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
2153
2154
  const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
2155
+ const models = [...deduped, ...customModels];
2156
+ // ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable.
2157
+ const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => {
2158
+ const providerModels = models.filter(model => model.provider === name);
2159
+ const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id))];
2160
+ return aliases.flatMap(([id, { alias }]) => {
2161
+ const exact = providerModels.filter(model => model.id === id);
2162
+ const matches = exact.length > 0
2163
+ ? exact
2164
+ : providerModels.filter(model => model.id.toLowerCase() === id.toLowerCase());
2165
+ return matches.length === 1
2166
+ ? [[`${name}/${matches[0]!.id}`, `${provider.alias || name}/${alias}`] as const]
2167
+ : [];
2168
+ });
2169
+ }));
2154
2170
  const providerModelOutcomes = providerResults.map(result => (
2155
2171
  result.outcome.provider === OPENAI_API_PROVIDER_ID
2156
2172
  && capture.openAiApiPolicy.state === "captured"
@@ -2159,7 +2175,10 @@ async function gatherRoutedModelsUncached(
2159
2175
  : result.outcome
2160
2176
  ));
2161
2177
  return {
2162
- models: [...deduped, ...customModels],
2178
+ models: models.map(model => {
2179
+ const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`);
2180
+ return displayName && !model.displayName ? { ...model, displayName } : model;
2181
+ }),
2163
2182
  comboOmissions: localOmissions,
2164
2183
  providerAuthOutcomes: localProviderAuthOutcomes,
2165
2184
  providerModelOutcomes,
@@ -35,6 +35,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../accou
35
35
  import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
36
36
  import {
37
37
  availableAccountGatedNativeModels,
38
+ codexModelEntitlementStateForAccount,
38
39
  isCodexModelEntitlementSnapshotCurrent,
39
40
  resolveCodexModelEntitlements,
40
41
  type CodexModelEntitlementSnapshot,
@@ -1613,10 +1614,10 @@ function writeRetainedCatalogSync({
1613
1614
  ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
1614
1615
  const target = accountTargets.get(selector);
1615
1616
  const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
1616
- const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
1617
- const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
1618
1617
  return [selector, slugs.filter(slug => (
1619
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
1618
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
1619
+ || (accountId !== undefined
1620
+ && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
1620
1621
  ))] as const;
1621
1622
  }))
1622
1623
  : new Map<string, readonly string[]>();