@gotcos/glasses-server 6.61.7 → 6.63.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.
Files changed (70) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +11 -4
  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 +4 -2
  10. package/server/index.ts +141 -17
  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 +22 -2
  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/continue-plan.ts +180 -0
  24. package/server/lib/continue-read-health.ts +26 -0
  25. package/server/lib/cursor-bridge.ts +26 -6
  26. package/server/lib/cursor-fork-context.ts +140 -0
  27. package/server/lib/cursor-model-catalog.ts +62 -5
  28. package/server/lib/cursor-observer-installer.ts +48 -7
  29. package/server/lib/cursor-session-model.ts +339 -0
  30. package/server/lib/cursor-spawn-env.ts +210 -0
  31. package/server/lib/fork-job-ledger.ts +194 -0
  32. package/server/lib/fork-thread.ts +169 -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 +57 -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 +32 -2
  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 +74 -5
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/thread-attach-capability.ts +15 -1
  59. package/server/lib/thread-drain-kick.ts +4 -2
  60. package/server/lib/thread-occupancy.ts +44 -0
  61. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  62. package/server/lib/thread-turn-queue.ts +6 -0
  63. package/server/lib/turn-request.ts +274 -0
  64. package/server/routes/agent-session-bindings.ts +349 -26
  65. package/server/routes/agent-session-stream.ts +26 -7
  66. package/server/routes/agent-sessions.ts +155 -10
  67. package/server/routes/health.ts +27 -2
  68. package/server/routes/session-hooks.ts +56 -5
  69. package/server/routes/thread-turn-queue.ts +37 -9
  70. package/server/scripts/hooks-cli.ts +33 -7
@@ -0,0 +1,90 @@
1
+ // What a Codex rollout's tail says about the thread RIGHT NOW (6.62.0, plan 1.3 and 1.9), for
2
+ // the session rows: is a turn open, and when did the newest context compaction finish.
3
+ //
4
+ // A DISPLAY HINT ONLY. The write gate keeps its own reading (`codex-turn-clock.ts`, the
5
+ // occupancy probe); nothing here can let a Continue through or hold one back.
6
+ //
7
+ // The turn rule is the queue's (`transcriptTurnVerdict` / `turnFromTail('codex')`): a
8
+ // `task_started` opens a turn, `task_complete` / `turn_complete` / `turn_aborted` close it.
9
+ // It reads the last 512 KiB synchronously, so it is MEMOIZED on (path, mtime, size): a steady
10
+ // list poll re-reads nothing, and only a rollout that changed is read again. A rollout not
11
+ // written inside the hook-active cap is never read at all: whatever its tail says, it is not
12
+ // working now (the cap decides), and a cold list stays free.
13
+
14
+ import { statSync } from 'node:fs'
15
+ import { turnFromTail, type TurnFromTail } from './session-stream-events.js'
16
+ import { TURN_END_TAIL_BYTES, readTranscriptTailLines } from './thread-turn-queue-store.js'
17
+
18
+ /** Hook or transcript evidence counts as "working" for this long after the last event (plan 1.1-1.3, W5). */
19
+ export const HOOK_ACTIVE_CAP_MS = 5 * 60_000
20
+
21
+ export interface CodexRolloutFacts {
22
+ /** The tail's verdict; null when the tail was not read (cold) or could not be. */
23
+ reason: TurnFromTail['reason'] | null
24
+ /** A turn is open in the tail AND the file was written inside the cap. */
25
+ open: boolean
26
+ mtimeMs: number
27
+ /** When the newest `ContextCompaction` item completed, from the tail; null when none is there. */
28
+ compactedAt: number | null
29
+ }
30
+
31
+ const MEMO_MAX = 256
32
+ const memo = new Map<string, { mtimeMs: number; size: number; reason: TurnFromTail['reason'] | null; compactedAt: number | null }>()
33
+ let reads = 0
34
+
35
+ /**
36
+ * The newest completed context compaction in a tail. Codex writes the `ContextCompaction` item
37
+ * (and its `compacted` replacement) only when a compaction is OVER: nothing in a rollout marks
38
+ * one starting, so this can close a compaction the PreCompact hook opened, never open one.
39
+ */
40
+ export function newestCompactionAt(lines: readonly string[]): number | null {
41
+ let newest: number | null = null
42
+ for (const line of lines) {
43
+ if (!line.includes('ContextCompaction')) continue
44
+ let record: Record<string, unknown>
45
+ try { record = JSON.parse(line) as Record<string, unknown> } catch { continue }
46
+ const payload = record.payload as Record<string, unknown> | undefined
47
+ const item = payload?.item as Record<string, unknown> | undefined
48
+ if (record.type !== 'event_msg' || payload?.type !== 'item_completed' || item?.type !== 'ContextCompaction') continue
49
+ const at = typeof payload.completed_at_ms === 'number' ? payload.completed_at_ms : Date.parse(String(record.timestamp))
50
+ if (Number.isFinite(at) && (newest === null || at > newest)) newest = at
51
+ }
52
+ return newest
53
+ }
54
+
55
+ export function codexRolloutFacts(path: string | null | undefined, now = Date.now()): CodexRolloutFacts | null {
56
+ if (!path) return null
57
+ let mtimeMs: number
58
+ let size: number
59
+ try {
60
+ const st = statSync(path)
61
+ mtimeMs = st.mtimeMs
62
+ size = st.size
63
+ } catch {
64
+ return null
65
+ }
66
+ const fresh = now - mtimeMs <= HOOK_ACTIVE_CAP_MS
67
+ if (!fresh) return { reason: null, open: false, mtimeMs, compactedAt: null }
68
+ let hit = memo.get(path)
69
+ if (!hit || hit.mtimeMs !== mtimeMs || hit.size !== size) {
70
+ const lines = readTranscriptTailLines(path, TURN_END_TAIL_BYTES)
71
+ reads++
72
+ hit = { mtimeMs, size, reason: lines === null ? null : turnFromTail('codex', lines).reason, compactedAt: lines === null ? null : newestCompactionAt(lines) }
73
+ memo.delete(path)
74
+ if (memo.size >= MEMO_MAX) {
75
+ const oldest = memo.keys().next().value
76
+ if (oldest !== undefined) memo.delete(oldest)
77
+ }
78
+ memo.set(path, hit)
79
+ }
80
+ return { reason: hit.reason, open: hit.reason === 'prompt_open', mtimeMs, compactedAt: hit.compactedAt }
81
+ }
82
+
83
+ /** Test seams. */
84
+ export function __resetCodexRolloutStateForTests(): void {
85
+ memo.clear()
86
+ reads = 0
87
+ }
88
+ export function codexRolloutReads(): number {
89
+ return reads
90
+ }
@@ -0,0 +1,528 @@
1
+ // What a Codex session runs with, read from its own rollout (6.62.0, plan 3.2).
2
+ //
3
+ // WHY. `codex exec resume <id>` does NOT keep the session's settings. Canary C11
4
+ // (2026-10-05): a thread created `-s danger-full-access -m gpt-5.6-luna` (effort low)
5
+ // resumed with no flags ran turn 2 on `gpt-6.1-sol high workspace-write`, which are the
6
+ // CONFIG defaults. So a Continue that should carry the session's own permissions (D1)
7
+ // has to pass the session's model, effort and sandbox explicitly, and those live in the
8
+ // rollout's newest `turn_context` row.
9
+ //
10
+ // WHERE THAT ROW IS. Not in the tail a list read takes. Desktop rollout 01a10197 is 297 MB;
11
+ // its newest `turn_context` sat 3.23 MB from the end, with gaps up to 16 MB between them
12
+ // (validation W3). So the read scans BACKWARDS in 1 MB chunks up to a 64 MB cap, and the
13
+ // answer is cached by (path, size): rollouts only append, so the next read scans just the
14
+ // bytes written since (plus a small overlap for a row that was mid-write last time).
15
+ //
16
+ // WHAT IT NEVER DOES. It never broadens. No posture, or a posture this build does not
17
+ // recognise, plans the 6.61 sandbox (`getCodexTrustMode()`, normally read-only) with an
18
+ // honest note. `danger-full-access` passes through ONLY when the session ran with it AND
19
+ // Codex itself already trusts the folder in `~/.codex/config.toml`. Codex writes a permanent
20
+ // `[projects."…"] trust_level = "trusted"` entry when a NEW session starts with a writable
21
+ // sandbox in an untrusted folder (canaries C11/C12/K4; a resume does not write one, K4), and
22
+ // a trusted entry loads that repo's own hooks and config, so COS never relies on it.
23
+ //
24
+ // WHICH FOLDER (6.62.0 /qa W5, canary K7). Codex trusts the cwd itself or its GIT ROOT, not
25
+ // any parent: a session in a nested repo under a trusted folder wrote its own entry. So the
26
+ // rule is: trusted iff the cwd, or its `git rev-parse --show-toplevel`, is a trusted entry;
27
+ // a LINKED worktree resolves to its MAIN worktree (`git worktree list --porcelain`, first
28
+ // entry), never `--git-common-dir`'s parent (this repo's git dir lives outside the tree).
29
+ // The config is parsed read-only, with a regex, and never written.
30
+
31
+ import { open, stat } from 'node:fs/promises'
32
+ import { readFileSync } from 'node:fs'
33
+ import { homedir } from 'node:os'
34
+ import { join } from 'node:path'
35
+
36
+ /** One backward read. */
37
+ export const POSTURE_CHUNK_BYTES = 1024 * 1024
38
+ /** How far back a cold read looks before it gives up and plans the fallback. */
39
+ export const POSTURE_SCAN_CAP_BYTES = 64 * 1024 * 1024
40
+ /** Re-read before the cached size, so a row mid-write at the last read is seen whole. */
41
+ const POSTURE_REREAD_OVERLAP_BYTES = 64 * 1024
42
+ /** A `turn_context` row is about 4 KB; any line longer than this is something else. */
43
+ const MAX_TURN_CONTEXT_LINE_BYTES = 256 * 1024
44
+ const MAX_CACHE_ENTRIES = 256
45
+ const TURN_CONTEXT_MARKER = Buffer.from('"type":"turn_context"')
46
+
47
+ export type CodexSandboxType = 'read-only' | 'workspace-write' | 'danger-full-access'
48
+
49
+ export interface CodexSessionPosture {
50
+ model: string | null
51
+ effort: string | null
52
+ sandboxType: CodexSandboxType | null
53
+ /** `sandbox_policy.network_access` for workspace-write; true for danger-full-access. */
54
+ networkAccess: boolean | null
55
+ approvalPolicy: string | null
56
+ cwd: string | null
57
+ }
58
+
59
+ const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/
60
+ const EFFORT_RE = /^[a-z]{2,16}$/
61
+
62
+ /** One rollout row to a posture, or null when it is not a usable `turn_context`. */
63
+ export function parseTurnContextLine(line: string): CodexSessionPosture | null {
64
+ let row: any
65
+ try {
66
+ row = JSON.parse(line)
67
+ } catch {
68
+ return null
69
+ }
70
+ if (!row || typeof row !== 'object' || row.type !== 'turn_context') return null
71
+ const payload = row.payload
72
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
73
+ const policy = payload.sandbox_policy
74
+ const rawType = policy && typeof policy === 'object' ? policy.type : null
75
+ const sandboxType: CodexSandboxType | null = rawType === 'read-only' || rawType === 'workspace-write' || rawType === 'danger-full-access'
76
+ ? rawType
77
+ : null
78
+ let networkAccess: boolean | null = null
79
+ if (sandboxType === 'danger-full-access') networkAccess = true
80
+ else if (sandboxType === 'workspace-write' && typeof policy.network_access === 'boolean') networkAccess = policy.network_access
81
+ else if (sandboxType === 'read-only') networkAccess = false
82
+ const model = typeof payload.model === 'string' && MODEL_RE.test(payload.model) ? payload.model : null
83
+ const effortRaw = typeof payload.effort === 'string'
84
+ ? payload.effort
85
+ : typeof payload?.collaboration_mode?.settings?.reasoning_effort === 'string'
86
+ ? payload.collaboration_mode.settings.reasoning_effort
87
+ : null
88
+ const effort = effortRaw && EFFORT_RE.test(effortRaw) ? effortRaw : null
89
+ return {
90
+ model,
91
+ effort,
92
+ sandboxType,
93
+ networkAccess,
94
+ approvalPolicy: typeof payload.approval_policy === 'string' ? payload.approval_policy.slice(0, 32) : null,
95
+ cwd: typeof payload.cwd === 'string' && payload.cwd.startsWith('/') ? payload.cwd : null,
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The newest `turn_context` row in `[lower, upper)` of an open file, scanning backwards.
101
+ * Null when there is none. A line whose start lies before `lower` is not looked at: it
102
+ * cannot be judged whole.
103
+ */
104
+ async function scanBackwards(
105
+ handle: { read: (buffer: Buffer, offset: number, length: number, position: number) => Promise<{ bytesRead: number }> },
106
+ lower: number,
107
+ upper: number,
108
+ ): Promise<CodexSessionPosture | null> {
109
+ let end = upper
110
+ // Bytes after the first newline of the chunk we just read and before the newline that
111
+ // ended the last line we judged: the start of a line that began in an earlier chunk.
112
+ let carry: Buffer = Buffer.alloc(0)
113
+ // The carry outgrew any turn_context row; skip until that line's start is found.
114
+ let oversized = false
115
+ while (end > lower) {
116
+ const start = Math.max(lower, end - POSTURE_CHUNK_BYTES)
117
+ const chunk = Buffer.alloc(end - start)
118
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, start)
119
+ const data = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)
120
+ const buffer = carry.length > 0 ? Buffer.concat([data, carry]) : data
121
+ // The tail of THIS buffer (after its last newline) is the line that was carried.
122
+ let lineEnd = buffer.length
123
+ let cursor = buffer.lastIndexOf(0x0a, lineEnd - 1)
124
+ while (cursor >= 0) {
125
+ const line = buffer.subarray(cursor + 1, lineEnd)
126
+ if (!oversized && line.length <= MAX_TURN_CONTEXT_LINE_BYTES && line.indexOf(TURN_CONTEXT_MARKER) >= 0) {
127
+ const posture = parseTurnContextLine(line.toString('utf8'))
128
+ if (posture) return posture
129
+ }
130
+ oversized = false
131
+ lineEnd = cursor
132
+ cursor = lineEnd > 0 ? buffer.lastIndexOf(0x0a, lineEnd - 1) : -1
133
+ }
134
+ // What is left (before the first newline) began earlier; carry it, within bounds.
135
+ const rest = buffer.subarray(0, lineEnd)
136
+ if (start === 0) {
137
+ // The file's first line: whole by definition.
138
+ if (!oversized && rest.length <= MAX_TURN_CONTEXT_LINE_BYTES && rest.indexOf(TURN_CONTEXT_MARKER) >= 0) {
139
+ return parseTurnContextLine(rest.toString('utf8'))
140
+ }
141
+ return null
142
+ }
143
+ if (oversized || rest.length > MAX_TURN_CONTEXT_LINE_BYTES) {
144
+ carry = Buffer.alloc(0)
145
+ oversized = true
146
+ } else {
147
+ carry = Buffer.from(rest)
148
+ }
149
+ end = start
150
+ }
151
+ return null
152
+ }
153
+
154
+ interface PostureCacheEntry {
155
+ size: number
156
+ posture: CodexSessionPosture | null
157
+ }
158
+
159
+ const postureCache = new Map<string, PostureCacheEntry>()
160
+
161
+ /** Tests only. */
162
+ export function __resetCodexPostureCacheForTests(): void {
163
+ postureCache.clear()
164
+ postureReadStats.reads = 0
165
+ postureReadStats.bytes = 0
166
+ }
167
+
168
+ /** How much work the reader did: lets a test prove the cache and the incremental read. */
169
+ export const postureReadStats = { reads: 0, bytes: 0 }
170
+
171
+ /**
172
+ * The session's newest posture, or null when the rollout carries none within the cap or
173
+ * cannot be read. Never throws.
174
+ */
175
+ export async function readCodexSessionPosture(
176
+ path: string | null | undefined,
177
+ options: { capBytes?: number } = {},
178
+ ): Promise<CodexSessionPosture | null> {
179
+ // The cap is injectable so a test can prove it bounds the scan without a 64 MB fixture.
180
+ const cap = typeof options.capBytes === 'number' && options.capBytes > 0 ? options.capBytes : POSTURE_SCAN_CAP_BYTES
181
+ if (typeof path !== 'string' || !path.startsWith('/')) return null
182
+ let size: number
183
+ try {
184
+ const st = await stat(path)
185
+ if (!st.isFile()) return null
186
+ size = st.size
187
+ } catch {
188
+ return null
189
+ }
190
+ const cached = postureCache.get(path)
191
+ if (cached && cached.size === size) return cached.posture
192
+
193
+ let lower = Math.max(0, size - cap)
194
+ // Grown since the last read: only the new bytes (plus the overlap) can hold a newer row.
195
+ const incremental = !!cached && cached.size < size
196
+ if (incremental) lower = Math.max(lower, cached!.size - POSTURE_REREAD_OVERLAP_BYTES)
197
+
198
+ let found: CodexSessionPosture | null = null
199
+ try {
200
+ const handle = await open(path, 'r')
201
+ try {
202
+ postureReadStats.reads += 1
203
+ postureReadStats.bytes += size - lower
204
+ found = await scanBackwards(handle, lower, size)
205
+ } finally {
206
+ await handle.close()
207
+ }
208
+ } catch {
209
+ return cached?.posture ?? null
210
+ }
211
+ const posture = found ?? (incremental ? cached!.posture : null)
212
+ postureCache.delete(path)
213
+ postureCache.set(path, { size, posture })
214
+ while (postureCache.size > MAX_CACHE_ENTRIES) {
215
+ const oldest = postureCache.keys().next()
216
+ if (oldest.done) break
217
+ postureCache.delete(oldest.value)
218
+ }
219
+ return posture
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Trust: which folders Codex itself already trusts
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** `~/.codex/config.toml`, honouring CODEX_HOME like the rest of the server. */
227
+ export function codexConfigPath(env: NodeJS.ProcessEnv = process.env): string {
228
+ return join(env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'config.toml')
229
+ }
230
+
231
+ /** The live config's text, or null. Read-only; never written by COS. */
232
+ export function readCodexConfigText(path = codexConfigPath()): string | null {
233
+ try {
234
+ return readFileSync(path, 'utf8')
235
+ } catch {
236
+ return null
237
+ }
238
+ }
239
+
240
+ function unescapeTomlBasic(value: string): string {
241
+ return value.replace(/\\(["\\])/g, '$1')
242
+ }
243
+
244
+ /**
245
+ * Every `[projects."<path>"]` table whose `trust_level` is `"trusted"`. A regex parse on
246
+ * purpose (the server has no TOML dependency): table headers in basic or literal strings,
247
+ * the key read only up to the next table header.
248
+ */
249
+ export function trustedCodexProjects(configText: string | null): string[] {
250
+ if (typeof configText !== 'string' || configText.length === 0) return []
251
+ const trusted: string[] = []
252
+ const header = /^\s*\[\s*projects\s*\.\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]\s*$/gm
253
+ let match: RegExpExecArray | null
254
+ while ((match = header.exec(configText)) !== null) {
255
+ const projectPath = match[1] !== undefined ? unescapeTomlBasic(match[1]) : match[2]!
256
+ const bodyStart = header.lastIndex
257
+ const next = configText.slice(bodyStart).search(/^\s*\[/m)
258
+ const body = next < 0 ? configText.slice(bodyStart) : configText.slice(bodyStart, bodyStart + next)
259
+ if (/^\s*trust_level\s*=\s*["']trusted["']\s*(?:#.*)?$/m.test(body) && projectPath.startsWith('/')) {
260
+ trusted.push(projectPath.replace(/\/+$/, '') || '/')
261
+ }
262
+ }
263
+ return trusted
264
+ }
265
+
266
+ const normalizeFolder = (path: string): string => path.replace(/\/+$/, '') || '/'
267
+
268
+ /**
269
+ * K7's rule: trusted iff the cwd itself, or its git trust root (the repo's top level, or the
270
+ * MAIN worktree for a linked one; `codexGitTrustRoot`), is a trusted `[projects]` entry.
271
+ * Exact folders only: a trusted parent does not cover a repo nested under it.
272
+ */
273
+ export function codexFolderTrusted(cwd: string, gitTrustRoot: string | null, configText: string | null): boolean {
274
+ if (typeof cwd !== 'string' || !cwd.startsWith('/')) return false
275
+ const trusted = new Set(trustedCodexProjects(configText).map(normalizeFolder))
276
+ if (trusted.has(normalizeFolder(cwd))) return true
277
+ return typeof gitTrustRoot === 'string' && gitTrustRoot.startsWith('/') && trusted.has(normalizeFolder(gitTrustRoot))
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // The git trust root (K7), bounded in time and cached
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /** One git call's budget. A folder git cannot answer for in this long is not a repo here. */
285
+ export const CODEX_GIT_TIMEOUT_MS = 2_000
286
+ const GIT_ROOT_TTL_MS = 60_000
287
+ export type GitRunner = (args: readonly string[], cwd: string, timeoutMs: number) => Promise<string>
288
+ export type GitRunnerSync = (args: readonly string[], cwd: string, timeoutMs: number) => string
289
+
290
+ const gitRootCache = new Map<string, { at: number; root: string | null }>()
291
+
292
+ /** Tests only. */
293
+ export function __resetCodexGitRootCacheForTests(): void {
294
+ gitRootCache.clear()
295
+ }
296
+
297
+ /** From `git rev-parse --show-toplevel` and `git worktree list --porcelain` output to the root. */
298
+ export function gitTrustRootFrom(toplevel: string, worktreeList: string | null): string | null {
299
+ const top = toplevel.trim()
300
+ if (!top.startsWith('/')) return null
301
+ // The first `worktree <path>` record is the MAIN worktree, whichever one asked.
302
+ const main = typeof worktreeList === 'string' ? /^worktree (.+)$/m.exec(worktreeList)?.[1]?.trim() : undefined
303
+ return main && main.startsWith('/') ? main : top
304
+ }
305
+
306
+ function cachedRoot(cwd: string, now: number): { hit: boolean; root: string | null } {
307
+ const entry = gitRootCache.get(cwd)
308
+ if (entry && now - entry.at < GIT_ROOT_TTL_MS) return { hit: true, root: entry.root }
309
+ return { hit: false, root: null }
310
+ }
311
+
312
+ function rememberRoot(cwd: string, root: string | null, now: number): string | null {
313
+ gitRootCache.delete(cwd)
314
+ gitRootCache.set(cwd, { at: now, root })
315
+ while (gitRootCache.size > 256) {
316
+ const oldest = gitRootCache.keys().next()
317
+ if (oldest.done) break
318
+ gitRootCache.delete(oldest.value)
319
+ }
320
+ return root
321
+ }
322
+
323
+ /** The git trust root for `cwd`, or null (not a repo, git missing, or too slow). Never throws. */
324
+ export async function codexGitTrustRoot(cwd: string, run: GitRunner, now = Date.now()): Promise<string | null> {
325
+ if (typeof cwd !== 'string' || !cwd.startsWith('/')) return null
326
+ const hit = cachedRoot(cwd, now)
327
+ if (hit.hit) return hit.root
328
+ let root: string | null = null
329
+ try {
330
+ const top = await run(['rev-parse', '--show-toplevel'], cwd, CODEX_GIT_TIMEOUT_MS)
331
+ let list: string | null = null
332
+ try { list = await run(['worktree', 'list', '--porcelain'], cwd, CODEX_GIT_TIMEOUT_MS) } catch { list = null }
333
+ root = gitTrustRootFrom(top, list)
334
+ } catch {
335
+ root = null
336
+ }
337
+ return rememberRoot(cwd, root, now)
338
+ }
339
+
340
+ /** The same, synchronously, for the adapter's spawn-time re-check (a fresh cache entry answers). */
341
+ export function codexGitTrustRootSync(cwd: string, run: GitRunnerSync, now = Date.now()): string | null {
342
+ if (typeof cwd !== 'string' || !cwd.startsWith('/')) return null
343
+ const hit = cachedRoot(cwd, now)
344
+ if (hit.hit) return hit.root
345
+ let root: string | null = null
346
+ try {
347
+ const top = run(['rev-parse', '--show-toplevel'], cwd, CODEX_GIT_TIMEOUT_MS)
348
+ let list: string | null = null
349
+ try { list = run(['worktree', 'list', '--porcelain'], cwd, CODEX_GIT_TIMEOUT_MS) } catch { list = null }
350
+ root = gitTrustRootFrom(top, list)
351
+ } catch {
352
+ root = null
353
+ }
354
+ return rememberRoot(cwd, root, now)
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // The plan: what one Codex Continue runs with
359
+ // ---------------------------------------------------------------------------
360
+
361
+ export interface CodexContinuePlan {
362
+ provider: 'codex'
363
+ sandbox: CodexSandboxType
364
+ /** `-m`, only when the model is in the full app-server list. */
365
+ model: string | null
366
+ /** `-c model_reasoning_effort="…"`. */
367
+ effort: string | null
368
+ /** `-c sandbox_workspace_write.network_access=…`, for workspace-write only. */
369
+ networkAccess: boolean | null
370
+ /** `session`: read from the rollout. `fallback`: the 6.61 sandbox, nothing else. */
371
+ source: 'session' | 'fallback'
372
+ /** At most 60 characters (plan 3.12). */
373
+ note: string
374
+ }
375
+
376
+ export interface CodexPlanDeps {
377
+ /** The 6.61 posture: `getCodexTrustMode()`. */
378
+ fallbackSandbox: () => 'read-only' | 'workspace-write'
379
+ /** The config text, read at plan time (never cached across turns). */
380
+ configText: () => string | null
381
+ /** 6.62.0 /qa (W5): the cwd's git trust root, already resolved (`codexGitTrustRoot`). */
382
+ gitTrustRoot?: string | null
383
+ /** Is this model in the full app-server list? */
384
+ isKnownModel: (model: string) => boolean
385
+ /** A display name for a known model (`gpt-6.1-sol` to `GPT-6.1 Sol`). */
386
+ modelLabel?: (model: string) => string | null
387
+ }
388
+
389
+ export const CONTINUE_NOTE_MAX = 60
390
+
391
+ /**
392
+ * Measured on the lens (integrator, 2026-10-05): a real note fits one line (the longest,
393
+ * "This Codex session's full access, GPT-6.1 Sol High.", is 440 of ~546 px), but 60
394
+ * characters of wide glyphs can wrap two or three. So a note that would pass this many
395
+ * characters with the model's DISPLAY name names the model by its short id instead.
396
+ */
397
+ export const CONTINUE_NOTE_SOFT_MAX = 45
398
+
399
+ /**
400
+ * A note that names a model: the display-name form when it stays within the soft budget,
401
+ * else the short-id form, else the model-free fallback. Never past CONTINUE_NOTE_MAX.
402
+ */
403
+ export function fitModelNote(
404
+ template: (model: string) => string,
405
+ display: string | null,
406
+ shortId: string | null,
407
+ fallback: string,
408
+ ): string {
409
+ const pretty = display ? template(display) : null
410
+ if (pretty && pretty.length <= CONTINUE_NOTE_SOFT_MAX) return pretty
411
+ const short = shortId ? template(shortId) : null
412
+ if (short && short.length <= CONTINUE_NOTE_MAX) return short
413
+ if (!shortId && pretty && pretty.length <= CONTINUE_NOTE_MAX) return pretty
414
+ return fitNote(fallback)
415
+ }
416
+
417
+ /** The first candidate that fits the lens budget. The last one must always fit. */
418
+ export function fitNote(...candidates: Array<string | null | undefined>): string {
419
+ for (const candidate of candidates) {
420
+ if (typeof candidate === 'string' && candidate.length > 0 && candidate.length <= CONTINUE_NOTE_MAX) return candidate
421
+ }
422
+ const last = candidates.filter((c): c is string => typeof c === 'string' && c.length > 0).pop() ?? ''
423
+ return last.slice(0, CONTINUE_NOTE_MAX)
424
+ }
425
+
426
+ const EFFORT_LABELS: Record<string, string> = {
427
+ minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra High', max: 'Max', ultra: 'Ultra',
428
+ }
429
+
430
+ export function effortLabel(effort: string | null): string | null {
431
+ if (!effort) return null
432
+ return EFFORT_LABELS[effort] ?? effort.charAt(0).toUpperCase() + effort.slice(1)
433
+ }
434
+
435
+ /** `gpt-6.1-sol` to `GPT-6.1 Sol`, used when the catalog has no display name. */
436
+ export function defaultCodexModelLabel(model: string): string {
437
+ return model
438
+ .split('-')
439
+ .map((part, index) => (index === 0 && /^gpt$/i.test(part) ? 'GPT' : part.charAt(0).toUpperCase() + part.slice(1)))
440
+ .join('-')
441
+ .replace(/^GPT-(\d+(?:\.\d+)*)-/, 'GPT-$1 ')
442
+ }
443
+
444
+ /**
445
+ * The plan for one Codex Continue. Pure over its deps: every branch is a test.
446
+ *
447
+ * `posture` null (unreadable, or no `turn_context` within the cap) or with no sandbox this
448
+ * build recognises: the fallback, never broader than 6.61 and never the config defaults.
449
+ */
450
+ export function planCodexContinue(posture: CodexSessionPosture | null, cwd: string, deps: CodexPlanDeps): CodexContinuePlan {
451
+ const fallback = (): CodexContinuePlan => {
452
+ let sandbox: 'read-only' | 'workspace-write' = 'read-only'
453
+ try {
454
+ sandbox = deps.fallbackSandbox() === 'workspace-write' ? 'workspace-write' : 'read-only'
455
+ } catch {
456
+ sandbox = 'read-only'
457
+ }
458
+ return {
459
+ provider: 'codex',
460
+ sandbox,
461
+ model: null,
462
+ effort: null,
463
+ networkAccess: null,
464
+ source: 'fallback',
465
+ note: sandbox === 'read-only' ? 'Read-only: session settings unreadable.' : 'Workspace write: session settings unreadable.',
466
+ }
467
+ }
468
+ if (!posture || posture.sandboxType === null) return fallback()
469
+
470
+ let known = false
471
+ try {
472
+ known = posture.model !== null && deps.isKnownModel(posture.model) === true
473
+ } catch {
474
+ known = false
475
+ }
476
+ const model = known ? posture.model : null
477
+ const effort = posture.effort
478
+ let label: string | null = null
479
+ if (model) {
480
+ try {
481
+ label = deps.modelLabel?.(model) ?? null
482
+ } catch {
483
+ label = null
484
+ }
485
+ label = label || defaultCodexModelLabel(model)
486
+ }
487
+ // Display form (`GPT-6.1 Sol High`) and short form (`gpt-6.1-sol high`), for fitModelNote.
488
+ const display = model ? [label, effortLabel(effort)].filter(Boolean).join(' ') : 'Codex default model'
489
+ const shortId = model ? [model, effort].filter(Boolean).join(' ') : null
490
+
491
+ if (posture.sandboxType === 'danger-full-access') {
492
+ let trusted = false
493
+ try {
494
+ trusted = codexFolderTrusted(cwd, deps.gitTrustRoot ?? null, deps.configText())
495
+ } catch {
496
+ trusted = false
497
+ }
498
+ if (trusted) {
499
+ return {
500
+ provider: 'codex', sandbox: 'danger-full-access', model, effort, networkAccess: true, source: 'session',
501
+ note: fitModelNote(m => `This Codex session's full access, ${m}.`, display, shortId, "This Codex session's full access."),
502
+ }
503
+ }
504
+ // The session ran with full access, but Codex has not trusted this folder: passing it
505
+ // would make Codex trust it permanently. Workspace write, said plainly.
506
+ return {
507
+ provider: 'codex', sandbox: 'workspace-write', model, effort, networkAccess: true, source: 'session',
508
+ note: fitNote('Workspace write: Codex has not trusted this folder.'),
509
+ }
510
+ }
511
+ if (posture.sandboxType === 'workspace-write') {
512
+ return {
513
+ provider: 'codex', sandbox: 'workspace-write', model, effort,
514
+ networkAccess: posture.networkAccess, source: 'session',
515
+ note: fitModelNote(m => `This Codex session's workspace access, ${m}.`, display, shortId, "This Codex session's workspace access."),
516
+ }
517
+ }
518
+ return {
519
+ provider: 'codex', sandbox: 'read-only', model, effort, networkAccess: null, source: 'session',
520
+ note: fitModelNote(m => `Read-only, as this Codex session runs, ${m}.`, display, shortId, 'Read-only, as this Codex session runs.'),
521
+ }
522
+ }
523
+
524
+ /** `reported_model` for a Codex row (plan 1.8): `<model> <effort>`, or null. */
525
+ export function codexReportedModel(posture: CodexSessionPosture | null): string | null {
526
+ if (!posture?.model) return null
527
+ return posture.effort ? `${posture.model} ${posture.effort}` : posture.model
528
+ }