@gotcos/glasses-server 6.38.0 → 6.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -48,7 +48,7 @@ BIND_HOST=0.0.0.0
48
48
  # Opus / Fable / Sonnet -> Claude Code CLI (https://docs.anthropic.com/en/docs/claude-code/getting-started, then `claude auth login`)
49
49
  # GPT Frontier/Balanced -> Codex CLI (https://developers.openai.com/codex/, then `codex login`)
50
50
  # Install at least one. Pick the default model the glasses use:
51
- # COS_G2_DEFAULT_MODEL=sonnet # opus | fable | sonnet | codex-frontier | codex-balanced
51
+ # COS_G2_DEFAULT_MODEL=sonnet # opus | fable | sonnet | codex-frontier | codex-balanced | cursor-grok | cursor-composer | ollama
52
52
  #
53
53
  # GPT slots refresh from the Codex model catalog every 15 minutes and retain
54
54
  # their last-known-good resolution if discovery is temporarily unavailable.
@@ -66,6 +66,26 @@ BIND_HOST=0.0.0.0
66
66
  # Pair with ~/.codex/config.toml [sandbox_workspace_write] network_access = true
67
67
  # for interactive Codex CLI; the managed glasses server also passes
68
68
  # -c sandbox_workspace_write.network_access=true when workspace-write is set.
69
+ #
70
+ # Local Ollama (https://ollama.com) as a fourth G2 picker. Hidden unless
71
+ # GET http://127.0.0.1:11434/api/tags succeeds with at least one pulled model.
72
+ # Direct POST /api/chat — chat/summarize only, no tools, no Codex --oss.
73
+ # brew install ollama
74
+ # ollama serve
75
+ # ollama run qwen2.5-coder
76
+ # COS_OLLAMA_MODEL=qwen2.5-coder
77
+ # COS_OLLAMA_HOST=http://127.0.0.1:11434
78
+ # COS_G2_DEFAULT_MODEL=ollama
79
+ #
80
+ # Codex CLI operator hatch (not the Ollama picker). Frontier stays ChatGPT
81
+ # unless you set this. Put it in ~/.cos-glasses/.env, not the LaunchAgent plist.
82
+ # Sessions Continue / Fork ignore these flags. No inline comments on this line.
83
+ # COS_CODEX_EXTRA_ARGS=--oss --local-provider ollama --model qwen2.5-coder
84
+ # Do not define [model_providers.ollama] — that id is reserved. LM Studio:
85
+ # COS_CODEX_EXTRA_ARGS=--oss --local-provider lmstudio --model <id>
86
+ # Other OpenAI-compatible proxies (not oss): --model inside EXTRA_ARGS so a
87
+ # plist COS_CODEX_MODEL cannot retarget it:
88
+ # COS_CODEX_EXTRA_ARGS=-c model_provider="local_vllm" --model <id>
69
89
 
70
90
  # Claude preserves COS's established trusted-machine behavior by default.
71
91
  # Security-conscious installs can remove the permission bypass and restrict
package/CHANGELOG.md CHANGED
@@ -1,3 +1,58 @@
1
+ ## 6.39.0
2
+
3
+ Ollama is a fourth G2 picker, hidden unless a local daemon answers.
4
+
5
+ When `GET http://127.0.0.1:11434/api/tags` succeeds with at least one pulled
6
+ model, glasses and companion Settings show **Ollama**. Queries go to
7
+ `POST /api/chat` on loopback — chat/summarize only, no tools, no vision, no
8
+ attached Continue / Fork, no Live Cues. If the daemon drops, the slot hides and
9
+ a saved `ollama` preference resets to Opus.
10
+
11
+ Optional pin: `COS_OLLAMA_MODEL`. Optional loopback override: `COS_OLLAMA_HOST`
12
+ (LAN / `0.0.0.0` refused). `COS_G2_DEFAULT_MODEL=ollama` is valid when the
13
+ probe is ready.
14
+
15
+ GPT Frontier / Balanced stay Codex CLI → ChatGPT. `COS_CODEX_EXTRA_ARGS` remains
16
+ a Codex operator hatch, not the Ollama product.
17
+
18
+ ## 6.38.1
19
+
20
+ GPT Frontier can point at a local Ollama (or LM Studio) engine without a fourth
21
+ G2 provider. Set `COS_CODEX_EXTRA_ARGS` in `~/.cos-glasses/.env`:
22
+
23
+ `COS_CODEX_EXTRA_ARGS=--oss --local-provider ollama --model qwen2.5-coder`
24
+
25
+ Put `--model` inside that string so a Control-keep-alived plist `COS_CODEX_MODEL`
26
+ cannot retarget off the local id. Sessions Continue / Fork / Settings proof
27
+ ignore extra args. A plist-only EXTRA_ARGS dies on the next Update Server.
28
+
29
+ The hatch is allowlisted (`--oss`, `--local-provider`, `--model`, `-c` keys
30
+ `model` / `model_provider` / `oss_provider` / `model_reasoning_effort` /
31
+ `service_tier`). It cannot pass `--sandbox`, `--permission-profile`, or a `-c`
32
+ sandbox key. Codex still runs `--sandbox read-only`; that sandbox does not block
33
+ the CLI's own HTTP to `localhost:11434`. `ollama serve` must be running or Codex
34
+ exits `OSS setup failed`. This is not a COS Ollama product.
35
+
36
+ ## 6.38.1
37
+
38
+ The conversation archive was quietly storing the same conversations over and over.
39
+
40
+ FIXED: `appendToArchive` blind-appended, and the daily mirror re-archives every
41
+ still-resident prior-day session at boot and every 24h without evicting it -- so
42
+ each restart added another copy. Measured on a real install: 1.268 GB of archive
43
+ of which 99.3% was duplicate; one 69 MB day file held a single conversation
44
+ 2,388 times. This also inflated the archive index's chat counts and archive
45
+ search's match counts (both from 6.38.0) by up to ~2,200x on affected days.
46
+
47
+ The merge now upserts on (sessionId, startedAt), replaces a chat that has grown,
48
+ returns without rewriting when nothing changed, and self-heals a file written by
49
+ the old code the next time it is touched.
50
+
51
+ NEW: `server/scripts/repair-archive-duplicates.ts` -- a dry-run-by-default repair
52
+ for day files the mirror no longer touches. Backs up every file before writing,
53
+ refuses while the server is running, verifies by unique-chat count (never file
54
+ size), and is idempotent.
55
+
1
56
  ## 6.38.0
2
57
 
3
58
  Six months of archived conversation you can finally search.
package/README.md CHANGED
@@ -60,7 +60,7 @@ without silently losing completed replies.
60
60
  > No provider API key is needed for chat when using signed-in CLIs. Usage is
61
61
  > billed to the corresponding Claude, Codex, or Cursor subscription. Pick a
62
62
  > provider per query, or set a default with `COS_G2_DEFAULT_MODEL`
63
- > (`opus`|`fable`|`sonnet`|`codex-frontier`|`codex-balanced`|`cursor-grok`|`cursor-composer`).
63
+ > (`opus`|`fable`|`sonnet`|`codex-frontier`|`codex-balanced`|`cursor-grok`|`cursor-composer`|`ollama`).
64
64
  > Claude tier aliases and the two GPT slots resolve dynamically, so new model
65
65
  > releases do not require a new glasses package. GPT discovery refreshes every
66
66
  > 15 minutes and retains its last-known-good catalog through transient failures.
@@ -75,6 +75,10 @@ without silently losing completed replies.
75
75
  > `COS_CODEX_SANDBOX=workspace-write` for workdir writes + outbound network
76
76
  > (`sandbox_workspace_write.network_access=true` is passed by the managed server
77
77
  > and should also be set in `~/.codex/config.toml` for interactive Codex).
78
+ > Local Ollama is a **fourth picker**, shown only when `ollama serve` answers
79
+ > `GET http://127.0.0.1:11434/api/tags` with a pulled model. Direct
80
+ > `POST /api/chat` — not Codex `--oss`. Optional pin: `COS_OLLAMA_MODEL`.
81
+ > `COS_CODEX_EXTRA_ARGS` is a Codex CLI hatch, not the Ollama UX. See Configuration.
78
82
  > **Claude is the most permissive provider by default.** It runs with
79
83
  > `--dangerously-skip-permissions`, so a glasses query on the Claude/Opus path can
80
84
  > run shell commands and read, edit, and write files on this Mac without prompting
@@ -173,6 +177,15 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
173
177
  in the managed CLI working directory),
174
178
  `COS_CURSOR_AGENT_BIN` (optional absolute Cursor `agent` binary),
175
179
  `COS_CURSOR_PERSIST_SESSIONS=0` (disable Cursor session resume),
180
+ `COS_CODEX_SANDBOX=workspace-write` (workdir writes + outbound network on GPT),
181
+ `COS_OLLAMA_MODEL` (optional pin; must match a name from `ollama list`),
182
+ `COS_OLLAMA_HOST` (optional loopback origin, default `http://127.0.0.1:11434`;
183
+ non-loopback is refused),
184
+ `COS_CODEX_EXTRA_ARGS` (Codex exec operator hatch — not the Ollama picker.
185
+ `--oss --local-provider ollama --model qwen2.5-coder` still retargets GPT
186
+ Frontier spawn. Put it in `~/.cos-glasses/.env`; a plist-only value is dropped
187
+ on Update Server. G2 Codex exec only — Sessions Continue / Fork ignore it.
188
+ No inline `#` on the EXTRA_ARGS line.),
176
189
  `COS_WEATHER_DEFAULT_LAT` / `COS_WEATHER_DEFAULT_LON` /
177
190
  `COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
178
191
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=0` (optional
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.38.0",
4
- "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
3
+ "version": "6.39.0",
4
+ "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glasses-server": "bin/cli.cjs",
@@ -343,12 +343,59 @@ export async function appendToArchive(
343
343
  }
344
344
 
345
345
  if (existing) {
346
- // Merge: re-number chat IDs
347
- const nextId = existing.chats.length
348
- for (let i = 0; i < newChats.length; i++) {
349
- newChats[i].id = nextId + i
346
+ // UPSERT, never blind-append.
347
+ //
348
+ // `runDailyArchiveMirror` walks every session still resident in memory, skips
349
+ // only TODAY's, and archives the rest -- at boot and every 24h -- WITHOUT
350
+ // evicting them from the map. This merge used to be
351
+ // `existing.chats.push(...newChats)`, so a session that stayed resident gained
352
+ // one more copy of itself in its day file on every single restart, forever.
353
+ //
354
+ // Measured on the live corpus before this fix: 1.28 GB across 176 day files, of
355
+ // which ~1.26 GB (98%) was duplicates, 31 files affected. 2026-07-30.json was
356
+ // 343 MB holding 4,421 chats belonging to exactly TWO sessions -- ~2,210 copies
357
+ // of roughly 0.16 MB of real content. 2026-07-28.json was 69 MB of ONE
358
+ // conversation, 2,388 times over. That is why the archive index and archive
359
+ // search (both shipped in 6.38.0) reported inflated counts, and why a day file
360
+ // ever reached a size that costs 1.2 GB of heap to parse.
361
+ //
362
+ // IDENTITY IS (sessionId, startedAt). `startedAt` is the chat's first exchange
363
+ // timestamp, so it survives re-archiving. `id` does NOT -- it is renumbered on
364
+ // every merge, which is precisely why the old code could never recognise a chat
365
+ // it had already written. Verified against the real corpus: this key collapses
366
+ // 2026-07-30 from 4,421 chats to 2, and leaves an unaffected day (2026-08-17,
367
+ // 12 chats) at exactly 12 -- so it does not over-merge distinct conversations.
368
+ const keyOf = (c: ArchivedChat): string => `${c.sessionId}:${c.startedAt}`
369
+ const byKey = new Map<string, ArchivedChat>()
370
+ for (const chat of existing.chats) {
371
+ const key = keyOf(chat)
372
+ const prior = byKey.get(key)
373
+ // Self-heal: a file written before this fix already contains duplicates.
374
+ // Keep the most complete copy rather than the first one encountered.
375
+ if (!prior || chat.exchangeCount > prior.exchangeCount) byKey.set(key, chat)
350
376
  }
351
- existing.chats.push(...newChats)
377
+ const hadDuplicates = byKey.size !== existing.chats.length
378
+
379
+ let changed = false
380
+ for (const incoming of newChats) {
381
+ const key = keyOf(incoming)
382
+ const prior = byKey.get(key)
383
+ // REPLACE rather than skip: a session archived yesterday with 5 exchanges that
384
+ // now holds 8 must update, not be discarded as "already seen".
385
+ if (!prior || incoming.exchangeCount > prior.exchangeCount) {
386
+ byKey.set(key, incoming)
387
+ changed = true
388
+ }
389
+ }
390
+
391
+ // Nothing new and nothing to repair: return WITHOUT rewriting. Re-serialising a
392
+ // large day file to produce identical bytes is pure cost on the process that
393
+ // also records the wearer's live session, and it is the common case at boot.
394
+ if (!changed && !hadDuplicates) return
395
+
396
+ const merged = [...byKey.values()].sort((a, b) => a.startedAt - b.startedAt)
397
+ merged.forEach((chat, i) => { chat.id = i })
398
+ existing.chats = merged
352
399
  existing.summary = await generateDaySummary(existing.chats, opts.skipLLM)
353
400
  existing.archivedAt = new Date().toISOString()
354
401
  saveArchive(existing)
@@ -110,26 +110,6 @@ export function isAttachedPermissionPolicy(value: unknown): value is AttachedPer
110
110
  return value === 'read_only'
111
111
  }
112
112
 
113
- /**
114
- * Flags that must never reach a provider that is about to write into someone's
115
- * real conversation (plan 4.7: no always-approve, bypass-permissions,
116
- * danger-full-access, force, or yolo behavior).
117
- *
118
- * Exported so the assertion can be made against the argv that is actually
119
- * spawned rather than against the source that builds it.
120
- */
121
- export const BANNED_PERMISSION_ARGS: readonly string[] = [
122
- '--dangerously-skip-permissions',
123
- '--dangerously-bypass-approvals-and-sandbox',
124
- '--dangerously-bypass-hook-trust',
125
- '--full-auto',
126
- '--yolo',
127
- '--force',
128
- 'danger-full-access',
129
- 'bypassPermissions',
130
- 'acceptEdits',
131
- ]
132
-
133
113
  /** Terminal reason on failure. Never carries prompt or transcript text. */
134
114
  export type AttachedTurnFailure =
135
115
  | 'invalid_provider'
@@ -238,6 +218,7 @@ import {
238
218
  resolveProviderBinary,
239
219
  type BinaryResolution,
240
220
  } from './provider-binary.js'
221
+ import { findBannedPermissionArg } from './banned-permission-args.js'
241
222
 
242
223
  export {
243
224
  STALE_SHIM_PREFIXES,
@@ -249,6 +230,7 @@ export {
249
230
  type BinaryResolutionFailure,
250
231
  type BinarySpec,
251
232
  } from './provider-binary.js'
233
+ export { findBannedPermissionArg, BANNED_PERMISSION_ARGS } from './banned-permission-args.js'
252
234
 
253
235
 
254
236
  // ---------------------------------------------------------------------------
@@ -506,34 +488,6 @@ export function buildCursorAttachedArgs(nativeThreadId: string, cwd: string): st
506
488
  ]
507
489
  }
508
490
 
509
- const PATH_VALUED_FLAGS = new Set(['--workspace', '-C', '--cd', '--add-dir'])
510
- const BARE_BANNED_PERMISSION_ARGS = new Set(
511
- BANNED_PERMISSION_ARGS.filter(flag => !flag.startsWith('-')),
512
- )
513
-
514
- /**
515
- * Is this argv free of every flag plan 4.7 bans?
516
- *
517
- * Flag-position tokens (start with `-`) are substring-matched so
518
- * `--permission-mode=bypassPermissions` still hits. Bare tokens
519
- * (`danger-full-access`) match only as their own argv slot. Values of
520
- * `--workspace` / `-C` are skipped: a cwd containing `--force` is a path,
521
- * not a permission flag.
522
- */
523
- export function findBannedPermissionArg(args: readonly string[]): string | null {
524
- for (let i = 0; i < args.length; i++) {
525
- const value = String(args[i])
526
- const prev = i > 0 ? String(args[i - 1]) : ''
527
- if (PATH_VALUED_FLAGS.has(prev)) continue
528
- if (BARE_BANNED_PERMISSION_ARGS.has(value)) return value
529
- if (!value.startsWith('-')) continue
530
- for (const banned of BANNED_PERMISSION_ARGS) {
531
- if (value.includes(banned)) return banned
532
- }
533
- }
534
- return null
535
- }
536
-
537
491
  /**
538
492
  * Build the argv, and REFUSE to hand back one carrying a banned flag.
539
493
  *
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared permission-ban list for attached, fork, and G2 Codex extra-args.
3
+ * Keep PATH_VALUED_FLAGS and the bare-token set private; they only exist to
4
+ * make findBannedPermissionArg skip cwd-shaped values.
5
+ */
6
+
7
+ export const BANNED_PERMISSION_ARGS: readonly string[] = [
8
+ '--dangerously-skip-permissions',
9
+ '--dangerously-bypass-approvals-and-sandbox',
10
+ '--dangerously-bypass-hook-trust',
11
+ '--full-auto',
12
+ '--yolo',
13
+ '--force',
14
+ 'danger-full-access',
15
+ 'bypassPermissions',
16
+ 'acceptEdits',
17
+ ]
18
+
19
+ const PATH_VALUED_FLAGS = new Set(['--workspace', '-C', '--cd', '--add-dir'])
20
+ const BARE_BANNED_PERMISSION_ARGS = new Set(
21
+ BANNED_PERMISSION_ARGS.filter(flag => !flag.startsWith('-')),
22
+ )
23
+
24
+ /**
25
+ * Is this argv free of every flag plan 4.7 bans?
26
+ *
27
+ * Flag-position tokens (start with `-`) are substring-matched so
28
+ * `--permission-mode=bypassPermissions` still hits. Bare tokens
29
+ * (`danger-full-access`) match only as their own argv slot. Values of
30
+ * `--workspace` / `-C` are skipped: a cwd containing `--force` is a path,
31
+ * not a permission flag.
32
+ */
33
+ export function findBannedPermissionArg(args: readonly string[]): string | null {
34
+ for (let i = 0; i < args.length; i++) {
35
+ const value = String(args[i])
36
+ const prev = i > 0 ? String(args[i - 1]) : ''
37
+ if (PATH_VALUED_FLAGS.has(prev)) continue
38
+ if (BARE_BANNED_PERMISSION_ARGS.has(value)) return value
39
+ if (!value.startsWith('-')) continue
40
+ for (const banned of BANNED_PERMISSION_ARGS) {
41
+ if (value.includes(banned)) return banned
42
+ }
43
+ }
44
+ return null
45
+ }
@@ -311,6 +311,7 @@ export interface ModelRunMetadata {
311
311
  codexThreadId?: string
312
312
  cursorRunId?: string
313
313
  cursorChatId?: string
314
+ ollamaRunId?: string
314
315
  outputAttachments?: MediaAttachmentRef[]
315
316
  outputImageStats?: RunOutputImageCollectionStats
316
317
  }
@@ -318,7 +319,7 @@ export interface ModelRunMetadata {
318
319
  /** Public-safe provider launch metadata for durable job coordination. It
319
320
  * deliberately exposes no ChildProcess object, kill handle, paths, or env. */
320
321
  export interface ProviderProcessMetadata {
321
- provider: 'claude' | 'codex' | 'cursor'
322
+ provider: 'claude' | 'codex' | 'cursor' | 'ollama'
322
323
  runId: string
323
324
  pid?: number
324
325
  clientJobId?: string
@@ -7,6 +7,15 @@ import { spawn, spawnSync } from 'node:child_process'
7
7
  // because COS Control injects that directory into the managed plist PATH -- it is ENOENT
8
8
  // for every public npx user, and for anything launchd- or Finder-spawned.
9
9
  import { resolveProviderBinary } from './provider-binary.js'
10
+ import {
11
+ CodexExtraArgsError,
12
+ assertSafeCodexExtraArgs,
13
+ codexEngineFingerprint,
14
+ parseCodexExtraArgs,
15
+ skipsCosModel,
16
+ skipsCosReasoningEffort,
17
+ skipsCosServiceTier,
18
+ } from './codex-extra-args.js'
10
19
  import { logTokenAudit } from './token-audit.js'
11
20
  import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
12
21
  import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
@@ -161,6 +170,13 @@ export function codexSupportsAdditionalDir(): boolean {
161
170
  return addDirSupported
162
171
  }
163
172
 
173
+ export {
174
+ CodexExtraArgsError,
175
+ parseCodexExtraArgs,
176
+ assertSafeCodexExtraArgs,
177
+ codexEngineFingerprint,
178
+ } from './codex-extra-args.js'
179
+
164
180
  export function buildCodexExecArgs(input: {
165
181
  codexCwd: string
166
182
  imagePaths?: string[]
@@ -170,6 +186,7 @@ export function buildCodexExecArgs(input: {
170
186
  resolvedModel?: CodexModelOption
171
187
  effort?: EffortPreference
172
188
  publisherWritableDirectory?: string
189
+ extra?: string[]
173
190
  }): string[] {
174
191
  const imagePaths = input.imagePaths ?? []
175
192
  const resolvedModel = input.resolvedModel
@@ -184,10 +201,18 @@ export function buildCodexExecArgs(input: {
184
201
  args.push('--add-dir', input.publisherWritableDirectory)
185
202
  }
186
203
 
204
+ const extra = input.extra ?? parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
205
+ assertSafeCodexExtraArgs(extra)
206
+ args.push(...extra)
207
+
187
208
  const appendModelConfig = () => {
188
- if (resolvedModel.id) args.push('--model', resolvedModel.id)
189
- args.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
190
- if (serviceTier) args.push('-c', `service_tier="${serviceTier}"`)
209
+ if (resolvedModel.id && !skipsCosModel(extra)) args.push('--model', resolvedModel.id)
210
+ if (!skipsCosReasoningEffort(extra)) {
211
+ args.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
212
+ }
213
+ if (serviceTier && !skipsCosServiceTier(extra)) {
214
+ args.push('-c', `service_tier="${serviceTier}"`)
215
+ }
191
216
  }
192
217
 
193
218
  if (input.codexThreadId) {
@@ -299,6 +324,18 @@ export async function callCodexStreaming(
299
324
  if (options?.abortSignal?.aborted) {
300
325
  throw new Error('codex-bridge: client disconnected before Codex started.')
301
326
  }
327
+ let extra: string[]
328
+ try {
329
+ extra = parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
330
+ assertSafeCodexExtraArgs(extra)
331
+ } catch (error) {
332
+ if (error instanceof CodexExtraArgsError) {
333
+ await callbacks.onError(error.message)
334
+ return sid
335
+ }
336
+ throw error
337
+ }
338
+ const extraFingerprint = codexEngineFingerprint(extra)
302
339
  const history = getHistory(sid)
303
340
  const session = getSessionRaw(sid)
304
341
  const contextBreaks = session?.contextBreaks ?? []
@@ -310,7 +347,13 @@ export async function callCodexStreaming(
310
347
  const resolvedCodexModel = resolveCodexModelOption(model)
311
348
  const resolvedCodexEffort = resolveCodexEffortForModel(resolvedCodexModel, options?.effort)
312
349
  const engineSession = persistentCodexSession
313
- ? getCodexEngineSession({ cosSessionId: sid, model, cwd: codexCwd, trustMode: codexTrustMode })
350
+ ? getCodexEngineSession({
351
+ cosSessionId: sid,
352
+ model,
353
+ cwd: codexCwd,
354
+ trustMode: codexTrustMode,
355
+ engineFingerprint: extraFingerprint,
356
+ })
314
357
  : null
315
358
  const imageInputs: ModelImageInput[] = images ?? []
316
359
  const imagePaths = imageInputs.map(input => input.path)
@@ -419,6 +462,7 @@ export async function callCodexStreaming(
419
462
  resolvedModel: resolvedCodexModel,
420
463
  effort: options?.effort,
421
464
  publisherWritableDirectory: outputImagePublisher?.writableDirectory,
465
+ extra,
422
466
  })
423
467
 
424
468
  const env = { ...process.env }
@@ -481,6 +525,7 @@ export async function callCodexStreaming(
481
525
  codexThreadId,
482
526
  cwd: codexCwd,
483
527
  trustMode: codexTrustMode,
528
+ engineFingerprint: extraFingerprint,
484
529
  })
485
530
  updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
486
531
  } catch (error) {
@@ -20,6 +20,7 @@ export interface CodexEngineSession {
20
20
  savedAt: number
21
21
  lastUsedAt: number
22
22
  expiresAt: string
23
+ engineFingerprint: string
23
24
  }
24
25
 
25
26
  interface CodexEngineSessionFile {
@@ -93,18 +94,21 @@ export function getCodexEngineSession(input: {
93
94
  model: CodexModelPreference
94
95
  cwd: string
95
96
  trustMode: CodexTrustMode
97
+ engineFingerprint?: string
96
98
  }): CodexEngineSession | null {
99
+ const { engineFingerprint = '', ...rest } = input
97
100
  const store = readStore()
98
101
  const now = Date.now()
99
102
  const sessions = pruneExpired(store.sessions, now)
100
- const existing = sessions[sessionKey(input.cosSessionId, input.model)]
103
+ const existing = sessions[sessionKey(rest.cosSessionId, rest.model)]
101
104
  if (!existing) {
102
105
  if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
103
106
  writeStore({ sessions, savedAt: new Date().toISOString() })
104
107
  }
105
108
  return null
106
109
  }
107
- if (existing.cwd !== input.cwd || existing.trustMode !== input.trustMode) return null
110
+ if (existing.cwd !== rest.cwd || existing.trustMode !== rest.trustMode) return null
111
+ if ((existing.engineFingerprint ?? '') !== (engineFingerprint ?? '')) return null
108
112
  return existing
109
113
  }
110
114
 
@@ -115,6 +119,7 @@ export function saveCodexEngineSession(input: {
115
119
  cwd: string
116
120
  trustMode: CodexTrustMode
117
121
  now?: number
122
+ engineFingerprint?: string
118
123
  }): CodexEngineSession {
119
124
  const now = input.now ?? Date.now()
120
125
  const store = readStore()
@@ -131,6 +136,7 @@ export function saveCodexEngineSession(input: {
131
136
  savedAt: previous?.savedAt ?? now,
132
137
  lastUsedAt: now,
133
138
  expiresAt: expiresAtFrom(now),
139
+ engineFingerprint: input.engineFingerprint ?? '',
134
140
  }
135
141
  sessions[key] = session
136
142
  writeStore({ sessions, savedAt: new Date().toISOString() })