@gotcos/glasses-server 6.61.6 → 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.
Files changed (72) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +36 -0
  6. package/bin/hooks/cos-session-hook +46 -11
  7. package/docs/provider-parity.md +43 -0
  8. package/managed-runtime-contract.json +1 -0
  9. package/package.json +5 -2
  10. package/server/index.ts +135 -16
  11. package/server/lib/agent-session-binding-store.ts +9 -3
  12. package/server/lib/agent-session-store.ts +7 -4
  13. package/server/lib/attached-provider-adapter.ts +158 -45
  14. package/server/lib/banned-permission-args.ts +42 -1
  15. package/server/lib/claude-bridge.ts +17 -8
  16. package/server/lib/claude-hooks-installer.ts +28 -5
  17. package/server/lib/codex-bridge.ts +17 -2
  18. package/server/lib/codex-hooks-installer.ts +538 -0
  19. package/server/lib/codex-live-queue.ts +1 -1
  20. package/server/lib/codex-model-catalog.ts +49 -2
  21. package/server/lib/codex-rollout-state.ts +90 -0
  22. package/server/lib/codex-session-posture.ts +528 -0
  23. package/server/lib/codex-subagent-activity.ts +119 -0
  24. package/server/lib/continue-plan.ts +180 -0
  25. package/server/lib/continue-read-health.ts +26 -0
  26. package/server/lib/cursor-bridge.ts +26 -6
  27. package/server/lib/cursor-fork-context.ts +140 -0
  28. package/server/lib/cursor-model-catalog.ts +62 -5
  29. package/server/lib/cursor-observer-installer.ts +79 -0
  30. package/server/lib/cursor-session-model.ts +339 -0
  31. package/server/lib/cursor-spawn-env.ts +210 -0
  32. package/server/lib/fork-thread.ts +143 -25
  33. package/server/lib/health-static-probes.ts +21 -11
  34. package/server/lib/lens-gist-engines.ts +15 -4
  35. package/server/lib/lens-gist.ts +1 -1
  36. package/server/lib/live-cues-cursor.ts +16 -6
  37. package/server/lib/occupancy-probes.ts +45 -1
  38. package/server/lib/permission-broker.ts +24 -9
  39. package/server/lib/provider-actions.ts +149 -0
  40. package/server/lib/provider-failure-text.ts +27 -0
  41. package/server/lib/provider-observe.ts +212 -0
  42. package/server/lib/provider-proof.ts +23 -14
  43. package/server/lib/query-job-runtime.ts +16 -1
  44. package/server/lib/query-job-types.ts +2 -1
  45. package/server/lib/session-act-fields.ts +61 -0
  46. package/server/lib/session-cancel.ts +95 -4
  47. package/server/lib/session-continue-facts-real.ts +74 -0
  48. package/server/lib/session-continue-facts.ts +267 -0
  49. package/server/lib/session-halt.ts +67 -6
  50. package/server/lib/session-hook-events.ts +60 -0
  51. package/server/lib/session-hook-ledger.ts +21 -3
  52. package/server/lib/session-hook-spool.ts +68 -6
  53. package/server/lib/session-hooks-runtime.ts +40 -3
  54. package/server/lib/session-peer-inbox-deps.ts +7 -6
  55. package/server/lib/session-recommendation.ts +3 -2
  56. package/server/lib/session-signal-store.ts +81 -3
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/session-transcript-watcher.ts +25 -1
  59. package/server/lib/session-work-observation.ts +46 -0
  60. package/server/lib/thread-attach-capability.ts +15 -1
  61. package/server/lib/thread-drain-kick.ts +4 -2
  62. package/server/lib/thread-occupancy.ts +44 -0
  63. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  64. package/server/lib/thread-turn-queue.ts +6 -0
  65. package/server/lib/turn-request.ts +274 -0
  66. package/server/routes/agent-session-bindings.ts +224 -22
  67. package/server/routes/agent-session-stream.ts +26 -7
  68. package/server/routes/agent-sessions.ts +163 -10
  69. package/server/routes/health.ts +27 -2
  70. package/server/routes/session-hooks.ts +60 -3
  71. package/server/routes/thread-turn-queue.ts +37 -9
  72. package/server/scripts/hooks-cli.ts +38 -9
@@ -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, unlike `COS_CONTINUE_LIVE`, because the path it replaces cannot work: for a
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 readDiskCatalog(): CodexModelCatalog | null {
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 = Array.isArray(parsed?.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.