jeo-code 0.11.0 → 0.11.2

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/CHANGELOG.md CHANGED
@@ -9,6 +9,32 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.11.2] - 2026-09-30
13
+ _OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries._
14
+
15
+ ### Fixed
16
+ - Use Codex `visibility` for ChatGPT model discovery; retain visible subscription-only models even when `supported_in_api` is false. Keep modern GPT Codex models in API-key discovery and fetch both catalogs when both credentials are configured.
17
+ - Preserve the full OpenAI model list by default, de-duplicate picker rows, and keep empty or failed live results honest instead of fabricating availability from a static snapshot.
18
+ - Replace account-scoped OpenAI model observations on refresh, migrate old caches, and separate authentication sources and custom endpoints. Use stored account identity for opaque OAuth credentials and the proxy's own API key for custom endpoints.
19
+
20
+ ### Validation
21
+ - Regression coverage for visibility, dual credentials, complete lists, account headers, proxy scoping, cache replacement, and authoritative empty catalogs.
22
+
23
+ ## [0.11.1] - 2026-08-25
24
+ _Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited._
25
+
26
+ ### Fixed
27
+ - **A one-shot run never exited when `notifications.enabled` was set** (`src/agent/notify/session-endpoint.ts`, `src/commands/launch.ts`) — `startSessionNotifyEndpoint` binds a `Bun.serve` socket plus a snapshot-poll `setInterval` for the whole launch lifetime, but its teardown lived only on the interactive REPL's exit path. A one-shot run leaves through one of ~17 early `return`s, none of which reach it, so the listening socket and the ref'd timer kept the event loop alive indefinitely. Fixed on both sides: the server and poll timer are now `unref`'d so the endpoint can never be the reason the process stays up, and the entire one-shot block is wrapped in a `try/finally` that stops the endpoint — which also removes the session discovery file, so the Telegram daemon stops dialling a dead socket after a scripted run.
28
+ - This was invisible for two compounding reasons: it only reproduces on a machine with notifications actually configured, and Bun timers/servers do not appear in `process._getActiveHandles()` — so the hung process reported **zero** active handles while refusing to exit. It was found by monkey-patching `setInterval`/`setTimeout` to record creation sites and dumping the still-ref'd ones after `dispatch()` returned.
29
+ - **The `bashTool` orphan-reaping assertions compared raw command output to the string `"0"`** (`test/tools-fs.test.ts`) — `bashTool` merges stdout and stderr, so any warning `pgrep` wrote to stderr false-failed a run in which zero strays actually existed. The count is now parsed rather than string-matched, and the orphan check is gated on process enumeration actually working (hardened sandboxes answer `pgrep: Cannot get process list`). The abort **contract** itself (`success:false` / `"Command aborted"`) is still asserted unconditionally, so a restricted host keeps covering the behaviour that matters.
30
+
31
+ ### Added
32
+ - `test/notify-endpoint-exit.test.ts` — pins the exit contract deterministically by pointing `JEO_CONFIG_DIR` at a temp config with notifications ON, rather than depending on the developer's real `~/.jeo/config.json`. Covers both halves independently: the `finally` teardown (one-shot exits, no leftover discovery file) and the `unref` guard (any future path that forgets `stop()` still cannot pin the process open).
33
+
34
+ ### Verified
35
+ - `bun run typecheck` clean; `bun test` **3348 pass, 0 fail** — down from 22 failures two releases ago and 6 at 0.11.0. Suite wall time also dropped ~148s → ~88s, since the hung specs no longer burn their full 25s/5s timeouts.
36
+ - The unref guard was verified by reverting it and confirming the new unit test fails, then restoring it.
37
+
12
38
  ## [0.11.0] - 2026-08-25
13
39
  _One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside._
14
40
 
package/README.ja.md CHANGED
@@ -319,10 +319,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
319
319
 
320
320
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
321
321
  - **[Unreleased]**
322
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
323
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
322
324
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
323
325
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
324
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
325
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
326
326
 
327
327
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
328
328
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -320,10 +320,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
320
320
 
321
321
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
322
322
  - **[Unreleased]**
323
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
324
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
323
325
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
324
326
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
325
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
326
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
327
327
 
328
328
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
329
329
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -327,10 +327,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
327
327
 
328
328
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
329
329
  - **[Unreleased]**
330
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
331
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
330
332
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
331
333
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
332
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
333
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
334
334
 
335
335
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
336
336
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -317,10 +317,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
317
317
 
318
318
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
319
319
  - **[Unreleased]**
320
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
321
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
320
322
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
321
323
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
322
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
323
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
324
324
 
325
325
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
326
326
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -229,7 +229,22 @@ export class SessionNotifyEndpoint {
229
229
  },
230
230
  },
231
231
  });
232
+ // Same reasoning as the poll timer below: a listening socket also keeps the Bun
233
+ // event loop alive. This server exists to MIRROR a session, never to outlive it,
234
+ // so it must not be able to decide the process stays up. `stop()` still tears it
235
+ // down explicitly on the paths that have one — this only removes its veto over exit.
236
+ (this.server as { unref?: () => void }).unref?.();
232
237
  this.pollTimer = setInterval(() => this.broadcastIfChanged(), SNAPSHOT_POLL_MS);
238
+ // Unref the poll timer so it can NEVER be the reason the process stays alive.
239
+ // This endpoint mirrors an already-running session; it has no work of its own
240
+ // worth holding the event loop open for. Left ref'd, any code path that starts
241
+ // the endpoint but does not reach an explicit `stop()` hangs the process
242
+ // forever — which is exactly what happened to every non-interactive invocation
243
+ // (`echo "…" | jeo`, `jeo -p "…"` in CI): the one-shot branch returns long
244
+ // before the interactive REPL's exit teardown runs. Bun timers do not appear in
245
+ // `process._getActiveHandles()`, so that hang presented as a process with zero
246
+ // active handles refusing to exit.
247
+ (this.pollTimer as { unref?: () => void }).unref?.();
233
248
  await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
234
249
  const payload = {
235
250
  url: `ws://127.0.0.1:${this.server.port}`,
@@ -11,7 +11,7 @@
11
11
  * Persisting the discovered lists (gjc parity) closes both gaps: the next launch
12
12
  * rehydrates the account's REAL model set from disk before any network call, and a
13
13
  * background refresh keeps it current. The cache is an optimization — a missing,
14
- * corrupt, or stale file only means "fall back to live discovery + static catalog",
14
+ * corrupt, or stale file only means live discovery must refresh the catalog,
15
15
  * never an error.
16
16
  */
17
17
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -37,15 +37,16 @@ export interface CachedProviderModels {
37
37
  }
38
38
 
39
39
  export interface ModelCacheFile {
40
- version: 2;
40
+ version: 3;
41
41
  updatedAt: number;
42
42
  providers: CachedProviderModels[];
43
43
  }
44
44
 
45
- const CACHE_VERSION = 2;
45
+ // Earlier snapshots could persist hidden or fabricated OpenAI entries.
46
+ const CACHE_VERSION = 3;
46
47
  /** Refresh in the background once the cache is older than this (6h). */
47
48
  export const MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
48
- /** Bound a single provider's persisted list so a pathological aggregator cannot bloat the file. */
49
+ /** Bound other providers' cache rows; preserve complete authoritative OpenAI lists. */
49
50
  const MAX_MODELS_PER_PROVIDER = 500;
50
51
 
51
52
  function cacheDir(): string {
@@ -62,9 +63,9 @@ export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
62
63
  for (const entry of raw) {
63
64
  const row = entry as Partial<CachedProviderModels>;
64
65
  if (!row || typeof row.provider !== "string" || !Array.isArray(row.models)) continue;
65
- const models = row.models.filter((m): m is string => typeof m === "string" && m.trim().length > 0).slice(0, MAX_MODELS_PER_PROVIDER);
66
- if (models.length === 0) continue;
67
66
  const provider = row.provider as ProviderName;
67
+ const models = row.models.filter((m): m is string => typeof m === "string" && m.trim().length > 0).slice(0, provider === "openai" ? undefined : MAX_MODELS_PER_PROVIDER);
68
+ if (models.length === 0 && (provider !== "openai" || row.models.length !== 0)) continue;
68
69
  const source: ModelCacheSource =
69
70
  row.source === "oauth" || row.source === "api_key" || row.source === "keyless" || row.source === "none" ? row.source : "none";
70
71
  const baseUrl = typeof row.baseUrl === "string" ? row.baseUrl.replace(/\/+$/, "") : undefined;
@@ -111,26 +112,26 @@ export function isModelCacheStale(cache: ModelCacheFile | null, now = Date.now()
111
112
  */
112
113
  export function mergeCacheEntries(
113
114
  previous: readonly CachedProviderModels[],
114
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
115
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
115
116
  ): CachedProviderModels[] {
116
117
  const byKey = new Map<string, CachedProviderModels>();
117
- const keyOf = (provider: string, baseUrl?: string, accountId?: string) =>
118
- `${provider}\u0000${baseUrl ?? ""}\u0000${accountId ?? ""}`;
118
+ const keyOf = (provider: string, baseUrl?: string, accountId?: string, source?: ModelCacheSource) =>
119
+ `${provider}\u0000${baseUrl?.replace(/\/+$/, "") ?? ""}\u0000${accountId ?? ""}\u0000${provider === "openai" ? source ?? "none" : ""}`;
119
120
  for (const entry of previous) {
120
121
  const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
121
122
  if (isOpenAIOAuth && !entry.accountId) continue;
122
- byKey.set(keyOf(entry.provider, entry.baseUrl, isOpenAIOAuth ? entry.accountId : undefined), entry);
123
+ byKey.set(keyOf(entry.provider, entry.baseUrl, isOpenAIOAuth ? entry.accountId : undefined, entry.source), entry);
123
124
  }
124
125
  for (const result of results) {
125
- if (!result.ok || result.models.length === 0) continue;
126
+ if (!result.ok || result.fallback || (result.models.length === 0 && result.provider !== "openai")) continue;
126
127
  const source: ModelCacheSource = result.source ?? "none";
127
128
  const isOpenAIOAuth = result.provider === "openai" && source === "oauth";
128
129
  const baseUrl = typeof result.baseUrl === "string" ? result.baseUrl.replace(/\/+$/, "") : undefined;
129
130
  const accountId = typeof result.accountId === "string" ? result.accountId.trim() : "";
130
131
  if (isOpenAIOAuth && !accountId) continue;
131
- byKey.set(keyOf(result.provider, baseUrl, isOpenAIOAuth ? accountId : undefined), {
132
+ byKey.set(keyOf(result.provider, baseUrl, isOpenAIOAuth ? accountId : undefined, source), {
132
133
  provider: result.provider,
133
- models: [...result.models].slice(0, MAX_MODELS_PER_PROVIDER),
134
+ models: [...result.models].slice(0, result.provider === "openai" ? undefined : MAX_MODELS_PER_PROVIDER),
134
135
  source,
135
136
  ...(baseUrl ? { baseUrl } : {}),
136
137
  ...(isOpenAIOAuth ? { accountId } : {}),
@@ -141,11 +142,16 @@ export function mergeCacheEntries(
141
142
 
142
143
  /** Persist discovery results (best-effort; never throws, never blocks a turn). */
143
144
  export async function writeModelCache(
144
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
145
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
145
146
  ): Promise<void> {
146
147
  // Same hermeticity rule as saveGlobalConfig: a test run may only write into an
147
148
  // explicitly sandboxed JEO_CONFIG_DIR, never the developer's real ~/.jeo.
148
149
  if (process.env.NODE_ENV === "test" && !jeoEnv("CONFIG_DIR")) return;
150
+ // A failed/fallback-only refresh must not make an old snapshot appear fresh.
151
+ const hasFreshResult = results.some(result => result.ok && !result.fallback
152
+ && (result.models.length > 0 || result.provider === "openai")
153
+ && !(result.provider === "openai" && result.source === "oauth" && !result.accountId?.trim()));
154
+ if (!hasFreshResult) return;
149
155
  try {
150
156
  const existing = await readModelCache();
151
157
  const providers = mergeCacheEntries(existing?.providers ?? [], results);
@@ -173,9 +179,10 @@ export function applyCachedModels(cache: ModelCacheFile | null, openAIOauthAccou
173
179
  recordLiveProviderModels(entry.provider, entry.models, {
174
180
  source: entry.source,
175
181
  baseUrl: entry.baseUrl,
182
+ replace: entry.provider === "openai",
176
183
  ...(isOpenAIOAuth && entry.accountId ? { accountId: entry.accountId } : {}),
177
184
  });
178
- if (isOpenAIOAuth && entry.accountId) {
185
+ if (isOpenAIOAuth && entry.accountId && !entry.baseUrl) {
179
186
  recordLiveCodexModels(entry.models, entry.accountId);
180
187
  }
181
188
  applied += entry.models.length;
@@ -179,9 +179,8 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
179
179
 
180
180
  /**
181
181
  * OpenAI models the ChatGPT/Codex subscription backend (`codex/responses`) actually
182
- * serves. The Codex backend rejects standard API ids (gpt-4o, o3, …) and exposes no
183
- * usable list endpoint, so an OAuth-only OpenAI login surfaces exactly these instead
184
- * of the full chat-completions catalog. Verified live against a ChatGPT account.
182
+ * serves in the bundled offline snapshot. A confirmed account-specific model list
183
+ * replaces this snapshot for OAuth eligibility, including a successful empty list.
185
184
  */
186
185
  export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
187
186
 
@@ -236,7 +235,7 @@ export function recordLiveCodexModels(ids: readonly string[], accountId?: string
236
235
  const explicitScope = normalizeOpenAIOauthScope(accountId);
237
236
  if (explicitScope) setOpenAIOauthAccountScope(explicitScope);
238
237
  const scope = explicitScope || EPHEMERAL_OPENAI_OAUTH_SCOPE;
239
- const bucket = liveCodexModels.get(scope) ?? new Set<string>();
238
+ const bucket = new Set<string>();
240
239
  for (const id of ids) {
241
240
  const trimmed = id.trim();
242
241
  if (trimmed) bucket.add(trimmed);
@@ -244,10 +243,11 @@ export function recordLiveCodexModels(ids: readonly string[], accountId?: string
244
243
  liveCodexModels.set(scope, bucket);
245
244
  }
246
245
 
247
- /** True for the static Codex snapshot or a live id confirmed for the active scope. */
246
+ /** A confirmed account catalog is authoritative; use the snapshot before discovery. */
248
247
  export function isCodexModel(model: string): boolean {
249
248
  const wire = model.startsWith("openai/") ? model.slice(7) : model;
250
- return CODEX_MODELS.includes(wire) || (liveCodexModels.get(activeOpenAIOauthScope)?.has(wire) ?? false);
249
+ const confirmed = liveCodexModels.get(activeOpenAIOauthScope);
250
+ return confirmed ? confirmed.has(wire) : CODEX_MODELS.includes(wire);
251
251
  }
252
252
 
253
253
  /** Test-only reset for live Codex observations and the active account scope. */
@@ -330,9 +330,10 @@ function liveModelRecordKey(
330
330
  canonical: string,
331
331
  baseUrl: string | undefined,
332
332
  accountId?: string,
333
+ source?: LiveProviderModelSource,
333
334
  ): string {
334
335
  const normalizedAccountId = provider === "openai" ? normalizeOpenAIOauthScope(accountId) : "";
335
- return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${normalizedAccountId}\u0000${canonical}`;
336
+ return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${normalizedAccountId}\u0000${provider === "openai" ? source ?? "none" : ""}\u0000${canonical}`;
336
337
  }
337
338
 
338
339
  function liveModelFallbackRow(provider: ProviderName, canonical: string, providerModel: string): CatalogModel {
@@ -351,12 +352,12 @@ function liveModelFallbackRow(provider: ProviderName, canonical: string, provide
351
352
  };
352
353
  }
353
354
 
354
- /** Record live ids returned by a provider's own model-list endpoint. Additive only:
355
- * transient partial responses must not evict an id observed earlier this session. */
355
+ /** Record live ids. Complete OpenAI refreshes may replace their matching scope;
356
+ * partial observations and other providers remain additive. */
356
357
  export function recordLiveProviderModels(
357
358
  provider: ProviderName,
358
359
  ids: readonly string[],
359
- opts: { source?: LiveProviderModelSource; baseUrl?: string; accountId?: string } = {},
360
+ opts: { source?: LiveProviderModelSource; baseUrl?: string; accountId?: string; replace?: boolean } = {},
360
361
  ): void {
361
362
  const source = opts.source ?? "none";
362
363
  const baseUrl = normalizeBaseUrl(opts.baseUrl);
@@ -366,6 +367,14 @@ export function recordLiveProviderModels(
366
367
  if (provider === "openai" && source === "oauth" && scopedAccountId) {
367
368
  setOpenAIOauthAccountScope(scopedAccountId);
368
369
  }
370
+ if (provider === "openai" && opts.replace) {
371
+ for (const [key, record] of liveProviderModels) {
372
+ if (record.row.provider === provider && record.source === source && record.baseUrl === baseUrl
373
+ && oauthScope(record.accountId) === oauthScope(scopedAccountId)) {
374
+ removeLiveProviderRecord(key, record);
375
+ }
376
+ }
377
+ }
369
378
  for (const id of ids) {
370
379
  const canonical = liveCanonicalId(provider, id);
371
380
  if (!canonical) continue;
@@ -374,7 +383,7 @@ export function recordLiveProviderModels(
374
383
  const row = known
375
384
  ? { ...known, canonical, provider, providerModel }
376
385
  : liveModelFallbackRow(provider, canonical, providerModel);
377
- const recordKey = liveModelRecordKey(provider, canonical, baseUrl, scopedAccountId);
386
+ const recordKey = liveModelRecordKey(provider, canonical, baseUrl, scopedAccountId, source);
378
387
  liveProviderModels.set(recordKey, {
379
388
  row,
380
389
  source,
@@ -13,7 +13,6 @@ import type { ProviderName } from "./types";
13
13
  import { PROVIDER_NAMES } from "./provider-status";
14
14
  import {
15
15
  catalogByProvider,
16
- CODEX_MODELS,
17
16
  KIMI_CODE_MODELS,
18
17
  recordLiveCodexModels,
19
18
  recordLiveProviderModels,
@@ -32,6 +31,8 @@ export interface ProviderModelsResult {
32
31
  source: "oauth" | "api_key" | "keyless" | "none";
33
32
  /** Stable OpenAI OAuth account scope for account-specific cache rows. */
34
33
  accountId?: string;
34
+ /** Actual custom endpoint queried, for endpoint-scoped persistent caches. */
35
+ baseUrl?: string;
35
36
  /** Present on failure: a short, human-readable reason. */
36
37
  error?: string;
37
38
  /** True when the live endpoint was unusable and ids came from the static catalog. */
@@ -45,7 +46,7 @@ export interface DiscoveryOptions {
45
46
  /** Per-request timeout; default 5000ms. */
46
47
  timeoutMs?: number;
47
48
  signal?: AbortSignal;
48
- /** Cap the number of returned ids per provider; default 100. */
49
+ /** Cap returned ids; OpenAI defaults to the full list, other providers to 100. */
49
50
  limit?: number;
50
51
  /** Config snapshot used for provider base URLs. */
51
52
  config?: Config;
@@ -54,8 +55,8 @@ export interface DiscoveryOptions {
54
55
  * the just-stored OAuth credential even when an unrelated `providers.openai`
55
56
  * API key is also configured, so the report reflects the account that was
56
57
  * JUST logged in rather than silently swapping to the API key. Does not
57
- * change any other discovery caller — the TUI/`discoverModels` default
58
- * (API key preferred when configured) is untouched.
58
+ * change single-provider discovery's API-key preference. `discoverModels`
59
+ * queries both OpenAI catalogs when both credentials are configured.
59
60
  */
60
61
  preferOAuth?: boolean;
61
62
  }
@@ -107,6 +108,7 @@ export function discoveryRequest(
107
108
  provider: ProviderName,
108
109
  cred: Credential | undefined,
109
110
  baseUrl?: string,
111
+ oauthAccountId?: string,
110
112
  ): { url: string; headers: Record<string, string>; method?: "GET" | "POST"; body?: string } {
111
113
  // Catalog-driven compat providers: OpenAI `${base}/models` (Bearer) or Anthropic
112
114
  // `${base}/v1/models` (x-api-key). Both return { data: [{ id }] }.
@@ -125,7 +127,7 @@ export function discoveryRequest(
125
127
  case "openai": {
126
128
  const token = cred?.kind === "oauth" || cred?.kind === "api_key" ? cred.token : "";
127
129
  if (cred?.kind === "oauth" && !baseUrl && !process.env.OPENAI_BASE_URL) {
128
- const accountId = extractChatgptAccountId(token);
130
+ const accountId = extractChatgptAccountId(token) || oauthAccountId;
129
131
  const headers: Record<string, string> = {
130
132
  Authorization: `Bearer ${token}`,
131
133
  "OpenAI-Beta": "responses=experimental",
@@ -180,11 +182,12 @@ export function discoveryRequest(
180
182
 
181
183
  /**
182
184
  * OpenAI `/v1/models` lists every model family — embeddings, audio/tts, image, moderation,
183
- * realtime — but jeo only calls chat/completions. Drop the families that can never serve a
185
+ * realtime — jeo uses chat/completions and Responses. Drop the families that cannot serve a
184
186
  * chat turn so pickers never offer a model that fails at call time.
185
187
  */
186
188
  function isOpenAiChatModel(id: string): boolean {
187
- return !/(^|[-/])(text-embedding|embedding|tts|whisper|dall-e|moderation|omni-moderation|davinci|babbage|computer-use|realtime|audio|image|sora|transcribe|instruct|codex)([-/]|$)/i.test(id);
189
+ return !/(^|[-/])(text-embedding|embedding|tts|whisper|dall-e|moderation|omni-moderation|davinci|babbage|computer-use|realtime|audio|image|sora|transcribe|instruct)([-/]|$)/i.test(id)
190
+ && !/^codex([-/]|$)/i.test(id);
188
191
  }
189
192
 
190
193
  /**
@@ -195,11 +198,11 @@ function isGeminiChatModel(id: string): boolean {
195
198
  return !/(^|[-/])(embedding|aqa|tts|image|imagen|veo|lyria|nano-banana|deep-research|computer-use|antigravity)([-/]|$)/i.test(id);
196
199
  }
197
200
 
198
- type CodexModelRow = { slug?: string; id?: string; supported_in_api?: boolean; priority?: number };
201
+ type CodexModelRow = { slug?: string; id?: string; visibility?: string; supported_in_api?: boolean; priority?: number };
199
202
  type AntigravityModelRow = { slug?: string; id?: string; name?: string; isInternal?: boolean; model?: string };
200
203
 
201
204
  /** Parse a provider's models response body into normalized, chat-capable model ids. */
202
- export function parseModelsBody(provider: ProviderName, body: unknown): string[] {
205
+ export function parseModelsBody(provider: ProviderName, body: unknown, opts: { openaiCompatible?: boolean } = {}): string[] {
203
206
  const data = body as {
204
207
  data?: { id?: string }[];
205
208
  models?: ({ name?: string; supportedGenerationMethods?: string[] } & CodexModelRow)[];
@@ -285,14 +288,16 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
285
288
  }
286
289
  if (provider === "openai" && data.models?.some(m => m.slug || m.id)) {
287
290
  return data.models
288
- .filter(m => m.supported_in_api !== false)
291
+ // supported_in_api describes API-key availability, not ChatGPT entitlement.
292
+ // Codex's ChatGPT picker uses visibility, including subscription-only rows.
293
+ .filter(m => m.visibility === undefined || m.visibility === "list")
289
294
  .map(m => m.slug ?? m.id ?? "")
290
295
  // Review-only entries (e.g. codex-auto-review) are not chat-turn models.
291
296
  .filter(id => id && !/(^|[-/])auto-review([-/]|$)/i.test(id));
292
297
  }
293
298
  // anthropic / openai: { data: [{ id }] }
294
299
  const ids = (data.data ?? []).map(m => m.id ?? "").filter(Boolean);
295
- return provider === "openai" ? ids.filter(isOpenAiChatModel) : ids;
300
+ return provider === "openai" && !opts.openaiCompatible ? ids.filter(isOpenAiChatModel) : ids;
296
301
  }
297
302
 
298
303
  /**
@@ -305,23 +310,23 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
305
310
  */
306
311
  export function catalogOr(result: ProviderModelsResult): ProviderModelsResult {
307
312
  if (result.ok && result.models.length > 0) return result;
313
+ // OpenAI has live list endpoints for both auth modes. Neither a failed request
314
+ // nor an authoritative empty list establishes access to the static snapshot.
315
+ if (result.provider === "openai") return result;
308
316
  // Antigravity's available models depend on the Cloud Code Assist agent backend
309
317
  // and must never be faked from a hard-coded catalog.
310
318
  if (result.provider === "antigravity") return result;
311
319
  // No catalog-fallback for oauth-sourced gemini failures: OAuth can LIST but
312
320
  // not SERVE gemini models (see listProviderModels' gemini gate).
313
321
  if (result.provider === "gemini" && result.source === "oauth") return result;
314
- // OAuth: OpenAI/Codex legitimately rejects the standard /models endpoint while
315
- // the fixed Codex ids still work; other OAuth providers fall back to their full
316
- // static catalog too. API-key providers only fall back when the provider has NO
322
+ // Other OAuth providers fall back to their static catalog. API-key providers
323
+ // only fall back when the provider has NO
317
324
  // models-list endpoint at all (HTTP 404 / not found) — never for an auth
318
325
  // rejection, so a wrong key stays a failure instead of fabricating catalog rows.
319
326
  const eligible = result.source === "oauth"
320
327
  || (result.source === "api_key" && isMissingModelsEndpoint(result.error));
321
328
  if (!eligible) return result;
322
- const ids = result.provider === "openai" && result.source === "oauth"
323
- ? [...CODEX_MODELS]
324
- : result.provider === "kimi" && result.source === "oauth"
329
+ const ids = result.provider === "kimi" && result.source === "oauth"
325
330
  // Kimi OAuth (Kimi Code subscription) serves ONLY the Kimi Code catalog —
326
331
  // moonshot API-platform ids would 404 against api.kimi.com/coding.
327
332
  ? KIMI_CODE_MODELS.map(id => `kimi/${id}`)
@@ -345,7 +350,8 @@ export async function listProviderModels(
345
350
  opts: DiscoveryOptions = {},
346
351
  ): Promise<ProviderModelsResult> {
347
352
  const fetchImpl = opts.fetchImpl ?? fetch;
348
- const limit = opts.limit ?? DEFAULT_LIMIT;
353
+ const limit = opts.limit ?? (provider === "openai" ? Number.POSITIVE_INFINITY : DEFAULT_LIMIT);
354
+ let baseUrl = opts.baseUrl;
349
355
 
350
356
  let cred: Credential | undefined;
351
357
  let oauthAccountId: string | undefined;
@@ -363,6 +369,7 @@ export async function listProviderModels(
363
369
  cred = raw;
364
370
  source = cred.kind === "oauth" ? "oauth" : cred.kind === "api_key" ? "api_key" : "none";
365
371
  const config = opts.config ?? (await readGlobalConfig());
372
+ if (provider === "openai") baseUrl = (baseUrl ?? config.openaiBaseUrl ?? process.env.OPENAI_BASE_URL)?.replace(/\/+$/, "");
366
373
 
367
374
  if (provider === "antigravity") {
368
375
  // Antigravity lists models from the LIVE Cloud Code Assist endpoint
@@ -391,6 +398,12 @@ export async function listProviderModels(
391
398
  cred = { kind: "api_key", provider: prov, token: config.providers[prov]! };
392
399
  source = "api_key";
393
400
  }
401
+ if (provider === "openai" && baseUrl && cred.kind === "oauth") {
402
+ // Subscription credentials belong to ChatGPT, not a configured proxy.
403
+ const key = config.providers?.openai;
404
+ cred = key ? { kind: "api_key", provider: "openai", token: key } : { kind: "none", provider: "openai" };
405
+ source = key ? "api_key" : "keyless";
406
+ }
394
407
  if (provider === "openai" && source === "oauth" && cred.kind === "oauth") {
395
408
  const stored = await getStoredOAuth("openai").catch(() => undefined);
396
409
  const jwtAccountId = extractChatgptAccountId(cred.token);
@@ -406,23 +419,32 @@ export async function listProviderModels(
406
419
  if (provider === "gemini" && cred.kind === "oauth") {
407
420
  return { provider, models: [], ok: false, source, error: "OAuth cannot serve gemini models — set GEMINI_API_KEY, or use antigravity/* (Cloud Code Assist)" };
408
421
  }
409
- const isLocalOpenAi = provider === "openai" && !!(opts.baseUrl ?? process.env.OPENAI_BASE_URL);
422
+ const isLocalOpenAi = provider === "openai" && !!baseUrl;
410
423
  if (source === "none" && !isLocalOpenAi) {
411
424
  return { provider, models: [], ok: false, source, error: "not logged in" };
412
425
  }
413
426
  }
414
427
 
415
- const { url, headers, method, body: requestBody } = discoveryRequest(provider, cred, opts.baseUrl);
428
+ const { url, headers, method, body: requestBody } = discoveryRequest(provider, cred, baseUrl, oauthAccountId);
429
+ const scope = { ...(oauthAccountId ? { accountId: oauthAccountId } : {}), ...(baseUrl ? { baseUrl } : {}) };
416
430
  const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT;
417
431
  const signal = opts.signal ?? AbortSignal.timeout(timeout);
418
432
  try {
419
433
  const res = await fetchImpl(url, { method: method ?? "GET", headers, body: requestBody, signal });
420
434
  if (!res.ok) {
421
435
  const reason = res.status === 401 || res.status === 403 ? "auth rejected" : `HTTP ${res.status}`;
422
- return { provider, models: [], ok: false, source, error: reason };
436
+ return { provider, models: [], ok: false, source, error: reason, ...scope };
423
437
  }
424
438
  const body = await res.json();
425
- let ids = parseModelsBody(provider, body);
439
+ if (provider === "openai") {
440
+ const payload = body as { models?: unknown; data?: unknown } | null;
441
+ const rows = Array.isArray(payload?.models) ? payload.models : payload?.data;
442
+ if (!Array.isArray(rows) || rows.some(row => !row || typeof row !== "object"
443
+ || ![row.slug, row.id].some(id => typeof id === "string" && id.trim().length > 0))) {
444
+ return { provider, models: [], ok: false, source, error: "invalid models response", ...scope };
445
+ }
446
+ }
447
+ let ids = parseModelsBody(provider, body, { openaiCompatible: provider === "openai" && !!baseUrl });
426
448
  // Gemini paginates: follow nextPageToken (bounded) so the available list is
427
449
  // COMPLETE — page 1 alone silently dropped the newest models (round-15).
428
450
  if (provider === "gemini") {
@@ -436,26 +458,28 @@ export async function listProviderModels(
436
458
  pageToken = pageBody.nextPageToken;
437
459
  }
438
460
  }
439
- const models = [...new Set(ids)].sort().slice(0, limit);
461
+ const allModels = [...new Set(ids)].sort();
462
+ const models = allModels.slice(0, limit);
440
463
  // Record live ids for routing supplements. For OpenAI OAuth, keep the
441
464
  // existing Codex allow-list widening separate: API-key/custom-base discovery
442
465
  // must not make OAuth Codex calls accept unrelated API models.
443
- recordLiveProviderModels(provider, models, {
466
+ recordLiveProviderModels(provider, allModels, {
444
467
  source,
445
- baseUrl: opts.baseUrl,
468
+ baseUrl,
469
+ replace: provider === "openai",
446
470
  ...(provider === "openai" && source === "oauth" && oauthAccountId ? { accountId: oauthAccountId } : {}),
447
471
  });
448
- if (provider === "openai" && source === "oauth") recordLiveCodexModels(models, oauthAccountId);
472
+ if (provider === "openai" && source === "oauth" && !baseUrl) recordLiveCodexModels(allModels, oauthAccountId);
449
473
  return {
450
474
  provider,
451
475
  models,
452
476
  ok: true,
453
477
  source,
454
- ...(oauthAccountId ? { accountId: oauthAccountId } : {}),
478
+ ...scope,
455
479
  };
456
480
  } catch (err) {
457
481
  const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
458
- return { provider, models: [], ok: false, source, error: msg };
482
+ return { provider, models: [], ok: false, source, error: msg, ...scope };
459
483
  }
460
484
  }
461
485
 
@@ -495,13 +519,24 @@ export async function discoverModels(
495
519
  const providers = opts.providers ?? [...PROVIDER_NAMES];
496
520
  const useFallback = opts.catalogFallback !== false;
497
521
  const results = await Promise.all(
498
- providers.map(p =>
499
- listProviderModels(p, {
522
+ providers.map(async p => {
523
+ const discoveryOpts = {
500
524
  ...opts,
501
525
  config: cfg,
502
526
  baseUrl: p === "ollama" ? (cfg.ollamaBaseUrl ?? opts.baseUrl) : p === "lmstudio" ? (cfg.lmstudioBaseUrl ?? opts.baseUrl) : p === "openai" ? (cfg.openaiBaseUrl ?? opts.baseUrl) : opts.baseUrl,
503
- }),
504
- ),
527
+ };
528
+ const primary = await listProviderModels(p, discoveryOpts);
529
+ // Both credentials can coexist. API discovery alone omits subscription-only
530
+ // models and never teaches the Codex call gate what this account can serve.
531
+ if (p === "openai" && primary.source === "api_key" && !discoveryOpts.baseUrl && !process.env.OPENAI_BASE_URL) {
532
+ const credential = await resolveCredential("openai");
533
+ if (credential.kind === "oauth") {
534
+ return [primary, await listProviderModels(p, { ...discoveryOpts, preferOAuth: true })];
535
+ }
536
+ }
537
+ return [primary];
538
+ }),
505
539
  );
506
- return useFallback ? results.map(catalogOr) : results;
540
+ const flat = results.flat();
541
+ return useFallback ? flat.map(catalogOr) : flat;
507
542
  }
@@ -16,9 +16,13 @@ export interface PickEntry {
16
16
  /** Flatten successful discovery results into an ordered, 1-based pick list. */
17
17
  export function flattenModels(results: ProviderModelsResult[]): PickEntry[] {
18
18
  const out: PickEntry[] = [];
19
+ const seen = new Set<string>();
19
20
  for (const r of results) {
20
21
  if (!r.ok) continue;
21
22
  for (const model of r.models) {
23
+ const key = `${r.provider}\u0000${model}`;
24
+ if (seen.has(key)) continue;
25
+ seen.add(key);
22
26
  out.push({ index: out.length + 1, provider: r.provider, model });
23
27
  }
24
28
  }
@@ -55,7 +55,7 @@ import { callLlm, type Message } from "../agent/loop";
55
55
  import { friendlyProviderError } from "../util/provider-error";
56
56
  import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
57
57
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
58
- import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
58
+ import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, liveProviderCatalogModels, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
59
59
  import { rehydrateLiveModels, writeModelCache } from "../ai/model-cache";
60
60
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
61
61
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
@@ -335,8 +335,8 @@ function providerDefaultModel(p: ProviderName): string {
335
335
  }
336
336
 
337
337
  /**
338
- * Pick-list entries for ONE provider, with static fallbacks so the list is never
339
- * empty.
338
+ * Pick-list entries for ONE provider. OpenAI availability comes only from live
339
+ * results; other providers retain their offline catalog fallbacks.
340
340
  *
341
341
  * Live discovery yields ids only for a logged-in, reachable provider, and
342
342
  * `catalogOr` backfills the static catalog for OAuth sources and for API-key
@@ -348,6 +348,9 @@ function providerDefaultModel(p: ProviderName): string {
348
348
  export function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
349
349
  const fromLive = flattenModels(live.filter(r => r.provider === want));
350
350
  if (fromLive.length) return fromLive;
351
+ // An OpenAI empty/error response is authoritative for this picker. Offering
352
+ // the static capability catalog here would fabricate account availability.
353
+ if (want === "openai") return [];
351
354
  const catalog = catalogByProvider(want);
352
355
  if (catalog.length) {
353
356
  return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
@@ -1974,239 +1977,250 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1974
1977
  };
1975
1978
 
1976
1979
  if (isOneShot) {
1977
- let messageContent = joinedArgs;
1978
- if (!process.stdin.isTTY && joinedArgs.length === 0) {
1979
- messageContent = (await Bun.stdin.text()).trim();
1980
- }
1981
- if (!messageContent) {
1982
- console.log("No input provided.");
1983
- return;
1984
- }
1985
- // One-shot piped/`-p` input that is a control slash command must be handled
1986
- // HERE — never forwarded to the model. `echo "/clear" | jeo` (and Ctrl-D after
1987
- // a piped command) previously sent the literal "/clear" to the LLM as a prompt:
1988
- // a slow, flaky, and semantically wrong round-trip. These no-arg session/system
1989
- // commands have a meaningful one-shot effect (or are simple no-ops) and exit.
1990
- {
1991
- const cmd = messageContent.trim();
1992
- if (cmd === "/exit" || cmd === "/quit") {
1993
- return;
1994
- }
1995
- if (cmd === "/clear" || cmd === "/session new" || cmd === "/session drop" || cmd === "/session delete") {
1996
- // Reset history to just the system prompt and overwrite the session file so
1997
- // the persisted transcript matches (a fresh session for /session new and /session drop).
1998
- history.length = 1;
1999
- if (sessionId && !flags.noSession) {
2000
- try {
2001
- sessionId = (await createSession(cwd, undefined, sessionModel)).id;
2002
- } catch { /* best-effort: in-memory clear already done */ }
2003
- }
2004
- console.log("(history cleared)");
2005
- return;
2006
- }
2007
- if (cmd === "/" || cmd === "/?" || cmd === "/help") {
2008
- for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
2009
- console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
2010
- return;
2011
- }
2012
- if (cmd === "/usage") {
2013
- const cfg = await readGlobalConfig();
2014
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2015
- const result = handleUsage(ctx, sessionUsage);
2016
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2017
- return;
2018
- }
2019
- if (cmd === "/context") {
2020
- const cfg = await readGlobalConfig();
2021
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2022
- const result = await handleContext(ctx);
2023
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2024
- return;
2025
- }
2026
- if (cmd === "/tools") {
2027
- const cfg = await readGlobalConfig();
2028
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2029
- const result = await handleTools(ctx);
2030
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2031
- return;
2032
- }
2033
- if (cmd === "/hotkeys") {
2034
- const cfg = await readGlobalConfig();
2035
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2036
- const result = handleHotkeys(ctx);
2037
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2038
- return;
1980
+ // A one-shot run has ~17 exit points below. The session notify endpoint (a
1981
+ // listening socket + a discovery file the Telegram daemon polls) is created for
1982
+ // the WHOLE launch lifetime up at `startSessionNotifyEndpoint`, but its teardown
1983
+ // lives on the interactive REPL's exit path — which none of those 17 returns
1984
+ // reach. The endpoint's socket/timer are unref'd so a leak can no longer HANG the
1985
+ // process, but a one-shot run would still leave a stale discovery file behind for
1986
+ // the daemon to keep dialling. One `finally` covers every exit point.
1987
+ try {
1988
+ let messageContent = joinedArgs;
1989
+ if (!process.stdin.isTTY && joinedArgs.length === 0) {
1990
+ messageContent = (await Bun.stdin.text()).trim();
2039
1991
  }
2040
- if (cmd === "/handoff" || cmd.startsWith("/handoff ")) {
2041
- const parsed = parseHandoffCommand(cmd) ?? {};
2042
- const cfg = await readGlobalConfig();
2043
- const activeModel = sessionModel || defaultModel;
2044
- // `Config` (state.ts) predates config-schema.ts's `compaction` field and is
2045
- // out of this change's scope — read the passthrough-preserved value via a
2046
- // narrow structural cast instead of widening the shared interface.
2047
- const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
2048
- const res = await buildHandoffDocument(history, { model: activeModel, focus });
2049
- for (const line of handoffLines(res, process.stdout.columns)) console.log(line);
1992
+ if (!messageContent) {
1993
+ console.log("No input provided.");
2050
1994
  return;
2051
1995
  }
2052
- if (cmd === "/theme" || cmd.startsWith("/theme ")) {
2053
- const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
2054
- for (const line of decision.lines) console.log(line);
2055
- if (decision.kind === "set") {
2056
- process.env.JEO_TUI_THEME = decision.themeName!;
2057
- await saveConfigPatch(raw => ({ theme: decision.themeName }));
2058
- // No refreshUiTheme() here — one-shot mode returns immediately after this
2059
- // block, so there is no live TUI paint state to re-resolve (and
2060
- // refreshUiTheme is declared later in this closure, out of scope here).
1996
+ // One-shot piped/`-p` input that is a control slash command must be handled
1997
+ // HERE — never forwarded to the model. `echo "/clear" | jeo` (and Ctrl-D after
1998
+ // a piped command) previously sent the literal "/clear" to the LLM as a prompt:
1999
+ // a slow, flaky, and semantically wrong round-trip. These no-arg session/system
2000
+ // commands have a meaningful one-shot effect (or are simple no-ops) and exit.
2001
+ {
2002
+ const cmd = messageContent.trim();
2003
+ if (cmd === "/exit" || cmd === "/quit") {
2004
+ return;
2061
2005
  }
2062
- return;
2063
- }
2064
- if (cmd === "/wiki" || cmd.startsWith("/wiki ")) {
2065
- const decision = decideWikiSlash(
2066
- cmd,
2067
- resolveWikiRoot(await readGlobalConfig()),
2068
- !!jeoEnv("WIKI_ROOT"),
2069
- );
2070
- for (const line of decision.lines) console.log(line);
2071
- if (decision.kind === "clear") {
2072
- await saveConfigPatch(() => ({ wikiRoot: undefined }));
2073
- delete process.env.JEO_WIKI_ROOT;
2074
- sessionWikiRoot = undefined;
2075
- await refreshSessionMemory("");
2076
- } else if (decision.kind === "set") {
2077
- await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
2078
- process.env.JEO_WIKI_ROOT = decision.root;
2079
- sessionWikiRoot = decision.root;
2080
- await refreshSessionMemory("");
2006
+ if (cmd === "/clear" || cmd === "/session new" || cmd === "/session drop" || cmd === "/session delete") {
2007
+ // Reset history to just the system prompt and overwrite the session file so
2008
+ // the persisted transcript matches (a fresh session for /session new and /session drop).
2009
+ history.length = 1;
2010
+ if (sessionId && !flags.noSession) {
2011
+ try {
2012
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
2013
+ } catch { /* best-effort: in-memory clear already done */ }
2014
+ }
2015
+ console.log("(history cleared)");
2016
+ return;
2081
2017
  }
2082
- return;
2083
- }
2084
- if (cmd === "/config" || cmd === "/settings") {
2085
- for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
2086
- return;
2087
- }
2088
- if (cmd === "/evolve") {
2089
- await runEvolveSimulation(animateAsciiArt);
2090
- return;
2091
- }
2092
- }
2093
- // One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
2094
- // single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
2095
- const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
2096
- const isBundleWorkflow = isWorkflowSkill(inv.skill.name);
2097
- if (isBundleWorkflow) {
2098
- const startMsg: Message = {
2099
- role: "system",
2100
- content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
2101
- };
2102
- history.push(startMsg);
2103
- if (sessionId) {
2104
- await appendMessage(sessionId, startMsg, cwd);
2018
+ if (cmd === "/" || cmd === "/?" || cmd === "/help") {
2019
+ for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
2020
+ console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
2021
+ return;
2105
2022
  }
2023
+ if (cmd === "/usage") {
2024
+ const cfg = await readGlobalConfig();
2025
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2026
+ const result = handleUsage(ctx, sessionUsage);
2027
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2028
+ return;
2029
+ }
2030
+ if (cmd === "/context") {
2031
+ const cfg = await readGlobalConfig();
2032
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2033
+ const result = await handleContext(ctx);
2034
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2035
+ return;
2036
+ }
2037
+ if (cmd === "/tools") {
2038
+ const cfg = await readGlobalConfig();
2039
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2040
+ const result = await handleTools(ctx);
2041
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2042
+ return;
2043
+ }
2044
+ if (cmd === "/hotkeys") {
2045
+ const cfg = await readGlobalConfig();
2046
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2047
+ const result = handleHotkeys(ctx);
2048
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2049
+ return;
2050
+ }
2051
+ if (cmd === "/handoff" || cmd.startsWith("/handoff ")) {
2052
+ const parsed = parseHandoffCommand(cmd) ?? {};
2053
+ const cfg = await readGlobalConfig();
2054
+ const activeModel = sessionModel || defaultModel;
2055
+ // `Config` (state.ts) predates config-schema.ts's `compaction` field and is
2056
+ // out of this change's scope — read the passthrough-preserved value via a
2057
+ // narrow structural cast instead of widening the shared interface.
2058
+ const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
2059
+ const res = await buildHandoffDocument(history, { model: activeModel, focus });
2060
+ for (const line of handoffLines(res, process.stdout.columns)) console.log(line);
2061
+ return;
2062
+ }
2063
+ if (cmd === "/theme" || cmd.startsWith("/theme ")) {
2064
+ const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
2065
+ for (const line of decision.lines) console.log(line);
2066
+ if (decision.kind === "set") {
2067
+ process.env.JEO_TUI_THEME = decision.themeName!;
2068
+ await saveConfigPatch(raw => ({ theme: decision.themeName }));
2069
+ // No refreshUiTheme() here — one-shot mode returns immediately after this
2070
+ // block, so there is no live TUI paint state to re-resolve (and
2071
+ // refreshUiTheme is declared later in this closure, out of scope here).
2072
+ }
2073
+ return;
2074
+ }
2075
+ if (cmd === "/wiki" || cmd.startsWith("/wiki ")) {
2076
+ const decision = decideWikiSlash(
2077
+ cmd,
2078
+ resolveWikiRoot(await readGlobalConfig()),
2079
+ !!jeoEnv("WIKI_ROOT"),
2080
+ );
2081
+ for (const line of decision.lines) console.log(line);
2082
+ if (decision.kind === "clear") {
2083
+ await saveConfigPatch(() => ({ wikiRoot: undefined }));
2084
+ delete process.env.JEO_WIKI_ROOT;
2085
+ sessionWikiRoot = undefined;
2086
+ await refreshSessionMemory("");
2087
+ } else if (decision.kind === "set") {
2088
+ await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
2089
+ process.env.JEO_WIKI_ROOT = decision.root;
2090
+ sessionWikiRoot = decision.root;
2091
+ await refreshSessionMemory("");
2092
+ }
2093
+ return;
2094
+ }
2095
+ if (cmd === "/config" || cmd === "/settings") {
2096
+ for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
2097
+ return;
2098
+ }
2099
+ if (cmd === "/evolve") {
2100
+ await runEvolveSimulation(animateAsciiArt);
2101
+ return;
2102
+ }
2103
+ }
2104
+ // One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
2105
+ // single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
2106
+ const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
2107
+ const isBundleWorkflow = isWorkflowSkill(inv.skill.name);
2108
+ if (isBundleWorkflow) {
2109
+ const startMsg: Message = {
2110
+ role: "system",
2111
+ content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
2112
+ };
2113
+ history.push(startMsg);
2114
+ if (sessionId) {
2115
+ await appendMessage(sessionId, startMsg, cwd);
2116
+ }
2106
2117
 
2107
- const harness = createInFlightAbortHarness({
2108
- captureEsc: false,
2109
- onAbortNotice: msg => console.log(msg),
2110
- onHardExit: () => forceExitFromCtrlC(),
2111
- });
2112
- const ac = harness.controller;
2118
+ const harness = createInFlightAbortHarness({
2119
+ captureEsc: false,
2120
+ onAbortNotice: msg => console.log(msg),
2121
+ onHardExit: () => forceExitFromCtrlC(),
2122
+ });
2123
+ const ac = harness.controller;
2113
2124
 
2114
- const opts = {
2115
- cwd,
2116
- signal: ac.signal,
2117
- onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
2118
- io: {
2119
- output: (line: string) => {
2120
- console.log(line);
2125
+ const opts = {
2126
+ cwd,
2127
+ signal: ac.signal,
2128
+ onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
2129
+ io: {
2130
+ output: (line: string) => {
2131
+ console.log(line);
2132
+ },
2133
+ // This `$a $b …` one-shot chain runs BEFORE the interactive REPL's
2134
+ // `promptInput`/`rl` exist (this whole dispatch is reached and returns
2135
+ // long before the `const promptInput = …` further down the function
2136
+ // executes) — referencing `promptInput` here throws a TDZ
2137
+ // ReferenceError ("Cannot access 'promptInput' before initialization")
2138
+ // the instant the workflow engine asks its first question. That error
2139
+ // is caught and stashed into session history (never printed to the
2140
+ // terminal), so the visible symptom is just a frozen prompt forever
2141
+ // (the "jeo --tmux $deep-interview hangs" / "$deep-interview hangs"
2142
+ // report). Omitting `input` here is safe and correct: no other
2143
+ // readline exists yet at this point, so deep-interview.ts's own
2144
+ // fallback `createInterface({input: process.stdin, ...})` (used
2145
+ // whenever `opts.io?.input` is absent) has nothing to fight over raw
2146
+ // mode with. The INTERACTIVE `runSkillInvocation` sibling path below
2147
+ // runs AFTER `promptInput` is declared and correctly wires it — do not
2148
+ // "fix" this block by copying that wiring here again.
2121
2149
  },
2122
- // This `$a $b …` one-shot chain runs BEFORE the interactive REPL's
2123
- // `promptInput`/`rl` exist (this whole dispatch is reached and returns
2124
- // long before the `const promptInput = …` further down the function
2125
- // executes) — referencing `promptInput` here throws a TDZ
2126
- // ReferenceError ("Cannot access 'promptInput' before initialization")
2127
- // the instant the workflow engine asks its first question. That error
2128
- // is caught and stashed into session history (never printed to the
2129
- // terminal), so the visible symptom is just a frozen prompt forever
2130
- // (the "jeo --tmux $deep-interview hangs" / "$deep-interview hangs"
2131
- // report). Omitting `input` here is safe and correct: no other
2132
- // readline exists yet at this point, so deep-interview.ts's own
2133
- // fallback `createInterface({input: process.stdin, ...})` (used
2134
- // whenever `opts.io?.input` is absent) has nothing to fight over raw
2135
- // mode with. The INTERACTIVE `runSkillInvocation` sibling path below
2136
- // runs AFTER `promptInput` is declared and correctly wires it — do not
2137
- // "fix" this block by copying that wiring here again.
2138
- },
2139
2150
 
2140
- args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
2151
+ args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
2141
2152
 
2142
- };
2153
+ };
2143
2154
 
2144
- let ok = false;
2145
- let reason: string | undefined;
2146
- try {
2147
- const res: { ok: boolean; reason?: string } = await runWorkflowEngine(inv.skill.name, opts);
2148
- ok = res.ok;
2149
- reason = res.reason;
2150
- } catch (err: any) {
2151
- ok = false;
2152
- reason = err.message;
2153
- } finally {
2154
- harness.dispose();
2155
- }
2155
+ let ok = false;
2156
+ let reason: string | undefined;
2157
+ try {
2158
+ const res: { ok: boolean; reason?: string } = await runWorkflowEngine(inv.skill.name, opts);
2159
+ ok = res.ok;
2160
+ reason = res.reason;
2161
+ } catch (err: any) {
2162
+ ok = false;
2163
+ reason = err.message;
2164
+ } finally {
2165
+ harness.dispose();
2166
+ }
2156
2167
 
2157
- const endMsg: Message = {
2158
- role: "system",
2159
- content: ok
2160
- ? `[workflow:${inv.skill.name}:finish]`
2161
- : `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
2162
- };
2163
- if (sessionId) {
2164
- await appendMessage(sessionId, endMsg, cwd);
2168
+ const endMsg: Message = {
2169
+ role: "system",
2170
+ content: ok
2171
+ ? `[workflow:${inv.skill.name}:finish]`
2172
+ : `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
2173
+ };
2174
+ if (sessionId) {
2175
+ await appendMessage(sessionId, endMsg, cwd);
2176
+ }
2177
+ return;
2165
2178
  }
2166
- return;
2167
- }
2168
2179
 
2169
- const useOneShotTui = shouldUseOneShotTui(flags.noTui);
2170
- if (!useOneShotTui) {
2171
- console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
2172
- }
2173
- const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
2174
- const { reply, rendered, usage } = await runTurn(task, useOneShotTui, undefined, { userCard: null });
2175
- if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2176
- else if (usage) console.log(usage.trim());
2177
- };
2180
+ const useOneShotTui = shouldUseOneShotTui(flags.noTui);
2181
+ if (!useOneShotTui) {
2182
+ console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
2183
+ }
2184
+ const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
2185
+ const { reply, rendered, usage } = await runTurn(task, useOneShotTui, undefined, { userCard: null });
2186
+ if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2187
+ else if (usage) console.log(usage.trim());
2188
+ };
2178
2189
 
2179
- // `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
2180
- const skillChain = parseSkillChain(messageContent, resolvedSkills);
2181
- if (skillChain && skillChain.invocations.length) {
2182
- if (skillChain.unresolved.length) {
2183
- console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
2190
+ // `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
2191
+ const skillChain = parseSkillChain(messageContent, resolvedSkills);
2192
+ if (skillChain && skillChain.invocations.length) {
2193
+ if (skillChain.unresolved.length) {
2194
+ console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
2195
+ }
2196
+ if (skillChain.invocations.length > 1) {
2197
+ console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
2198
+ }
2199
+ for (const inv of skillChain.invocations) {
2200
+ await runOneSkillShot(inv);
2201
+ }
2202
+ await maybeDistillOneShot();
2203
+ return;
2184
2204
  }
2185
- if (skillChain.invocations.length > 1) {
2186
- console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
2205
+ // A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
2206
+ const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
2207
+ if (skillInvocation) {
2208
+ await runOneSkillShot(skillInvocation);
2209
+ await maybeDistillOneShot();
2210
+ return;
2187
2211
  }
2188
- for (const inv of skillChain.invocations) {
2189
- await runOneSkillShot(inv);
2212
+ try {
2213
+ const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
2214
+ if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2215
+ else if (usage) console.log(usage.trim());
2216
+ } catch (err) {
2217
+ console.log(`! ${friendlyProviderError(err)}`);
2190
2218
  }
2191
2219
  await maybeDistillOneShot();
2192
2220
  return;
2221
+ } finally {
2222
+ await sessionNotifyEndpoint?.stop().catch(() => {});
2193
2223
  }
2194
- // A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
2195
- const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
2196
- if (skillInvocation) {
2197
- await runOneSkillShot(skillInvocation);
2198
- await maybeDistillOneShot();
2199
- return;
2200
- }
2201
- try {
2202
- const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
2203
- if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2204
- else if (usage) console.log(usage.trim());
2205
- } catch (err) {
2206
- console.log(`! ${friendlyProviderError(err)}`);
2207
- }
2208
- await maybeDistillOneShot();
2209
- return;
2210
2224
  }
2211
2225
 
2212
2226
  // INTERACTIVE mode
@@ -2416,7 +2430,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2416
2430
  // the OAuth Codex gate from the first keystroke, instead of only after the network
2417
2431
  // discovery below happens to land.
2418
2432
  const aliasNames = Object.keys(await listAliases());
2419
- const cachedModels = await rehydrateLiveModels().catch(() => null);
2433
+ await rehydrateLiveModels().catch(() => null);
2434
+ const completionConfig = await readGlobalConfig();
2435
+ const cachedCompletionModels = liveProviderCatalogModels({ openaiBaseUrl: completionConfig.openaiBaseUrl ?? process.env.OPENAI_BASE_URL });
2420
2436
  void getLiveModels()
2421
2437
  .then(r => {
2422
2438
  liveModelsCache ??= r;
@@ -2431,14 +2447,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2431
2447
  // access to were un-completable for the first seconds of every session.
2432
2448
  const liveFromDiscovery = liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : null;
2433
2449
  const cachedFor = (p: string): string[] =>
2434
- cachedModels?.providers.filter(entry => entry.provider === p).flatMap(entry => entry.models) ?? [];
2450
+ cachedCompletionModels.filter(entry => entry.provider === p).map(entry => entry.canonical);
2435
2451
  return {
2436
2452
  ...base,
2437
2453
  slashCommands: [...base.slashCommands, ...skillSlashDetails.map(d => d.command)],
2438
- liveModels: liveFromDiscovery ?? (cachedModels?.providers.flatMap(entry => entry.models) ?? []),
2454
+ liveModels: liveFromDiscovery ?? cachedCompletionModels.map(entry => entry.canonical),
2439
2455
  aliases: aliasNames,
2440
2456
  skillNames: resolvedSkillTokens,
2441
- modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? cachedFor(p),
2457
+ modelsForProvider: p => liveModelsCache
2458
+ ? flattenModels(liveModelsCache.filter(r => r.provider === p)).map(entry => entry.model)
2459
+ : cachedFor(p),
2442
2460
  mentionPaths,
2443
2461
  };
2444
2462
  };