@gotcos/glasses-server 6.61.7 → 6.62.1
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 +6 -0
- package/CHANGELOG.md +34 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +4 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +22 -2
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +48 -7
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +57 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +32 -2
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// Installing the COS session hook into Codex's user-level `~/.codex/hooks.json` (6.62.0), and
|
|
2
|
+
// knowing whether it is installed and TRUSTED.
|
|
3
|
+
//
|
|
4
|
+
// A SIBLING OF `claude-hooks-installer.ts`, never a branch of it. Claude's subscriptions,
|
|
5
|
+
// command bytes and status words stay exactly as they were (B2): Control 0.5.254 keys its
|
|
6
|
+
// banner and its Install check off `sessionHooks.state` / `installed`, and `hookHaltReady`
|
|
7
|
+
// feeds `deskClaude`. Codex is reported apart, as `sessionHooks.codex`.
|
|
8
|
+
//
|
|
9
|
+
// THE SAME BYTES, ITS OWN COPY AND COMMAND (QA W12). Codex runs `cos-session-hook-codex`, a
|
|
10
|
+
// copy of the packaged script this installer keeps current, never the stable Claude script: a
|
|
11
|
+
// rollback that reinstalls an older Claude script must not change what Codex runs (an older
|
|
12
|
+
// script has no provider stamp and a halt reply Codex fails open on). The command carries
|
|
13
|
+
// `COS_HOOK_PROVIDER=codex`, which makes the script print the deny-only halt reply Codex
|
|
14
|
+
// honours (canaries C10, C12) and stamp `"provider":"codex"` on what it spools. The bytes of
|
|
15
|
+
// the command never change between releases (no version in it), so a reinstall does not ask
|
|
16
|
+
// Miles to review the hooks again.
|
|
17
|
+
//
|
|
18
|
+
// THE MERGE NEVER MOVES A USER BLOCK (W2, QA W13). Codex trusts each hook by a POSITIONAL key,
|
|
19
|
+
// `<file>:<snake_event>:<block index>:<hook index>`, recorded as `trusted_hash` in
|
|
20
|
+
// `~/.codex/config.toml`. Claude's merge drops our block and appends a fresh one, which would
|
|
21
|
+
// move every user block after it to a new index and silently untrust it. Here our FIRST block
|
|
22
|
+
// is rewritten where it stands and a missing one is appended at the end. A second block of
|
|
23
|
+
// ours (a hand edit, or a COS block Codex imported from the Claude settings) is LEFT where it
|
|
24
|
+
// is and reported as drift: removing it would shift every user block after it, and whether
|
|
25
|
+
// Codex accepts an emptied group is unproven. Only a block whose every hook is ours is ours:
|
|
26
|
+
// a user block that also calls our script is left alone too, because rewriting it would drop
|
|
27
|
+
// the user's own hooks. Uninstall is the one write that removes our blocks, and a user block
|
|
28
|
+
// after one of them then moves (and Codex asks to review it again).
|
|
29
|
+
//
|
|
30
|
+
// TRUST IS READ, NEVER WRITTEN (K3). An untrusted user hook silently does not run (C1). Trust is
|
|
31
|
+
// recorded by Codex itself when Miles trusts the hooks once in the `codex` TUI; COS never writes
|
|
32
|
+
// `trusted_hash`. The state comes from Codex's own app-server (`hooks/list`, which also knows
|
|
33
|
+
// `modified`), read in the background and cached (`codex-hooks-trust` below).
|
|
34
|
+
|
|
35
|
+
import { spawn } from 'node:child_process'
|
|
36
|
+
import { createHash } from 'node:crypto'
|
|
37
|
+
import { lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
|
38
|
+
import { homedir } from 'node:os'
|
|
39
|
+
import { dirname, join, resolve } from 'node:path'
|
|
40
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
41
|
+
import {
|
|
42
|
+
PERMISSION_HOOK_TIMEOUT_S,
|
|
43
|
+
backupSettings,
|
|
44
|
+
currentHookPaths,
|
|
45
|
+
ensureStableHookScript,
|
|
46
|
+
hookIsOurs,
|
|
47
|
+
packagedHookScriptPath,
|
|
48
|
+
shellQuote,
|
|
49
|
+
cosGlassesHome,
|
|
50
|
+
type HookPaths,
|
|
51
|
+
} from './claude-hooks-installer.js'
|
|
52
|
+
import { resolveProviderBinary } from './provider-binary.js'
|
|
53
|
+
|
|
54
|
+
export interface CodexHookSubscription {
|
|
55
|
+
event: string
|
|
56
|
+
/** Codex's own handler field (K2): `async` is honoured, and written explicitly every time. */
|
|
57
|
+
async: boolean
|
|
58
|
+
/** Codex's own handler field (K2). Claude's `timeout` may be silently ignored by Codex. */
|
|
59
|
+
timeoutSec: number
|
|
60
|
+
/** As Claude's PermissionRequest block: the wait rides the command as the script's `$2`. */
|
|
61
|
+
passTimeout?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Codex's events, its OWN list (W2). Synchronous: the turn transitions a row is derived from
|
|
66
|
+
* (SessionStart, UserPromptSubmit, Stop, SessionEnd, and Interrupt, Codex's Esc), PreToolUse
|
|
67
|
+
* (the halt check reads every tool call; UserPromptSubmit clears a halt marker), and
|
|
68
|
+
* PermissionRequest (it must return a decision to answer from the lens). The rest are
|
|
69
|
+
* fire-and-forget. Never Claude-only events (StopFailure, Notification, PostModelSwitch...),
|
|
70
|
+
* which Codex does not fire.
|
|
71
|
+
*/
|
|
72
|
+
export const CODEX_HOOK_SUBSCRIPTIONS: readonly CodexHookSubscription[] = [
|
|
73
|
+
{ event: 'SessionStart', async: false, timeoutSec: 5 },
|
|
74
|
+
{ event: 'SessionEnd', async: false, timeoutSec: 5 },
|
|
75
|
+
{ event: 'UserPromptSubmit', async: false, timeoutSec: 5 },
|
|
76
|
+
{ event: 'Stop', async: false, timeoutSec: 5 },
|
|
77
|
+
{ event: 'Interrupt', async: false, timeoutSec: 5 },
|
|
78
|
+
{ event: 'PreToolUse', async: false, timeoutSec: 5 },
|
|
79
|
+
{ event: 'PermissionRequest', async: false, timeoutSec: PERMISSION_HOOK_TIMEOUT_S, passTimeout: true },
|
|
80
|
+
{ event: 'PostToolUse', async: true, timeoutSec: 10 },
|
|
81
|
+
{ event: 'SubagentStart', async: true, timeoutSec: 10 },
|
|
82
|
+
{ event: 'SubagentStop', async: true, timeoutSec: 10 },
|
|
83
|
+
{ event: 'PreCompact', async: true, timeoutSec: 10 },
|
|
84
|
+
{ event: 'PostCompact', async: true, timeoutSec: 10 },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
/** QA W12: the script Codex runs, a copy of the packaged one beside the stable Claude script. */
|
|
88
|
+
export const CODEX_HOOK_SCRIPT_NAME = 'cos-session-hook-codex'
|
|
89
|
+
|
|
90
|
+
export function stableCodexHookScriptPath(): string {
|
|
91
|
+
return join(cosGlassesHome(), 'bin', CODEX_HOOK_SCRIPT_NAME)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Ours in the Codex file: the Claude predicate (an imported block) or the Codex copy's name. */
|
|
95
|
+
function codexHookIsOurs(h: unknown): boolean {
|
|
96
|
+
return hookIsOurs(h) || (!!h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
|
|
97
|
+
&& (h as { command: string }).command.includes(`/${CODEX_HOOK_SCRIPT_NAME}`))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Codex's own home variable, as `occupancy-probes.ts` reads it. */
|
|
101
|
+
export function codexHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
102
|
+
const raw = env.CODEX_HOME?.trim()
|
|
103
|
+
return raw ? resolve(raw) : join(homedir(), '.codex')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function codexHooksPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
107
|
+
return join(codexHome(env), 'hooks.json')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Is Codex on this Mac at all? Its binary resolves (the same resolution every Codex spawn
|
|
112
|
+
* uses) AND it has a home: a Mac that never ran Codex gets no `~/.codex/hooks.json` from us.
|
|
113
|
+
*/
|
|
114
|
+
export function codexPresent(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
115
|
+
if (!resolveProviderBinary('codex', env).ok) return false
|
|
116
|
+
try { return statSync(codexHome(env)).isDirectory() } catch { return false }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The Codex command: Claude's, with the provider in front. No version, so it never drifts on update. */
|
|
120
|
+
export function codexHookCommand(scriptPath: string, sub: CodexHookSubscription, paths: HookPaths = currentHookPaths()): string {
|
|
121
|
+
const base = `COS_HOOK_PROVIDER=codex COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${sub.event}`
|
|
122
|
+
return sub.passTimeout ? `${base} ${Math.trunc(sub.timeoutSec)}` : base
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function canonicalBlock(scriptPath: string, sub: CodexHookSubscription, paths: HookPaths): Record<string, unknown> {
|
|
126
|
+
return { hooks: [{ type: 'command', command: codexHookCommand(scriptPath, sub, paths), timeoutSec: sub.timeoutSec, async: sub.async }] }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Ours only when EVERY hook in it is ours; a user block that also calls our script is not. */
|
|
130
|
+
function blockIsOurs(block: unknown): boolean {
|
|
131
|
+
if (!block || typeof block !== 'object') return false
|
|
132
|
+
const hooks = (block as { hooks?: unknown }).hooks
|
|
133
|
+
return Array.isArray(hooks) && hooks.length > 0 && hooks.every(codexHookIsOurs)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Any hook of ours inside this block, pure or mixed. */
|
|
137
|
+
function blockMentionsUs(block: unknown): boolean {
|
|
138
|
+
if (!block || typeof block !== 'object') return false
|
|
139
|
+
const hooks = (block as { hooks?: unknown }).hooks
|
|
140
|
+
return Array.isArray(hooks) && hooks.some(codexHookIsOurs)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type CodexMergeResult =
|
|
144
|
+
| { ok: true; settings: Record<string, unknown>; changed: boolean }
|
|
145
|
+
| { ok: false; reason: 'codex_hooks_invalid' }
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pure. Our first block for each subscribed event is REPLACED WHERE IT STANDS, or appended when
|
|
149
|
+
* there is none; a second block of ours is left in place (and reads as drift, `duplicated`).
|
|
150
|
+
* Every user block keeps its index, so every trust key Codex holds for it still names it. Other
|
|
151
|
+
* events and keys pass through.
|
|
152
|
+
*/
|
|
153
|
+
export function mergeCodexHooks(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): CodexMergeResult {
|
|
154
|
+
const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
155
|
+
const before = JSON.stringify(settings)
|
|
156
|
+
if (settings.hooks !== undefined && (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) return { ok: false, reason: 'codex_hooks_invalid' }
|
|
157
|
+
const hooks: Record<string, unknown> = { ...(settings.hooks as Record<string, unknown> | undefined ?? {}) }
|
|
158
|
+
for (const sub of CODEX_HOOK_SUBSCRIPTIONS) {
|
|
159
|
+
const value = hooks[sub.event]
|
|
160
|
+
if (value !== undefined && !Array.isArray(value)) return { ok: false, reason: 'codex_hooks_invalid' }
|
|
161
|
+
const blocks = Array.isArray(value) ? [...value] : []
|
|
162
|
+
const ours = blocks.map((block, index) => (blockIsOurs(block) ? index : -1)).filter(index => index >= 0)
|
|
163
|
+
const canonical = canonicalBlock(scriptPath, sub, paths)
|
|
164
|
+
if (ours.length === 0) blocks.push(canonical)
|
|
165
|
+
else blocks[ours[0]!] = canonical
|
|
166
|
+
hooks[sub.event] = blocks
|
|
167
|
+
}
|
|
168
|
+
settings.hooks = hooks
|
|
169
|
+
return { ok: true, settings, changed: JSON.stringify(settings) !== before }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Pure. Removes our blocks (every event, subscribed or not); emptied events are deleted. */
|
|
173
|
+
export function stripCodexHooks(current: unknown): CodexMergeResult {
|
|
174
|
+
const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
175
|
+
const before = JSON.stringify(settings)
|
|
176
|
+
const hooksIn = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks as Record<string, unknown> : null
|
|
177
|
+
if (!hooksIn) return { ok: true, settings, changed: false }
|
|
178
|
+
const hooks: Record<string, unknown> = {}
|
|
179
|
+
for (const [event, blocks] of Object.entries(hooksIn)) {
|
|
180
|
+
if (!Array.isArray(blocks)) { hooks[event] = blocks; continue }
|
|
181
|
+
const kept = blocks.filter(block => !blockIsOurs(block))
|
|
182
|
+
if (kept.length > 0) hooks[event] = kept
|
|
183
|
+
}
|
|
184
|
+
settings.hooks = hooks
|
|
185
|
+
return { ok: true, settings, changed: JSON.stringify(settings) !== before }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function codexSubscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[]; duplicated: string[] } {
|
|
189
|
+
const hooks = current && typeof current === 'object' ? (current as Record<string, unknown>).hooks : null
|
|
190
|
+
const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
|
|
191
|
+
const subscribed: string[] = []
|
|
192
|
+
const missing: string[] = []
|
|
193
|
+
const drifted: string[] = []
|
|
194
|
+
const duplicated: string[] = []
|
|
195
|
+
for (const sub of CODEX_HOOK_SUBSCRIPTIONS) {
|
|
196
|
+
const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
|
|
197
|
+
const mentions = blocks.filter(blockMentionsUs)
|
|
198
|
+
if (mentions.length === 0) { missing.push(sub.event); continue }
|
|
199
|
+
if (mentions.length === 1 && JSON.stringify(mentions[0]) === JSON.stringify(canonicalBlock(scriptPath, sub, paths))) subscribed.push(sub.event)
|
|
200
|
+
else drifted.push(sub.event)
|
|
201
|
+
if (mentions.length > 1) duplicated.push(sub.event)
|
|
202
|
+
}
|
|
203
|
+
return { subscribed, missing, drifted, duplicated }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type CodexHookInstallState =
|
|
207
|
+
| 'installed'
|
|
208
|
+
| 'drift'
|
|
209
|
+
| 'missing'
|
|
210
|
+
/** The stable script is not this package's: a Codex halt needs THIS script's deny-only reply. */
|
|
211
|
+
| 'script_outdated'
|
|
212
|
+
| 'hooks_unparseable'
|
|
213
|
+
| 'hooks_symlink'
|
|
214
|
+
| 'hooks_unreadable'
|
|
215
|
+
|
|
216
|
+
export interface CodexHookStatus {
|
|
217
|
+
state: CodexHookInstallState
|
|
218
|
+
installed: boolean
|
|
219
|
+
hooksPath: string
|
|
220
|
+
scriptPath: string
|
|
221
|
+
scriptSha: string | null
|
|
222
|
+
packageScriptSha: string | null
|
|
223
|
+
/**
|
|
224
|
+
* The stable script is this package's, byte for byte. Unlike Claude there is no halt-capable
|
|
225
|
+
* earlier script: every script before 6.62.0 prints the reply Codex fails open on (C10).
|
|
226
|
+
*/
|
|
227
|
+
scriptOk: boolean
|
|
228
|
+
subscribed: string[]
|
|
229
|
+
missing: string[]
|
|
230
|
+
drifted: string[]
|
|
231
|
+
/** QA W13: events carrying more than one block of ours. Install leaves the extra (removing it would move user blocks). */
|
|
232
|
+
duplicated: string[]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sha256File(path: string): string | null {
|
|
236
|
+
try { return createHash('sha256').update(readFileSync(path)).digest('hex') } catch { return null }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
type HooksRead = { ok: true; settings: unknown; existed: boolean } | { ok: false; reason: 'hooks_unparseable' | 'hooks_symlink' | 'hooks_unreadable' }
|
|
240
|
+
|
|
241
|
+
function readHooksFile(path: string): HooksRead {
|
|
242
|
+
try {
|
|
243
|
+
if (lstatSync(path).isSymbolicLink()) return { ok: false, reason: 'hooks_symlink' }
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if ((error as { code?: string }).code === 'ENOENT') return { ok: true, settings: {}, existed: false }
|
|
246
|
+
return { ok: false, reason: 'hooks_unreadable' }
|
|
247
|
+
}
|
|
248
|
+
let text: string
|
|
249
|
+
try { text = readFileSync(path, 'utf-8') } catch { return { ok: false, reason: 'hooks_unreadable' } }
|
|
250
|
+
try { return { ok: true, settings: JSON.parse(text), existed: true } } catch { return { ok: false, reason: 'hooks_unparseable' } }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface CodexHookPaths {
|
|
254
|
+
hooksPath?: string
|
|
255
|
+
scriptPath?: string
|
|
256
|
+
packageScriptPath?: string
|
|
257
|
+
hookPaths?: HookPaths
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function codexHookStatus(paths: CodexHookPaths = {}): CodexHookStatus {
|
|
261
|
+
const hooksPath = paths.hooksPath ?? codexHooksPath()
|
|
262
|
+
const scriptPath = paths.scriptPath ?? stableCodexHookScriptPath()
|
|
263
|
+
const packageScriptPath = paths.packageScriptPath ?? packagedHookScriptPath()
|
|
264
|
+
const hookPaths = paths.hookPaths ?? currentHookPaths()
|
|
265
|
+
const scriptSha = sha256File(scriptPath)
|
|
266
|
+
const packageScriptSha = sha256File(packageScriptPath)
|
|
267
|
+
const scriptOk = scriptSha !== null && scriptSha === packageScriptSha
|
|
268
|
+
const read = readHooksFile(hooksPath)
|
|
269
|
+
if (!read.ok) {
|
|
270
|
+
return { state: read.reason, installed: false, hooksPath, scriptPath, scriptSha, packageScriptSha, scriptOk, subscribed: [], missing: CODEX_HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], duplicated: [] }
|
|
271
|
+
}
|
|
272
|
+
const events = codexSubscribedEvents(read.settings, scriptPath, hookPaths)
|
|
273
|
+
let state: CodexHookInstallState
|
|
274
|
+
if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
|
|
275
|
+
else if (events.missing.length > 0 || events.drifted.length > 0) state = 'drift'
|
|
276
|
+
else if (!scriptOk) state = 'script_outdated'
|
|
277
|
+
else state = 'installed'
|
|
278
|
+
return { state, installed: state === 'installed', hooksPath, scriptPath, scriptSha, packageScriptSha, scriptOk, ...events }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface CodexInstallResult {
|
|
282
|
+
ok: boolean
|
|
283
|
+
reason?: string
|
|
284
|
+
changed: boolean
|
|
285
|
+
scriptCopied: boolean
|
|
286
|
+
backupPath: string | null
|
|
287
|
+
status: CodexHookStatus
|
|
288
|
+
merged?: Record<string, unknown>
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function installCodexHooks(options: CodexHookPaths & { dryRun?: boolean } = {}): CodexInstallResult {
|
|
292
|
+
const hooksPath = options.hooksPath ?? codexHooksPath()
|
|
293
|
+
const scriptPath = options.scriptPath ?? stableCodexHookScriptPath()
|
|
294
|
+
const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
|
|
295
|
+
const hookPaths = options.hookPaths ?? currentHookPaths()
|
|
296
|
+
const statusNow = () => codexHookStatus({ hooksPath, scriptPath, packageScriptPath, hookPaths })
|
|
297
|
+
const refuse = (reason: string, scriptCopied = false): CodexInstallResult => ({ ok: false, reason, changed: false, scriptCopied, backupPath: null, status: statusNow() })
|
|
298
|
+
const read = readHooksFile(hooksPath)
|
|
299
|
+
if (!read.ok) return refuse(read.reason)
|
|
300
|
+
const merged = mergeCodexHooks(read.settings, scriptPath, hookPaths)
|
|
301
|
+
if (!merged.ok) return refuse(merged.reason)
|
|
302
|
+
if (options.dryRun) return { ok: true, changed: merged.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: merged.settings }
|
|
303
|
+
let scriptCopied = false
|
|
304
|
+
try {
|
|
305
|
+
scriptCopied = ensureStableHookScript(scriptPath, packageScriptPath)
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return refuse(error instanceof Error ? error.message : String(error))
|
|
308
|
+
}
|
|
309
|
+
let backupPath: string | null = null
|
|
310
|
+
if (merged.changed) {
|
|
311
|
+
if (read.existed) {
|
|
312
|
+
backupPath = backupSettings(hooksPath)
|
|
313
|
+
if (!backupPath) return refuse('backup_failed', scriptCopied)
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
mkdirSync(dirname(hooksPath), { recursive: true, mode: 0o700 })
|
|
317
|
+
atomicWriteFileSync(hooksPath, JSON.stringify(merged.settings, null, 2) + '\n', { mode: 0o600 })
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return refuse((error as { code?: string }).code ?? 'write_failed', scriptCopied)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { ok: true, changed: merged.changed, scriptCopied, backupPath, status: statusNow() }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function uninstallCodexHooks(options: CodexHookPaths & { dryRun?: boolean } = {}): CodexInstallResult {
|
|
326
|
+
const hooksPath = options.hooksPath ?? codexHooksPath()
|
|
327
|
+
const scriptPath = options.scriptPath ?? stableCodexHookScriptPath()
|
|
328
|
+
const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
|
|
329
|
+
const hookPaths = options.hookPaths ?? currentHookPaths()
|
|
330
|
+
const statusNow = () => codexHookStatus({ hooksPath, scriptPath, packageScriptPath, hookPaths })
|
|
331
|
+
const read = readHooksFile(hooksPath)
|
|
332
|
+
if (!read.ok) return { ok: false, reason: read.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
333
|
+
if (!read.existed) return { ok: true, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
334
|
+
const stripped = stripCodexHooks(read.settings)
|
|
335
|
+
if (!stripped.ok) return { ok: false, reason: stripped.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
336
|
+
if (options.dryRun) return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: stripped.settings }
|
|
337
|
+
let backupPath: string | null = null
|
|
338
|
+
if (stripped.changed) {
|
|
339
|
+
backupPath = backupSettings(hooksPath)
|
|
340
|
+
if (!backupPath) return { ok: false, reason: 'backup_failed', changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
341
|
+
atomicWriteFileSync(hooksPath, JSON.stringify(stripped.settings, null, 2) + '\n', { mode: 0o600 })
|
|
342
|
+
}
|
|
343
|
+
return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath, status: statusNow() }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Trust: Codex's own answer, read through its app-server (K3)
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/** `HookTrustStatus` from the app-server schema, plus `unknown` when the read failed. */
|
|
351
|
+
export type CodexHookTrust = 'trusted' | 'untrusted' | 'modified' | 'managed' | 'unknown'
|
|
352
|
+
|
|
353
|
+
export interface CodexTrustRead {
|
|
354
|
+
trust: CodexHookTrust
|
|
355
|
+
/** How many of our hooks the app-server listed. */
|
|
356
|
+
listed: number
|
|
357
|
+
/** A code when `unknown`: `spawn_failed`, `timeout`, `rpc_error`, `not_listed`, `no_binary`. */
|
|
358
|
+
reason: string | null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export const CODEX_TRUST_TIMEOUT_MS = 15_000
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Fold the statuses of OUR hooks into one word. Any modified hook (its bytes changed since it
|
|
365
|
+
* was trusted) or untrusted one means a halt cannot be relied on; a disabled hook does not run,
|
|
366
|
+
* so it counts as untrusted. Codex lists the user file's hooks as `source: user`.
|
|
367
|
+
*/
|
|
368
|
+
export function foldCodexTrust(hooks: readonly unknown[]): CodexTrustRead {
|
|
369
|
+
const ours = hooks.filter((h): h is Record<string, unknown> => !!h && typeof h === 'object'
|
|
370
|
+
&& (h as Record<string, unknown>).source === 'user' && codexHookIsOurs(h))
|
|
371
|
+
if (ours.length === 0) return { trust: 'unknown', listed: 0, reason: 'not_listed' }
|
|
372
|
+
const statuses = ours.map(h => (h.enabled === false ? 'untrusted' : String(h.trustStatus)))
|
|
373
|
+
if (statuses.includes('modified')) return { trust: 'modified', listed: ours.length, reason: null }
|
|
374
|
+
if (statuses.some(s => s !== 'trusted' && s !== 'managed')) return { trust: 'untrusted', listed: ours.length, reason: null }
|
|
375
|
+
if (ours.length < CODEX_HOOK_SUBSCRIPTIONS.length) return { trust: 'untrusted', listed: ours.length, reason: 'partial' }
|
|
376
|
+
return { trust: statuses.every(s => s === 'managed') ? 'managed' : 'trusted', listed: ours.length, reason: null }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* One `hooks/list` round trip against `codex app-server --listen stdio://`: initialize, then
|
|
381
|
+
* the list for one working directory, then the child is stopped. Never throws; any failure is
|
|
382
|
+
* `unknown` with a code. `binary` is the resolved Codex binary (a fixture in tests).
|
|
383
|
+
*/
|
|
384
|
+
export function readCodexHookTrust(options: { binary: string; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<CodexTrustRead> {
|
|
385
|
+
return new Promise(resolvePromise => {
|
|
386
|
+
let settled = false
|
|
387
|
+
let buffer = ''
|
|
388
|
+
let child: ReturnType<typeof spawn> | null = null
|
|
389
|
+
const finish = (value: CodexTrustRead) => {
|
|
390
|
+
if (settled) return
|
|
391
|
+
settled = true
|
|
392
|
+
clearTimeout(timer)
|
|
393
|
+
const proc = child
|
|
394
|
+
try { proc?.kill('SIGTERM') } catch { /* gone */ }
|
|
395
|
+
// QA W4: an app-server that ignores SIGTERM is killed, never left behind once a minute.
|
|
396
|
+
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
397
|
+
const kill = setTimeout(() => {
|
|
398
|
+
if (proc.exitCode === null && proc.signalCode === null) { try { proc.kill('SIGKILL') } catch { /* gone */ } }
|
|
399
|
+
}, CODEX_TRUST_KILL_GRACE_MS)
|
|
400
|
+
kill.unref?.()
|
|
401
|
+
proc.once('exit', () => clearTimeout(kill))
|
|
402
|
+
}
|
|
403
|
+
resolvePromise(value)
|
|
404
|
+
}
|
|
405
|
+
const timer = setTimeout(() => finish({ trust: 'unknown', listed: 0, reason: 'timeout' }), options.timeoutMs ?? CODEX_TRUST_TIMEOUT_MS)
|
|
406
|
+
timer.unref?.()
|
|
407
|
+
try {
|
|
408
|
+
child = spawn(options.binary, ['app-server', '--listen', 'stdio://'], { cwd: options.cwd ?? homedir(), env: options.env ?? process.env, stdio: ['pipe', 'pipe', 'ignore'] })
|
|
409
|
+
} catch {
|
|
410
|
+
finish({ trust: 'unknown', listed: 0, reason: 'spawn_failed' })
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const proc = child
|
|
414
|
+
const send = (message: Record<string, unknown>) => {
|
|
415
|
+
try { proc.stdin?.write(JSON.stringify(message) + '\n') } catch { /* the exit handler answers */ }
|
|
416
|
+
}
|
|
417
|
+
proc.on('error', () => finish({ trust: 'unknown', listed: 0, reason: 'spawn_failed' }))
|
|
418
|
+
proc.on('exit', () => finish({ trust: 'unknown', listed: 0, reason: 'rpc_error' }))
|
|
419
|
+
proc.stdin?.on('error', () => { /* a dead child; the exit handler answers */ })
|
|
420
|
+
proc.stdout?.setEncoding('utf8')
|
|
421
|
+
proc.stdout?.on('data', (chunk: string) => {
|
|
422
|
+
buffer += chunk
|
|
423
|
+
if (buffer.length > 4 * 1024 * 1024) { finish({ trust: 'unknown', listed: 0, reason: 'rpc_error' }); return }
|
|
424
|
+
let newline: number
|
|
425
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
426
|
+
const line = buffer.slice(0, newline).trim()
|
|
427
|
+
buffer = buffer.slice(newline + 1)
|
|
428
|
+
if (!line) continue
|
|
429
|
+
let message: Record<string, unknown>
|
|
430
|
+
try { message = JSON.parse(line) as Record<string, unknown> } catch { continue }
|
|
431
|
+
if (message.id === 1) {
|
|
432
|
+
if (message.error) { finish({ trust: 'unknown', listed: 0, reason: 'rpc_error' }); return }
|
|
433
|
+
send({ method: 'initialized' })
|
|
434
|
+
send({ id: 2, method: 'hooks/list', params: { cwds: [options.cwd ?? homedir()] } })
|
|
435
|
+
} else if (message.id === 2) {
|
|
436
|
+
const result = message.result as { data?: unknown } | undefined
|
|
437
|
+
if (message.error || !result || !Array.isArray(result.data)) { finish({ trust: 'unknown', listed: 0, reason: 'rpc_error' }); return }
|
|
438
|
+
const hooks = result.data.flatMap(entry => (entry && typeof entry === 'object' && Array.isArray((entry as { hooks?: unknown }).hooks) ? (entry as { hooks: unknown[] }).hooks : []))
|
|
439
|
+
finish(foldCodexTrust(hooks))
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'cos-glasses-server', version: '0' } } })
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* QA W4: how often the background tick reads Codex's trust, and how long a read stays the
|
|
450
|
+
* answer. The TTL is three refreshes, so a read that lands late (a 15 s timeout, a slow tick)
|
|
451
|
+
* never leaves a gap where trust reads `unknown` and a desk cancel turns off for a cycle.
|
|
452
|
+
*/
|
|
453
|
+
export const CODEX_TRUST_REFRESH_MS = 5 * 60_000
|
|
454
|
+
export const CODEX_TRUST_CACHE_MS = 3 * CODEX_TRUST_REFRESH_MS
|
|
455
|
+
/** After SIGTERM, an app-server still running this long later is SIGKILLed. */
|
|
456
|
+
export const CODEX_TRUST_KILL_GRACE_MS = 2_000
|
|
457
|
+
|
|
458
|
+
/** A read that did not get Codex's answer at all. It never replaces the last answer it got. */
|
|
459
|
+
const TRUST_READ_FAILURES: ReadonlySet<string> = new Set(['spawn_failed', 'timeout', 'rpc_error', 'no_binary'])
|
|
460
|
+
|
|
461
|
+
let trustCache: { at: number; value: CodexTrustRead } | null = null
|
|
462
|
+
let lastFailure: { at: number; reason: string } | null = null
|
|
463
|
+
let trustInFlight: Promise<CodexTrustRead> | null = null
|
|
464
|
+
let trustGeneration = 0
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Codex's trust as health and the desk cancel read it: the newest ANSWER (trusted, untrusted,
|
|
468
|
+
* modified, managed, or `unknown` with `not_listed`) for CODEX_TRUST_CACHE_MS. A read that
|
|
469
|
+
* failed (timeout, a dead app-server) keeps the last answer and is named in `trustReason`.
|
|
470
|
+
* trustReason null: a fresh answer and the newest read got it
|
|
471
|
+
* `<code>`: the newest read failed with that code (the answer shown is older)
|
|
472
|
+
* `not_listed`: Codex answered and lists none of our hooks
|
|
473
|
+
* `stale`: the newest answer is past the TTL; `unchecked`: never read
|
|
474
|
+
*/
|
|
475
|
+
export function cachedCodexHookTrust(nowMs = Date.now()): (CodexTrustRead & { checkedAt: number | null; trustReason: string | null }) {
|
|
476
|
+
const failedSince = lastFailure && (!trustCache || lastFailure.at >= trustCache.at) ? lastFailure.reason : null
|
|
477
|
+
if (trustCache && nowMs - trustCache.at < CODEX_TRUST_CACHE_MS) {
|
|
478
|
+
return { ...trustCache.value, checkedAt: trustCache.at, trustReason: failedSince ?? trustCache.value.reason }
|
|
479
|
+
}
|
|
480
|
+
const reason = trustCache ? 'stale' : (failedSince ?? 'unchecked')
|
|
481
|
+
return { trust: 'unknown', listed: 0, reason, checkedAt: trustCache?.at ?? null, trustReason: reason }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Read Codex's trust now: on the background tick, or after an install. One read at a time;
|
|
486
|
+
* a tick during a read gets that read. `fresh` (after an install) starts a NEW read whatever
|
|
487
|
+
* is in flight, and only the newest read started may write the answer, so a read begun
|
|
488
|
+
* before the install can never stand in for one after it (QA W4).
|
|
489
|
+
*/
|
|
490
|
+
export function refreshCodexHookTrust(options: { binary?: string; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; now?: () => number; fresh?: boolean } = {}): Promise<CodexTrustRead> {
|
|
491
|
+
if (trustInFlight && options.fresh !== true) return trustInFlight
|
|
492
|
+
const generation = ++trustGeneration
|
|
493
|
+
const binary = options.binary ?? (() => {
|
|
494
|
+
const resolved = resolveProviderBinary('codex', options.env ?? process.env)
|
|
495
|
+
return resolved.ok ? resolved.path : null
|
|
496
|
+
})()
|
|
497
|
+
const read = binary
|
|
498
|
+
? readCodexHookTrust({ binary, cwd: options.cwd, env: options.env, timeoutMs: options.timeoutMs })
|
|
499
|
+
: Promise.resolve<CodexTrustRead>({ trust: 'unknown', listed: 0, reason: 'no_binary' })
|
|
500
|
+
const pending: Promise<CodexTrustRead> = read.then(value => {
|
|
501
|
+
if (generation !== trustGeneration) return value
|
|
502
|
+
const at = (options.now ?? Date.now)()
|
|
503
|
+
if (value.trust === 'unknown' && value.reason && TRUST_READ_FAILURES.has(value.reason)) {
|
|
504
|
+
lastFailure = { at, reason: value.reason }
|
|
505
|
+
console.warn(`[codex-hooks] trust read failed (${value.reason}); keeping the last answer`)
|
|
506
|
+
} else {
|
|
507
|
+
trustCache = { at, value }
|
|
508
|
+
}
|
|
509
|
+
return value
|
|
510
|
+
}).finally(() => { if (trustInFlight === pending) trustInFlight = null })
|
|
511
|
+
trustInFlight = pending
|
|
512
|
+
return pending
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function __resetCodexHookTrustForTests(): void {
|
|
516
|
+
trustCache = null
|
|
517
|
+
lastFailure = null
|
|
518
|
+
trustInFlight = null
|
|
519
|
+
trustGeneration = 0
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** For `--hooks status`: say in words what a Codex state means. Null when ready. */
|
|
523
|
+
export function codexHookAdvice(status: Pick<CodexHookStatus, 'installed' | 'state'> & Partial<Pick<CodexHookStatus, 'duplicated'>>, trust: CodexHookTrust): string | null {
|
|
524
|
+
if (status.duplicated && status.duplicated.length > 0) {
|
|
525
|
+
// QA W13: Install leaves these alone (removing one would move the user's blocks).
|
|
526
|
+
return `~/.codex/hooks.json carries the COS hook more than once for ${status.duplicated.join(', ')}, so it runs twice there. `
|
|
527
|
+
+ 'Remove the extra COS block by hand; Install never moves or removes your blocks.'
|
|
528
|
+
}
|
|
529
|
+
if (!status.installed) {
|
|
530
|
+
return status.state === 'missing' || status.state === 'drift' || status.state === 'script_outdated'
|
|
531
|
+
? 'The COS hooks in ~/.codex/hooks.json are not current. Run `npx --yes @gotcos/glasses-server@latest --hooks install`, or press Install hooks in COS Control.'
|
|
532
|
+
: 'The Codex hooks file could not be read as it is; nothing was changed. Fix or restore it, then install again.'
|
|
533
|
+
}
|
|
534
|
+
if (trust === 'trusted' || trust === 'managed') return null
|
|
535
|
+
if (trust === 'unknown') return 'Codex did not say whether it trusts the COS hooks (see trustReason). If they never ran, run `codex` in Terminal and choose Trust all and continue.'
|
|
536
|
+
return 'Codex has not trusted the COS hooks yet, so they do not run. Run `codex` in Terminal and choose Trust all and continue.'
|
|
537
|
+
}
|
|
538
|
+
|
|
@@ -222,7 +222,7 @@ export async function deliverOverCodexQueue(request: CodexLiveRequest, deps: Cod
|
|
|
222
222
|
/**
|
|
223
223
|
* `COS_CODEX_LIVE_QUEUE`. ON unless set to `0`.
|
|
224
224
|
*
|
|
225
|
-
* Default-on
|
|
225
|
+
* Default-on (as `COS_CONTINUE_LIVE` also is since 6.62.0), because the path it replaces cannot work: for a
|
|
226
226
|
* thread the Codex app holds, the spawn fails every time and leaves a fence. Only a
|
|
227
227
|
* thread with a foreign writer ever reaches this module, so a thread nobody holds keeps
|
|
228
228
|
* the 6.50 path exactly.
|
|
@@ -199,12 +199,20 @@ function normalizeAppServerModel(raw: any): CodexCatalogModel | null {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
function
|
|
202
|
+
function readDiskModels(): CodexCatalogModel[] | null {
|
|
203
203
|
const path = modelCachePath()
|
|
204
204
|
if (!existsSync(path)) return null
|
|
205
205
|
try {
|
|
206
206
|
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
207
|
-
const models =
|
|
207
|
+
const models = parseDiskModels(parsed)
|
|
208
|
+
return models.length > 0 ? models : null
|
|
209
|
+
} catch {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseDiskModels(parsed: any): CodexCatalogModel[] {
|
|
215
|
+
return Array.isArray(parsed?.models)
|
|
208
216
|
? parsed.models.map((raw: any): CodexCatalogModel | null => {
|
|
209
217
|
const id = typeof raw?.slug === 'string' ? raw.slug : ''
|
|
210
218
|
if (!id) return null
|
|
@@ -230,6 +238,14 @@ function readDiskCatalog(): CodexModelCatalog | null {
|
|
|
230
238
|
}
|
|
231
239
|
}).filter((model: CodexCatalogModel | null): model is CodexCatalogModel => !!model)
|
|
232
240
|
: []
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readDiskCatalog(): CodexModelCatalog | null {
|
|
244
|
+
const path = modelCachePath()
|
|
245
|
+
if (!existsSync(path)) return null
|
|
246
|
+
try {
|
|
247
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
248
|
+
const models = parseDiskModels(parsed)
|
|
233
249
|
if (models.length === 0) return null
|
|
234
250
|
const refreshedAt = typeof parsed?.fetched_at === 'string' ? parsed.fetched_at : new Date().toISOString()
|
|
235
251
|
return buildCodexModelCatalog(models, 'disk-cache', refreshedAt)
|
|
@@ -337,6 +353,36 @@ async function fetchAppServerModels(): Promise<CodexCatalogModel[]> {
|
|
|
337
353
|
|
|
338
354
|
let catalogSnapshot = readDiskCatalog() ?? cliDefaultCatalog()
|
|
339
355
|
let refreshPromise: Promise<CodexModelCatalog> | null = null
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 6.62.0 (plan 3.2): EVERY model the last good discovery returned, kept in memory beside the
|
|
359
|
+
* two slot options. The slots stay two (the phone reads them); this list is what a Continue
|
|
360
|
+
* checks a session's own model against before it passes `-m`. Hidden models are excluded,
|
|
361
|
+
* exactly as `model/list` was asked (`includeHidden: false`).
|
|
362
|
+
*/
|
|
363
|
+
let fullModelList: CodexCatalogModel[] = readDiskModels() ?? []
|
|
364
|
+
|
|
365
|
+
export function getFullCodexModelList(): readonly CodexCatalogModel[] {
|
|
366
|
+
return fullModelList
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Is `modelId` one this Codex install lists? Exact id, case-insensitive. */
|
|
370
|
+
export function isKnownCodexModel(modelId: string): boolean {
|
|
371
|
+
const wanted = typeof modelId === 'string' ? modelId.trim().toLowerCase() : ''
|
|
372
|
+
return wanted.length > 0 && fullModelList.some(model => model.id.toLowerCase() === wanted && !model.hidden)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The catalog's display name for a listed model, or null. */
|
|
376
|
+
export function codexModelDisplayName(modelId: string): string | null {
|
|
377
|
+
const wanted = typeof modelId === 'string' ? modelId.trim().toLowerCase() : ''
|
|
378
|
+
const found = fullModelList.find(model => model.id.toLowerCase() === wanted)
|
|
379
|
+
return found ? formatDisplayName(found.displayName || found.id) : null
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Tests only. */
|
|
383
|
+
export function __setFullCodexModelListForTests(models: CodexCatalogModel[]): void {
|
|
384
|
+
fullModelList = [...models]
|
|
385
|
+
}
|
|
340
386
|
let periodicRefreshTimer: ReturnType<typeof setInterval> | null = null
|
|
341
387
|
|
|
342
388
|
export function getCodexModelCatalogSnapshot(): CodexModelCatalog {
|
|
@@ -352,6 +398,7 @@ export async function refreshCodexModelCatalog(
|
|
|
352
398
|
const refreshed = buildCodexModelCatalog(models, 'app-server')
|
|
353
399
|
if (refreshed.options.length === 0) throw new Error('No eligible GPT models')
|
|
354
400
|
catalogSnapshot = refreshed
|
|
401
|
+
fullModelList = models.filter(model => !model.hidden)
|
|
355
402
|
} catch {
|
|
356
403
|
// Never downgrade a working app-server/disk catalog because one refresh
|
|
357
404
|
// failed. A fresh disk read is useful only when there is no known model id.
|