jeo-code 0.9.10 → 0.9.11

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.
@@ -14,18 +14,25 @@ export interface SessionHeader {
14
14
  * its own model independent of the global default (per-session model selection). */
15
15
  model?: string;
16
16
  /** Unsent input-box draft at last save, restored into the prompt on `/resume`
17
- * (gajae-code parity: an in-progress prompt survives a quit + resume instead
17
+ * (gajae-code parity: an in-progress cursor survives a quit + resume instead
18
18
  * of being silently lost). Absent/empty when there was nothing to restore. */
19
19
  draft?: string;
20
-
20
+ /** Source system marker for imported sessions (read-only session importer layer). */
21
+ sourceSystem?: "gjc";
22
+ /** Source session id for imported sessions. */
23
+ sourceSessionId?: string;
24
+ /** Source leaf id for imported sessions. */
25
+ sourceLeafId?: string;
26
+ /** SHA-256 hex of the exact imported source bytes. */
27
+ sourceSha256?: string;
28
+ /** Import timestamp for imported sessions (ISO-8601 UTC string). */
29
+ importTimestamp?: string;
21
30
  }
22
-
23
31
  export interface SessionEntry {
24
32
  type: "message";
25
33
  timestamp: string;
26
34
  message: Message;
27
35
  }
28
-
29
36
  export interface CompactionEntry {
30
37
  type: "compaction";
31
38
  timestamp: string;
@@ -45,6 +52,19 @@ export interface SessionSummary {
45
52
  title?: string;
46
53
  /** Model id pinned to this session (header `model`), if any. */
47
54
  model?: string;
55
+ /** GJC import provenance (present for imported sessions). */
56
+ sourceSystem?: "gjc";
57
+ sourceSessionId?: string;
58
+ sourceLeafId?: string;
59
+ sourceSha256?: string;
60
+ importTimestamp?: string;
61
+ }
62
+
63
+ export interface ImportedSessionLookup {
64
+ sourceSystem: "gjc";
65
+ sourceSessionId: string;
66
+ sourceLeafId: string;
67
+ sourceSha256: string;
48
68
  }
49
69
 
50
70
  export const SESSION_VERSION = 1;
@@ -183,15 +203,15 @@ export async function loadSession(
183
203
  if (compactions.length > 0) {
184
204
  const lastComp = compactions[compactions.length - 1];
185
205
  const { summary, replacesThrough } = lastComp;
186
-
206
+
187
207
  const hasSystem = rawMessages.length > 0 && rawMessages[0].role === "system";
188
208
  const systemPrompt = hasSystem ? [rawMessages[0]] : [];
189
-
209
+
190
210
  const summaryMessage: Message = {
191
211
  role: "user",
192
212
  content: `[Earlier conversation summary]\n${summary}`,
193
213
  };
194
-
214
+
195
215
  const remaining = rawMessages.slice(replacesThrough + 1);
196
216
  messages = [...systemPrompt, summaryMessage, ...remaining];
197
217
  }
@@ -305,6 +325,11 @@ export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[
305
325
  sizeBytes: stat.size,
306
326
  title: header.title,
307
327
  model: header.model,
328
+ sourceSystem: header.sourceSystem,
329
+ sourceSessionId: header.sourceSessionId,
330
+ sourceLeafId: header.sourceLeafId,
331
+ sourceSha256: header.sourceSha256,
332
+ importTimestamp: header.importTimestamp,
308
333
  });
309
334
  } catch {
310
335
  // Tolerate malformed files (skip them)
@@ -348,6 +373,22 @@ export async function resolveSessionRef(idOrPrefix: string, cwd = process.cwd())
348
373
  return { kind: "not-found" };
349
374
  }
350
375
 
376
+ /**
377
+ * Locate an imported session by exact provenance match.
378
+ */
379
+ export async function findImportedSessionByProvenance(
380
+ cwd = process.cwd(),
381
+ lookup: ImportedSessionLookup,
382
+ ): Promise<SessionSummary | undefined> {
383
+ const all = await listSessions(cwd);
384
+ return all.find(s =>
385
+ s.sourceSystem === lookup.sourceSystem &&
386
+ s.sourceSessionId === lookup.sourceSessionId &&
387
+ s.sourceLeafId === lookup.sourceLeafId &&
388
+ s.sourceSha256 === lookup.sourceSha256
389
+ );
390
+ }
391
+
351
392
  /**
352
393
  * Locate the session's JSONL header, apply `mutate`, and rewrite the file in place.
353
394
  * `mutate` returns false to signal "no change needed" (skips the write). Shared by
@@ -18,7 +18,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
18
  import * as os from "node:os";
19
19
  import * as path from "node:path";
20
20
  import { jeoEnv } from "../util/env";
21
- import { recordLiveCodexModels, recordLiveProviderModels } from "./model-catalog";
21
+ import { recordLiveCodexModels, recordLiveProviderModels, setOpenAIOauthAccountScope } from "./model-catalog";
22
+ import { getStoredOAuth } from "../auth/storage";
23
+ import { extractChatgptAccountId } from "./providers/openai-responses";
22
24
  import type { ProviderName } from "./types";
23
25
 
24
26
  /** How a provider's list was obtained; mirrors `ProviderModelsResult["source"]`. */
@@ -30,15 +32,17 @@ export interface CachedProviderModels {
30
32
  source: ModelCacheSource;
31
33
  /** Base URL the list came from, so an OpenAI-compatible endpoint's ids stay scoped to it. */
32
34
  baseUrl?: string;
35
+ /** Stable OpenAI OAuth account scope for this row; required for account-specific rehydration. */
36
+ accountId?: string;
33
37
  }
34
38
 
35
39
  export interface ModelCacheFile {
36
- version: 1;
40
+ version: 2;
37
41
  updatedAt: number;
38
42
  providers: CachedProviderModels[];
39
43
  }
40
44
 
41
- const CACHE_VERSION = 1;
45
+ const CACHE_VERSION = 2;
42
46
  /** Refresh in the background once the cache is older than this (6h). */
43
47
  export const MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
44
48
  /** Bound a single provider's persisted list so a pathological aggregator cannot bloat the file. */
@@ -52,7 +56,6 @@ function cachePath(): string {
52
56
  return path.join(cacheDir(), "model-catalog-cache.json");
53
57
  }
54
58
 
55
- /** Keep only well-formed entries; a hand-edited or partially-written file must not throw. */
56
59
  export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
57
60
  if (!Array.isArray(raw)) return [];
58
61
  const out: CachedProviderModels[] = [];
@@ -61,11 +64,18 @@ export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
61
64
  if (!row || typeof row.provider !== "string" || !Array.isArray(row.models)) continue;
62
65
  const models = row.models.filter((m): m is string => typeof m === "string" && m.trim().length > 0).slice(0, MAX_MODELS_PER_PROVIDER);
63
66
  if (models.length === 0) continue;
67
+ const provider = row.provider as ProviderName;
68
+ const source: ModelCacheSource =
69
+ row.source === "oauth" || row.source === "api_key" || row.source === "keyless" || row.source === "none" ? row.source : "none";
70
+ const baseUrl = typeof row.baseUrl === "string" ? row.baseUrl.replace(/\/+$/, "") : undefined;
71
+ const accountId = typeof row.accountId === "string" ? row.accountId.trim() : "";
72
+ if (provider === "openai" && source === "oauth" && !accountId) continue;
64
73
  out.push({
65
- provider: row.provider as ProviderName,
74
+ provider,
66
75
  models,
67
- source: row.source === "oauth" || row.source === "api_key" || row.source === "keyless" ? row.source : "none",
68
- ...(typeof row.baseUrl === "string" && row.baseUrl ? { baseUrl: row.baseUrl } : {}),
76
+ source,
77
+ ...(baseUrl ? { baseUrl } : {}),
78
+ ...(provider === "openai" && source === "oauth" ? { accountId } : {}),
69
79
  });
70
80
  }
71
81
  return out;
@@ -101,18 +111,29 @@ export function isModelCacheStale(cache: ModelCacheFile | null, now = Date.now()
101
111
  */
102
112
  export function mergeCacheEntries(
103
113
  previous: readonly CachedProviderModels[],
104
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string }[],
114
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
105
115
  ): CachedProviderModels[] {
106
116
  const byKey = new Map<string, CachedProviderModels>();
107
- const keyOf = (provider: string, baseUrl?: string) => `${provider}\u0000${baseUrl ?? ""}`;
108
- for (const entry of previous) byKey.set(keyOf(entry.provider, entry.baseUrl), entry);
117
+ const keyOf = (provider: string, baseUrl?: string, accountId?: string) =>
118
+ `${provider}\u0000${baseUrl ?? ""}\u0000${accountId ?? ""}`;
119
+ for (const entry of previous) {
120
+ const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
121
+ if (isOpenAIOAuth && !entry.accountId) continue;
122
+ byKey.set(keyOf(entry.provider, entry.baseUrl, isOpenAIOAuth ? entry.accountId : undefined), entry);
123
+ }
109
124
  for (const result of results) {
110
125
  if (!result.ok || result.models.length === 0) continue;
111
- byKey.set(keyOf(result.provider, result.baseUrl), {
126
+ const source: ModelCacheSource = result.source ?? "none";
127
+ const isOpenAIOAuth = result.provider === "openai" && source === "oauth";
128
+ const baseUrl = typeof result.baseUrl === "string" ? result.baseUrl.replace(/\/+$/, "") : undefined;
129
+ const accountId = typeof result.accountId === "string" ? result.accountId.trim() : "";
130
+ if (isOpenAIOAuth && !accountId) continue;
131
+ byKey.set(keyOf(result.provider, baseUrl, isOpenAIOAuth ? accountId : undefined), {
112
132
  provider: result.provider,
113
133
  models: [...result.models].slice(0, MAX_MODELS_PER_PROVIDER),
114
- source: result.source ?? "none",
115
- ...(result.baseUrl ? { baseUrl: result.baseUrl } : {}),
134
+ source,
135
+ ...(baseUrl ? { baseUrl } : {}),
136
+ ...(isOpenAIOAuth ? { accountId } : {}),
116
137
  });
117
138
  }
118
139
  return [...byKey.values()];
@@ -120,7 +141,7 @@ export function mergeCacheEntries(
120
141
 
121
142
  /** Persist discovery results (best-effort; never throws, never blocks a turn). */
122
143
  export async function writeModelCache(
123
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string }[],
144
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
124
145
  ): Promise<void> {
125
146
  // Same hermeticity rule as saveGlobalConfig: a test run may only write into an
126
147
  // explicitly sandboxed JEO_CONFIG_DIR, never the developer's real ~/.jeo.
@@ -142,14 +163,21 @@ export async function writeModelCache(
142
163
  * routing, and — critically — the OAuth Codex gate know the account's real models
143
164
  * BEFORE the first network call of this launch. Returns the number of ids applied.
144
165
  */
145
- export function applyCachedModels(cache: ModelCacheFile | null): number {
166
+ export function applyCachedModels(cache: ModelCacheFile | null, openAIOauthAccountId?: string): number {
146
167
  if (!cache) return 0;
168
+ const accountId = typeof openAIOauthAccountId === "string" ? openAIOauthAccountId.trim() : "";
147
169
  let applied = 0;
148
170
  for (const entry of cache.providers) {
149
- recordLiveProviderModels(entry.provider, entry.models, { source: entry.source, baseUrl: entry.baseUrl });
150
- // An OAuth-sourced OpenAI list IS the Codex allow-list; without this the gate
151
- // still rejects any model newer than the maintained static snapshot.
152
- if (entry.provider === "openai" && entry.source === "oauth") recordLiveCodexModels(entry.models);
171
+ const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
172
+ if (isOpenAIOAuth && (!accountId || entry.accountId !== accountId)) continue;
173
+ recordLiveProviderModels(entry.provider, entry.models, {
174
+ source: entry.source,
175
+ baseUrl: entry.baseUrl,
176
+ ...(isOpenAIOAuth && entry.accountId ? { accountId: entry.accountId } : {}),
177
+ });
178
+ if (isOpenAIOAuth && entry.accountId) {
179
+ recordLiveCodexModels(entry.models, entry.accountId);
180
+ }
153
181
  applied += entry.models.length;
154
182
  }
155
183
  return applied;
@@ -158,6 +186,15 @@ export function applyCachedModels(cache: ModelCacheFile | null): number {
158
186
  /** Read + apply in one step. Returns the cache so callers can decide about refreshing. */
159
187
  export async function rehydrateLiveModels(): Promise<ModelCacheFile | null> {
160
188
  const cache = await readModelCache();
161
- applyCachedModels(cache);
189
+ let accountId: string | undefined;
190
+ try {
191
+ const stored = await getStoredOAuth("openai");
192
+ const jwtAccountId = stored ? extractChatgptAccountId(stored.access) : undefined;
193
+ accountId = jwtAccountId || stored?.accountId;
194
+ } catch {
195
+ accountId = undefined;
196
+ }
197
+ setOpenAIOauthAccountScope(accountId);
198
+ applyCachedModels(cache, accountId);
162
199
  return cache;
163
200
  }
@@ -186,42 +186,75 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
186
186
  export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
187
187
 
188
188
  /**
189
- * Session-lifetime cache of OpenAI Codex model ids OBSERVED live from the account's
190
- * own `codex/models` response (see `listProviderModels` in model-discovery.ts, which
191
- * calls `recordLiveCodexModels` on every successful OAuth discovery). `CODEX_MODELS`
192
- * above is a maintained snapshot that WILL drift the moment OpenAI ships a new Codex
193
- * model — that drift is exactly the bug class this closes: the picker shows a model
194
- * (from the SAME live endpoint) that the static gate then hard-rejects at call time
195
- * ("OAuth doesn't support this model") even though the account can serve it. Any
196
- * successful discovery this session widens the gate immediately, no release needed;
197
- * a fresh process starts empty and falls back to the static list until the first
198
- * discovery call (picker open, `jeo doctor`, or model resolution) populates it.
189
+ * Session-lifetime Codex model observations. The maintained `CODEX_MODELS` list
190
+ * above is deliberately static; live-only ids are accepted only after the
191
+ * current OAuth account's Codex Responses model list confirms them.
192
+ *
193
+ * An identity-less observation uses the ephemeral scope and is never persisted.
194
+ * Account changes evict prior OAuth observations so a model from one account
195
+ * cannot authorize a call for another account.
199
196
  */
200
- const liveCodexModels = new Set<string>();
197
+ const EPHEMERAL_OPENAI_OAUTH_SCOPE = "__jeo-openai-oauth-ephemeral__";
198
+ let activeOpenAIOauthScope = EPHEMERAL_OPENAI_OAUTH_SCOPE;
199
+ const liveCodexModels = new Map<string, Set<string>>();
200
+
201
+ function normalizeOpenAIOauthScope(scope: string | undefined): string {
202
+ return typeof scope === "string" ? scope.trim() : "";
203
+ }
204
+
205
+ function oauthScope(scope: string | undefined): string {
206
+ return normalizeOpenAIOauthScope(scope) || EPHEMERAL_OPENAI_OAUTH_SCOPE;
207
+ }
201
208
 
202
- /** Record model ids OpenAI's live Codex endpoint returned for the current account
203
- * this session. Additive only — never removes a previously-observed id (a transient
204
- * discovery hiccup should never narrow what a call is allowed to reach). */
205
- export function recordLiveCodexModels(ids: readonly string[]): void {
206
- for (const id of ids) liveCodexModels.add(id);
209
+ function removeLiveProviderRecord(recordKey: string, record: LiveProviderModelRecord): void {
210
+ liveProviderModels.delete(recordKey);
211
+ for (const id of [record.row.canonical, record.row.providerModel]) {
212
+ const indexKey = liveIndexKey(record.row.provider, id);
213
+ const bucket = liveModelIdIndex.get(indexKey);
214
+ if (!bucket) continue;
215
+ bucket.delete(recordKey);
216
+ if (bucket.size === 0) liveModelIdIndex.delete(indexKey);
217
+ }
207
218
  }
208
219
 
209
- /** True when `model` is a Codex-servable id — either in the maintained static list,
210
- * or observed live this session (see `recordLiveCodexModels`). Accepts a bare or
211
- * `openai/`-qualified id. */
220
+ /** Select the current OAuth account and evict every other account's live rows. */
221
+ export function setOpenAIOauthAccountScope(accountId: string | undefined): void {
222
+ const nextScope = oauthScope(accountId);
223
+ activeOpenAIOauthScope = nextScope;
224
+ for (const scope of [...liveCodexModels.keys()]) {
225
+ if (scope !== nextScope) liveCodexModels.delete(scope);
226
+ }
227
+ for (const [recordKey, record] of liveProviderModels.entries()) {
228
+ if (record.row.provider === "openai" && record.source === "oauth" && oauthScope(record.accountId) !== nextScope) {
229
+ removeLiveProviderRecord(recordKey, record);
230
+ }
231
+ }
232
+ }
233
+
234
+ /** Record ids confirmed by the current account's Codex Responses endpoint. */
235
+ export function recordLiveCodexModels(ids: readonly string[], accountId?: string): void {
236
+ const explicitScope = normalizeOpenAIOauthScope(accountId);
237
+ if (explicitScope) setOpenAIOauthAccountScope(explicitScope);
238
+ const scope = explicitScope || EPHEMERAL_OPENAI_OAUTH_SCOPE;
239
+ const bucket = liveCodexModels.get(scope) ?? new Set<string>();
240
+ for (const id of ids) {
241
+ const trimmed = id.trim();
242
+ if (trimmed) bucket.add(trimmed);
243
+ }
244
+ liveCodexModels.set(scope, bucket);
245
+ }
246
+
247
+ /** True for the static Codex snapshot or a live id confirmed for the active scope. */
212
248
  export function isCodexModel(model: string): boolean {
213
249
  const wire = model.startsWith("openai/") ? model.slice(7) : model;
214
- return CODEX_MODELS.includes(wire) || liveCodexModels.has(wire);
250
+ return CODEX_MODELS.includes(wire) || (liveCodexModels.get(activeOpenAIOauthScope)?.has(wire) ?? false);
215
251
  }
216
252
 
217
- /** Test-only: clear the live-observed Codex model cache (mirrors `resetPromptRouterWarnings`
218
- * / `resetAppearanceCache`). Bun runs test files in one process, so this module-level Set
219
- * would otherwise leak an `oauth`-source `listProviderModels("openai", …)` call's observed
220
- * ids into unrelated tests in the same run. */
253
+ /** Test-only reset for live Codex observations and the active account scope. */
221
254
  export function resetLiveCodexModels(): void {
222
255
  liveCodexModels.clear();
256
+ activeOpenAIOauthScope = EPHEMERAL_OPENAI_OAUTH_SCOPE;
223
257
  }
224
-
225
258
  /** Live provider model ids observed from authenticated/keyless `/models` endpoints during
226
259
  * this process. Unlike `liveCodexModels`, this is NOT an OAuth allow-list; it is a
227
260
  * routing supplement so API-key and OpenAI-compatible catalogs discovered at runtime
@@ -231,6 +264,7 @@ interface LiveProviderModelRecord {
231
264
  row: CatalogModel;
232
265
  source: LiveProviderModelSource;
233
266
  baseUrl?: string;
267
+ accountId?: string;
234
268
  }
235
269
  const liveProviderModels = new Map<string, LiveProviderModelRecord>();
236
270
 
@@ -291,8 +325,14 @@ function liveCanonicalId(provider: ProviderName, id: string): string {
291
325
  : trimmed;
292
326
  }
293
327
 
294
- function liveModelRecordKey(provider: ProviderName, canonical: string, baseUrl: string | undefined): string {
295
- return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${canonical}`;
328
+ function liveModelRecordKey(
329
+ provider: ProviderName,
330
+ canonical: string,
331
+ baseUrl: string | undefined,
332
+ accountId?: string,
333
+ ): string {
334
+ const normalizedAccountId = provider === "openai" ? normalizeOpenAIOauthScope(accountId) : "";
335
+ return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${normalizedAccountId}\u0000${canonical}`;
296
336
  }
297
337
 
298
338
  function liveModelFallbackRow(provider: ProviderName, canonical: string, providerModel: string): CatalogModel {
@@ -316,9 +356,16 @@ function liveModelFallbackRow(provider: ProviderName, canonical: string, provide
316
356
  export function recordLiveProviderModels(
317
357
  provider: ProviderName,
318
358
  ids: readonly string[],
319
- opts: { source?: LiveProviderModelSource; baseUrl?: string } = {},
359
+ opts: { source?: LiveProviderModelSource; baseUrl?: string; accountId?: string } = {},
320
360
  ): void {
361
+ const source = opts.source ?? "none";
321
362
  const baseUrl = normalizeBaseUrl(opts.baseUrl);
363
+ const scopedAccountId = provider === "openai" && source === "oauth"
364
+ ? normalizeOpenAIOauthScope(opts.accountId) || undefined
365
+ : undefined;
366
+ if (provider === "openai" && source === "oauth" && scopedAccountId) {
367
+ setOpenAIOauthAccountScope(scopedAccountId);
368
+ }
322
369
  for (const id of ids) {
323
370
  const canonical = liveCanonicalId(provider, id);
324
371
  if (!canonical) continue;
@@ -327,26 +374,34 @@ export function recordLiveProviderModels(
327
374
  const row = known
328
375
  ? { ...known, canonical, provider, providerModel }
329
376
  : liveModelFallbackRow(provider, canonical, providerModel);
330
- const recordKey = liveModelRecordKey(provider, canonical, baseUrl);
377
+ const recordKey = liveModelRecordKey(provider, canonical, baseUrl, scopedAccountId);
331
378
  liveProviderModels.set(recordKey, {
332
379
  row,
333
- source: opts.source ?? "none",
380
+ source,
334
381
  baseUrl,
382
+ ...(scopedAccountId ? { accountId: scopedAccountId } : {}),
335
383
  });
336
384
  addToLiveIndex(provider, canonical, recordKey);
337
385
  addToLiveIndex(provider, providerModel, recordKey);
338
386
  }
339
387
  }
340
388
 
341
- function liveRecordMatchesConfig(record: LiveProviderModelRecord, config?: { openaiBaseUrl?: string }): boolean {
389
+ function liveRecordMatchesConfig(
390
+ record: LiveProviderModelRecord,
391
+ config?: { openaiBaseUrl?: string; openaiOauthScope?: string },
392
+ ): boolean {
342
393
  if (record.row.provider !== "openai") return true;
343
394
  const wanted = normalizeBaseUrl(config?.openaiBaseUrl);
344
- if (wanted || record.baseUrl) return wanted === record.baseUrl;
345
- return true;
395
+ if (wanted || record.baseUrl) {
396
+ if (wanted !== record.baseUrl) return false;
397
+ }
398
+ if (record.source !== "oauth") return true;
399
+ const wantedScope = oauthScope(config?.openaiOauthScope ?? activeOpenAIOauthScope);
400
+ return oauthScope(record.accountId) === wantedScope;
346
401
  }
347
402
 
348
403
  /** Live-discovered catalog rows usable for routing under the current config. */
349
- export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }): CatalogModel[] {
404
+ export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string; openaiOauthScope?: string }): CatalogModel[] {
350
405
  return [...liveProviderModels.values()]
351
406
  .filter(record => liveRecordMatchesConfig(record, config))
352
407
  .map(record => record.row);
@@ -363,7 +418,9 @@ export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }):
363
418
  * large), so a linear scan is cheap — same access pattern as `liveProviderCatalogModels`. */
364
419
  export function findLiveCatalogModel(canonical: string): CatalogModel | undefined {
365
420
  for (const record of liveProviderModels.values()) {
366
- if (record.row.canonical === canonical) return record.row;
421
+ if (record.row.canonical !== canonical) continue;
422
+ if (record.row.provider === "openai" && record.source === "oauth" && !liveRecordMatchesConfig(record)) continue;
423
+ return record.row;
367
424
  }
368
425
  return undefined;
369
426
  }
@@ -372,7 +429,7 @@ export function findLiveCatalogModel(canonical: string): CatalogModel | undefine
372
429
  * O(1) average case via `liveModelIdIndex` — see that Map's doc comment for why
373
430
  * this must not be an O(n) scan over every live-discovered model (it runs once
374
431
  * per STATIC catalog row inside `isAutoSelectCandidate`). */
375
- export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string }): boolean {
432
+ export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string; openaiOauthScope?: string }): boolean {
376
433
  const candidates = new Set([model, liveCanonicalId(provider, model), stripProviderPrefix(provider, model)]);
377
434
  for (const candidate of candidates) {
378
435
  const recordKeys = liveModelIdIndex.get(liveIndexKey(provider, candidate));
@@ -11,8 +11,16 @@ import { readGlobalConfig, type Config } from "../agent/state";
11
11
  import { resolveCredential, type AuthProvider, type Credential } from "../auth";
12
12
  import type { ProviderName } from "./types";
13
13
  import { PROVIDER_NAMES } from "./provider-status";
14
- import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS, recordLiveCodexModels, recordLiveProviderModels } from "./model-catalog";
14
+ import {
15
+ catalogByProvider,
16
+ CODEX_MODELS,
17
+ KIMI_CODE_MODELS,
18
+ recordLiveCodexModels,
19
+ recordLiveProviderModels,
20
+ setOpenAIOauthAccountScope,
21
+ } from "./model-catalog";
15
22
  import { extractChatgptAccountId } from "./providers/openai-responses";
23
+ import { getStoredOAuth } from "../auth/storage";
16
24
  import { openaiCompatDef } from "./providers/openai-compatible-catalog";
17
25
 
18
26
  export interface ProviderModelsResult {
@@ -22,6 +30,8 @@ export interface ProviderModelsResult {
22
30
  ok: boolean;
23
31
  /** How the request authenticated (for display). */
24
32
  source: "oauth" | "api_key" | "keyless" | "none";
33
+ /** Stable OpenAI OAuth account scope for account-specific cache rows. */
34
+ accountId?: string;
25
35
  /** Present on failure: a short, human-readable reason. */
26
36
  error?: string;
27
37
  /** True when the live endpoint was unusable and ids came from the static catalog. */
@@ -334,6 +344,7 @@ export async function listProviderModels(
334
344
  const limit = opts.limit ?? DEFAULT_LIMIT;
335
345
 
336
346
  let cred: Credential | undefined;
347
+ let oauthAccountId: string | undefined;
337
348
  let source: ProviderModelsResult["source"] = "keyless";
338
349
  if (provider === "xai") {
339
350
  // xAI (Grok) is API-key only and not an OAuth AuthProvider: resolve its key
@@ -376,6 +387,12 @@ export async function listProviderModels(
376
387
  cred = { kind: "api_key", provider: prov, token: config.providers[prov]! };
377
388
  source = "api_key";
378
389
  }
390
+ if (provider === "openai" && source === "oauth" && cred.kind === "oauth") {
391
+ const stored = await getStoredOAuth("openai").catch(() => undefined);
392
+ const jwtAccountId = extractChatgptAccountId(cred.token);
393
+ oauthAccountId = jwtAccountId || stored?.accountId;
394
+ setOpenAIOauthAccountScope(oauthAccountId);
395
+ }
379
396
  // Gemini models are exposed ONLY under API-key auth: a gemini OAuth token can
380
397
  // still list models from generativelanguage.googleapis.com, but it can no
381
398
  // longer SERVE them (the gemini-cli/Cloud Code Assist masquerade was removed
@@ -419,9 +436,19 @@ export async function listProviderModels(
419
436
  // Record live ids for routing supplements. For OpenAI OAuth, keep the
420
437
  // existing Codex allow-list widening separate: API-key/custom-base discovery
421
438
  // must not make OAuth Codex calls accept unrelated API models.
422
- recordLiveProviderModels(provider, models, { source, baseUrl: opts.baseUrl });
423
- if (provider === "openai" && source === "oauth") recordLiveCodexModels(models);
424
- return { provider, models, ok: true, source };
439
+ recordLiveProviderModels(provider, models, {
440
+ source,
441
+ baseUrl: opts.baseUrl,
442
+ ...(provider === "openai" && source === "oauth" && oauthAccountId ? { accountId: oauthAccountId } : {}),
443
+ });
444
+ if (provider === "openai" && source === "oauth") recordLiveCodexModels(models, oauthAccountId);
445
+ return {
446
+ provider,
447
+ models,
448
+ ok: true,
449
+ source,
450
+ ...(oauthAccountId ? { accountId: oauthAccountId } : {}),
451
+ };
425
452
  } catch (err) {
426
453
  const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
427
454
  return { provider, models: [], ok: false, source, error: msg };
@@ -0,0 +1,66 @@
1
+ import { createJobTool } from "../../agent/job-tool";
2
+ import type { JobRegistry } from "../../agent/job-registry";
3
+
4
+ export type JobsSlashLogger = (lines: string[]) => void;
5
+
6
+ /** Run the interactive `/jobs` view over the launch session's existing registry. */
7
+ export async function runJobsSlash(
8
+ input: string,
9
+ registry: JobRegistry,
10
+ cwd: string,
11
+ logLines: JobsSlashLogger = lines => console.log(lines.join("\n")),
12
+ ): Promise<void> {
13
+ const tokens = input.trim().split(/\s+/).filter(Boolean).slice(1);
14
+ const action = (tokens.shift() ?? "list").toLowerCase();
15
+ const args: Record<string, unknown> = { action };
16
+
17
+ if (action === "start") {
18
+ const command = tokens.join(" ").trim();
19
+ if (!command) {
20
+ logLines(["Usage: /jobs start <command>"]);
21
+ return;
22
+ }
23
+ args.command = command;
24
+ } else if (action === "list") {
25
+ if (tokens.length > 0) {
26
+ logLines(["Usage: /jobs [list|tail|await|cancel] [job-id ...]"]);
27
+ return;
28
+ }
29
+ } else if (action === "tail" || action === "cancel" || action === "await") {
30
+ const ids: string[] = [];
31
+ for (let index = 0; index < tokens.length; index++) {
32
+ const token = tokens[index]!;
33
+ if (token === "--timeout-ms" || token === "--timeout") {
34
+ const value = Number(tokens[++index]);
35
+ if (!Number.isFinite(value) || value <= 0) {
36
+ logLines([`Invalid timeout '${tokens[index] ?? ""}'. Use a positive number of milliseconds.`]);
37
+ return;
38
+ }
39
+ args.timeoutMs = value;
40
+ continue;
41
+ }
42
+ if (token.startsWith("--timeout-ms=") || token.startsWith("--timeout=")) {
43
+ const value = Number(token.slice(token.indexOf("=") + 1));
44
+ if (!Number.isFinite(value) || value <= 0) {
45
+ logLines([`Invalid timeout '${token}'. Use a positive number of milliseconds.`]);
46
+ return;
47
+ }
48
+ args.timeoutMs = value;
49
+ continue;
50
+ }
51
+ if (token.startsWith("--")) {
52
+ logLines([`Unknown /jobs option '${token}'.`]);
53
+ return;
54
+ }
55
+ ids.push(token);
56
+ }
57
+ if (ids.length > 0) args.ids = ids;
58
+ } else {
59
+ logLines([`Unknown /jobs action '${action}'. Use list | tail | await | cancel.`]);
60
+ return;
61
+ }
62
+
63
+ const result = await createJobTool(registry)(args, cwd);
64
+ if (result.output) logLines(result.output.split("\n"));
65
+ if (!result.success && result.error) logLines([`! ${result.error}`]);
66
+ }