@gotcos/glasses-server 6.36.12 → 6.36.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.36.14
4
+ - **The session LIST now reports what its caps hid.** The 7-day age gate, the
5
+ 20-per-provider cap, and the Cursor 32 MB skip used to drop rows with no
6
+ signal, so a 60-row list looked complete. `GET /api/agent-sessions` now carries
7
+ `dropped: { age, limit, oversized }`. Additive: an older client ignores the
8
+ key. Zero means the walk found nothing to hide, not that the caps are off.
9
+ Keep-warm titles and Codex files over 32 MB are not counted — Codex is listed
10
+ oversize on purpose; Cursor is the one that skips. Measured on this machine
11
+ before publish: 62 listed, **2,144** older than 7 days, **91** over the
12
+ per-provider cap, **0** oversized. The visible first/last ids match running
13
+ 6.36.13, so the list membership did not change — only the silence did.
14
+
15
+ ## 6.36.13
16
+ - **Codex was spawned by bare name in four places, and it only worked here by accident.**
17
+ `codex` on PATH is a shell alias to `/Applications/Codex.app`, which does not exist; the real
18
+ binary lives in ChatGPT.app. Every bare `spawn('codex', …)` resolved through a PATH that COS
19
+ Control injects into the managed plist — so it worked on this machine and was ENOENT for every
20
+ public npx user, and for anything launchd- or Finder-spawned. The sites: the model catalog, the
21
+ `--add-dir` capability probe, the health probe, and `callCodexStreaming`, which is the **live
22
+ turn-execution path**. Each now resolves first, and each refuses in the way that suits it: the
23
+ live turn throws with the reason, the catalog rejects to its existing `cli-default` degradation,
24
+ the capability probe reports unsupported, and the health probe reports `unresolved (…)` instead
25
+ of collapsing "cannot find it" and "found it and it errored" into one `error`.
26
+ - **Binary resolution moved to its own leaf module.** `provider-binary.ts` imports nothing from the
27
+ repo, deliberately: reaching the resolver through `attached-provider-adapter.ts` would close the
28
+ cycle adapter → codex-run-ledger → codex-model-catalog → adapter. The adapter re-exports it, so
29
+ no existing importer changed.
30
+ - **Three source-text assertions replaced with fixtures.** Two tests asserted on this repo's own
31
+ characters — `not.toMatch('AGENT_SESSION_MAX_FILE_BYTES')` and `toMatch('end = HEAD_BYTES - 1')`
32
+ — which go stale on any refactor and cannot observe the property they name. A Codex rollout is
33
+ now made genuinely larger than the 32 MB gate (sparse, via `truncateSync`) and asserted to still
34
+ be listed. Verified by mutation: adding that gate to `listCodexSessions` fails the new test.
35
+
3
36
  ## 6.36.12
4
37
  - **Claude and Codex now rank candidates by recency before spending the budget.** Both
5
38
  walked in raw `readdir` order, so which sessions were reachable came down to filesystem
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.12",
3
+ "version": "6.36.14",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,17 @@ const CODEX_ROLLOUT_STAMP = /^rollout-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}
27
27
  export type AgentProvider = 'claude' | 'codex' | 'cursor'
28
28
  export type AgentSessionSort = 'updated' | 'opened'
29
29
 
30
+ /** Caps that drop LIST rows with no other signal. Search has its own budget. */
31
+ export interface AgentSessionListDropped {
32
+ age: number
33
+ limit: number
34
+ oversized: number
35
+ }
36
+
37
+ export function emptySessionListDropped(): AgentSessionListDropped {
38
+ return { age: 0, limit: 0, oversized: 0 }
39
+ }
40
+
30
41
  export interface AgentSessionRow {
31
42
  session_id: string
32
43
  provider: AgentProvider
@@ -836,6 +847,7 @@ export async function listClaudeSessions(
836
847
  cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
837
848
  starredIds: ReadonlySet<string> = new Set(),
838
849
  desktopSessionsRoot = '',
850
+ dropped: AgentSessionListDropped = emptySessionListDropped(),
839
851
  ): Promise<AgentSessionRow[]> {
840
852
  const seen = new Set<string>()
841
853
  const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
@@ -854,7 +866,10 @@ export async function listClaudeSessions(
854
866
  if (!st?.isFile) continue
855
867
  const pinned = starredIds.has(native.toLowerCase())
856
868
  const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
857
- if (!pinned && !fresh) continue
869
+ if (!pinned && !fresh) {
870
+ dropped.age += 1
871
+ continue
872
+ }
858
873
  const candidate = {
859
874
  file,
860
875
  native,
@@ -888,8 +903,12 @@ export async function listClaudeSessions(
888
903
 
889
904
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
890
905
  const rows: AgentSessionRow[] = []
891
- for (const candidate of candidates) {
892
- if (rows.length >= Math.max(0, limit)) break
906
+ for (let i = 0; i < candidates.length; i++) {
907
+ if (rows.length >= Math.max(0, limit)) {
908
+ dropped.limit += candidates.length - i
909
+ break
910
+ }
911
+ const candidate = candidates[i]
893
912
  let title = ''
894
913
  let project = candidate.project
895
914
  let firstPrompt = ''
@@ -935,6 +954,7 @@ export async function listCodexSessions(
935
954
  sessionsRoot: string,
936
955
  now: Date,
937
956
  cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
957
+ dropped: AgentSessionListDropped = emptySessionListDropped(),
938
958
  ): Promise<AgentSessionRow[]> {
939
959
  const names = await loadCodexThreadNames(sessionsRoot)
940
960
  const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
@@ -947,7 +967,10 @@ export async function listCodexSessions(
947
967
  const fileId = idFromCodexFilename(name)
948
968
  const pinned = fileId ? pinnedIds.has(fileId) : false
949
969
  const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
950
- if (!pinned && !fresh) continue
970
+ if (!pinned && !fresh) {
971
+ dropped.age += 1
972
+ continue
973
+ }
951
974
  const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
952
975
  if (pinned) pinnedCandidates.push(candidate)
953
976
  else recentCandidates.push(candidate)
@@ -957,8 +980,12 @@ export async function listCodexSessions(
957
980
 
958
981
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
959
982
  const rows: AgentSessionRow[] = []
960
- for (const candidate of candidates) {
961
- if (rows.length >= Math.max(0, limit)) break
983
+ for (let i = 0; i < candidates.length; i++) {
984
+ if (rows.length >= Math.max(0, limit)) {
985
+ dropped.limit += candidates.length - i
986
+ break
987
+ }
988
+ const candidate = candidates[i]
962
989
  const meta = await peekCodexMeta(candidate.file)
963
990
  if (!meta || meta.subagent) continue
964
991
  // THE FILENAME WINS WHEN THEY DISAGREE.
@@ -1011,6 +1038,7 @@ export async function listCursorSessions(
1011
1038
  cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
1012
1039
  composerDb = '',
1013
1040
  pinnedIds: ReadonlySet<string> = new Set(),
1041
+ dropped: AgentSessionListDropped = emptySessionListDropped(),
1014
1042
  ): Promise<AgentSessionRow[]> {
1015
1043
  const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
1016
1044
  const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
@@ -1024,9 +1052,15 @@ export async function listCursorSessions(
1024
1052
  const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
1025
1053
  const st = await fileStat(file)
1026
1054
  if (!st?.isFile) continue
1027
- if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
1055
+ if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES) {
1056
+ dropped.oversized += 1
1057
+ continue
1058
+ }
1028
1059
  const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
1029
- if (!pinned && !fresh) continue
1060
+ if (!pinned && !fresh) {
1061
+ dropped.age += 1
1062
+ continue
1063
+ }
1030
1064
  const next = {
1031
1065
  file,
1032
1066
  sessionDir,
@@ -1045,8 +1079,12 @@ export async function listCursorSessions(
1045
1079
 
1046
1080
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
1047
1081
  const rows: AgentSessionRow[] = []
1048
- for (const candidate of candidates) {
1049
- if (rows.length >= Math.max(0, limit)) break
1082
+ for (let i = 0; i < candidates.length; i++) {
1083
+ if (rows.length >= Math.max(0, limit)) {
1084
+ dropped.limit += candidates.length - i
1085
+ break
1086
+ }
1087
+ const candidate = candidates[i]
1050
1088
  const peek = await peekCursorDiscussion(candidate.file)
1051
1089
  const firstPrompt = await firstCursorUserTitle(candidate.file) ?? peek.lastUser ?? ''
1052
1090
  const title = composerNames.get(candidate.sessionDir)
@@ -1141,6 +1179,7 @@ export async function listAgentSessions(
1141
1179
  live: AgentSessionRow[] = [],
1142
1180
  limit = AGENT_SESSION_LIST_LIMIT,
1143
1181
  sort: AgentSessionSort = 'updated',
1182
+ dropped: AgentSessionListDropped = emptySessionListDropped(),
1144
1183
  ): Promise<AgentSessionRow[]> {
1145
1184
  const starredIds = await loadClaudeStarredIds(roots.claudeDesktopConfig)
1146
1185
  const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
@@ -1159,9 +1198,9 @@ export async function listAgentSessions(
1159
1198
  const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
1160
1199
  let rows = dedupeSessions([
1161
1200
  ...enrichedLive,
1162
- ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions),
1163
- ...await listCodexSessions(roots.codexSessions, now, cap),
1164
- ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned),
1201
+ ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped),
1202
+ ...await listCodexSessions(roots.codexSessions, now, cap, dropped),
1203
+ ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped),
1165
1204
  ])
1166
1205
  if (sort === 'opened') {
1167
1206
  rows = rows.filter(entry => {
@@ -1178,6 +1217,8 @@ export async function listAgentSessions(
1178
1217
  // pin-boosting here made the lens show July stars instead of today.
1179
1218
  rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
1180
1219
  }
1220
+ const extras = Math.max(0, rows.length - Math.max(0, limit))
1221
+ if (extras) dropped.limit += extras
1181
1222
  return rows.slice(0, limit)
1182
1223
  }
1183
1224
 
@@ -227,173 +227,28 @@ export function attachedDeliveryAmbiguous(result: AttachedTurnResult): boolean {
227
227
  // ---------------------------------------------------------------------------
228
228
  // Binary resolution
229
229
  // ---------------------------------------------------------------------------
230
+ //
231
+ // MOVED to ./provider-binary.js so codex-bridge, codex-model-catalog and
232
+ // health-static-probes can resolve without importing this module -- doing so would close
233
+ // the cycle adapter -> codex-run-ledger -> codex-model-catalog -> adapter.
234
+ // Re-exported here so existing importers and tests are unaffected.
235
+
236
+ import {
237
+ resolveProviderBinary,
238
+ type BinaryResolution,
239
+ } from './provider-binary.js'
240
+
241
+ export {
242
+ STALE_SHIM_PREFIXES,
243
+ isKnownStaleShimPath,
244
+ providerBinarySpec,
245
+ resolveProviderBinary,
246
+ resolveBinaryFromSpec,
247
+ type BinaryResolution,
248
+ type BinaryResolutionFailure,
249
+ type BinarySpec,
250
+ } from './provider-binary.js'
230
251
 
231
- export type BinaryResolutionFailure = 'env_override_unusable' | 'not_found'
232
-
233
- export type BinaryResolution =
234
- | { ok: true; path: string; source: 'env' | 'absolute' | 'path' }
235
- | { ok: false; binary: string; detail: BinaryResolutionFailure }
236
-
237
- /**
238
- * Path prefixes that can never be a usable provider binary.
239
- *
240
- * `codex` resolved through PATH (or a shell alias) points at
241
- * `/Applications/Codex.app/Contents/Resources/codex` on this machine, and that
242
- * app no longer exists. Today the shim is dangling, so an existence check
243
- * already rejects it — but a reinstalled or partially-removed Codex.app puts a
244
- * real, executable file back on that path, and then only this list stands
245
- * between an attached turn and a binary that cannot serve it.
246
- */
247
- export const STALE_SHIM_PREFIXES: readonly string[] = ['/Applications/Codex.app/']
248
-
249
- export function isKnownStaleShimPath(
250
- candidate: string,
251
- prefixes: readonly string[] = STALE_SHIM_PREFIXES,
252
- ): boolean {
253
- // A non-array argument falls back to the known list rather than to "exclude
254
- // nothing" — the classic version of this bug is `list.some(isKnownStaleShimPath)`,
255
- // where `.some` passes the INDEX as the second argument and every exclusion
256
- // silently disappears.
257
- const list = Array.isArray(prefixes) ? prefixes : STALE_SHIM_PREFIXES
258
- return list.some(prefix => typeof prefix === 'string' && prefix.length > 0 && candidate.startsWith(prefix))
259
- }
260
-
261
- function isUsableExecutable(
262
- candidate: string,
263
- excludePrefixes: readonly string[] = STALE_SHIM_PREFIXES,
264
- ): boolean {
265
- try {
266
- if (!isAbsolute(candidate) || candidate.includes('\0')) return false
267
- if (isKnownStaleShimPath(candidate, excludePrefixes)) return false
268
- if (!statSync(candidate).isFile()) return false
269
- accessSync(candidate, fsConstants.X_OK)
270
- return true
271
- } catch {
272
- return false
273
- }
274
- }
275
-
276
- export interface BinarySpec {
277
- name: string
278
- envKeys: readonly string[]
279
- /** Tried in order, BEFORE any PATH scan. */
280
- absolutes: readonly string[]
281
- /**
282
- * Paths that must never be selected, from any source. Defaults to
283
- * `STALE_SHIM_PREFIXES`.
284
- *
285
- * On the spec rather than hardcoded because the exclusion is the only guard
286
- * standing between resolution and a known-bad binary, and a guard whose input
287
- * cannot be constructed is a guard nobody can prove works: the real prefix
288
- * lives under `/Applications`, which no fixture can write to.
289
- */
290
- excludePrefixes?: readonly string[]
291
- }
292
-
293
- /**
294
- * Where each provider's binary is looked for, in precedence order.
295
- *
296
- * Exported as data rather than kept private so the precedence itself is
297
- * testable: on a machine where a stale shim sits on PATH, "ChatGPT.app is tried
298
- * before PATH" is the property that decides whether an attached Codex turn
299
- * launches the real binary or a dangling one.
300
- */
301
- export function providerBinarySpec(provider: AttachedProvider): BinarySpec {
302
- const home = (() => {
303
- try {
304
- return homedir()
305
- } catch {
306
- return ''
307
- }
308
- })()
309
- if (provider === 'claude') {
310
- return {
311
- name: 'claude',
312
- envKeys: ['COS_ATTACHED_CLAUDE_BIN', 'COS_CLAUDE_BIN'],
313
- absolutes: [
314
- '/opt/homebrew/bin/claude',
315
- '/usr/local/bin/claude',
316
- home ? join(home, '.local', 'bin', 'claude') : '',
317
- ].filter(Boolean),
318
- }
319
- }
320
- return {
321
- name: 'codex',
322
- envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
323
- absolutes: [
324
- // Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lives here, and there is
325
- // no `codex` on PATH at all on this machine.
326
- '/Applications/ChatGPT.app/Contents/Resources/codex',
327
- home ? join(home, '.codex', 'bin', 'codex') : '',
328
- '/opt/homebrew/bin/codex',
329
- '/usr/local/bin/codex',
330
- ].filter(Boolean),
331
- }
332
- }
333
-
334
- /**
335
- * Resolve a provider binary to a verified absolute path, or refuse.
336
- *
337
- * Never returns a bare name. A bare name is a silent PATH lookup, and the two
338
- * environments this server runs in disagree about PATH: a login shell finds the
339
- * CLI, a Finder- or launchd-spawned process gets a minimal PATH and does not
340
- * (hit twice in COS Control). The PATH scan below is done by us, entry by
341
- * entry, and still yields an absolute path we have stat'ed — so a failure names
342
- * the missing binary instead of surfacing as ENOENT from inside a spawn.
343
- *
344
- * An unusable env override REFUSES rather than falling through to the
345
- * candidates: an operator who set it wrongly needs to be told, not overridden.
346
- */
347
- export function resolveProviderBinary(
348
- provider: AttachedProvider,
349
- env: NodeJS.ProcessEnv = process.env,
350
- ): BinaryResolution {
351
- return resolveBinaryFromSpec(providerBinarySpec(provider), env)
352
- }
353
-
354
- /**
355
- * The resolution algorithm itself, separated from the provider tables.
356
- *
357
- * Not a testing seam bolted on: on any developer machine at least one real
358
- * absolute candidate exists, so the PATH-scan and not-found branches of
359
- * `resolveProviderBinary` are unreachable from a test and would ship
360
- * unexercised — which is exactly how a launchd-only failure hides. Driving the
361
- * REAL algorithm with a fixture spec exercises them for real.
362
- */
363
- export function resolveBinaryFromSpec(spec: BinarySpec, env: NodeJS.ProcessEnv): BinaryResolution {
364
- const excluded = spec.excludePrefixes ?? STALE_SHIM_PREFIXES
365
-
366
- for (const key of spec.envKeys) {
367
- const raw = env[key]
368
- if (typeof raw !== 'string' || raw.trim().length === 0) continue
369
- const candidate = raw.trim()
370
- if (!isUsableExecutable(candidate, excluded)) {
371
- return { ok: false, binary: spec.name, detail: 'env_override_unusable' }
372
- }
373
- return { ok: true, path: candidate, source: 'env' }
374
- }
375
-
376
- for (const candidate of spec.absolutes) {
377
- if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'absolute' }
378
- }
379
-
380
- const pathValue = typeof env.PATH === 'string' ? env.PATH : ''
381
- for (const dir of pathValue.split(delimiter)) {
382
- // A relative PATH entry resolves against the server's cwd, which is not a
383
- // location we control. Skipped rather than resolved.
384
- //
385
- // Redundant with the `isAbsolute` inside `isUsableExecutable` — verified by
386
- // mutation: removing EITHER one alone changes no outcome, and only removing
387
- // BOTH lets a relative entry through. Kept because two independent guards
388
- // on "never resolve against the server cwd" is the correct amount for a
389
- // path that ends up as a spawned executable.
390
- if (!dir || !isAbsolute(dir)) continue
391
- const candidate = join(dir, spec.name)
392
- if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'path' }
393
- }
394
-
395
- return { ok: false, binary: spec.name, detail: 'not_found' }
396
- }
397
252
 
398
253
  // ---------------------------------------------------------------------------
399
254
  // Injected surface
@@ -2,6 +2,11 @@
2
2
  // Concrete GPT ids resolve from Codex's live model catalog at run time.
3
3
 
4
4
  import { spawn, spawnSync } from 'node:child_process'
5
+ // Resolved, never bare. `codex` on PATH is a shell alias to /Applications/Codex.app,
6
+ // which does not exist; the real binary is in ChatGPT.app. A bare argv works here only
7
+ // because COS Control injects that directory into the managed plist PATH -- it is ENOENT
8
+ // for every public npx user, and for anything launchd- or Finder-spawned.
9
+ import { resolveProviderBinary } from './provider-binary.js'
5
10
  import { logTokenAudit } from './token-audit.js'
6
11
  import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
7
12
  import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
@@ -137,7 +142,14 @@ let addDirSupported: boolean | undefined
137
142
  export function codexSupportsAdditionalDir(): boolean {
138
143
  if (addDirSupported !== undefined) return addDirSupported
139
144
  try {
140
- const result = spawnSync('codex', ['exec', '--help'], {
145
+ const resolved = resolveProviderBinary('codex')
146
+ if (!resolved.ok) {
147
+ // Cannot probe what cannot be found. Report unsupported rather than throwing:
148
+ // this gate only disables output publishing, and chat stays functional.
149
+ addDirSupported = false
150
+ return addDirSupported
151
+ }
152
+ const result = spawnSync(resolved.path, ['exec', '--help'], {
141
153
  encoding: 'utf8',
142
154
  timeout: 5_000,
143
155
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -413,7 +425,18 @@ export async function callCodexStreaming(
413
425
  delete env.CLAUDECODE
414
426
  if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
415
427
 
416
- const proc = spawn('codex', args, {
428
+ // The live turn path. This one REFUSES loudly rather than degrading -- a turn that
429
+ // cannot reach the binary must say so, not fail later as an opaque ENOENT from inside
430
+ // a spawn whose argv the caller never sees.
431
+ const resolvedCodex = resolveProviderBinary('codex')
432
+ if (!resolvedCodex.ok) {
433
+ throw new Error(
434
+ `codex binary unresolved (${resolvedCodex.detail}). Checked the env override, `
435
+ + 'then ChatGPT.app, then PATH; /Applications/Codex.app is excluded as a stale shim.',
436
+ )
437
+ }
438
+
439
+ const proc = spawn(resolvedCodex.path, args, {
417
440
  stdio: ['pipe', 'pipe', 'pipe'],
418
441
  env,
419
442
  cwd: codexCwd,
@@ -6,6 +6,10 @@
6
6
  // the CLI default is used only when no discovered catalog exists yet.
7
7
 
8
8
  import { spawn } from 'node:child_process'
9
+ // Resolved, never bare -- see provider-binary.ts. This module is a leaf, and the resolver
10
+ // was extracted precisely so importing it here cannot close the cycle
11
+ // catalog -> adapter -> codex-run-ledger -> catalog.
12
+ import { resolveProviderBinary } from './provider-binary.js'
9
13
  import { existsSync, readFileSync } from 'node:fs'
10
14
  import { homedir } from 'node:os'
11
15
  import { resolve } from 'node:path'
@@ -248,7 +252,15 @@ async function fetchAppServerModels(): Promise<CodexCatalogModel[]> {
248
252
  return new Promise((resolveModels, reject) => {
249
253
  const env = { ...process.env }
250
254
  delete env.CLAUDECODE
251
- const child = spawn('codex', ['app-server', '--stdio'], {
255
+ const resolved = resolveProviderBinary('codex')
256
+ if (!resolved.ok) {
257
+ // Degrade, do not throw: this module already models exactly this outcome as
258
+ // `CodexCatalogSource = 'cli-default'`. A catalog refresh that cannot find the
259
+ // binary is a stale catalog, not a broken server.
260
+ reject(new Error(`codex binary unresolved (${resolved.detail})`))
261
+ return
262
+ }
263
+ const child = spawn(resolved.path, ['app-server', '--stdio'], {
252
264
  stdio: ['pipe', 'pipe', 'pipe'],
253
265
  env,
254
266
  })
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process'
2
+ import { resolveProviderBinary } from './provider-binary.js'
2
3
  import { PYTHON_BIN } from './python-bridge.js'
3
4
  import {
4
5
  getCursorModelCatalog,
@@ -116,7 +117,12 @@ async function probeClaude(): Promise<{ value: string; available: boolean }> {
116
117
 
117
118
  async function probeCodex(): Promise<{ value: string; available: boolean }> {
118
119
  try {
119
- const result = await execute('codex', ['--version'])
120
+ // Distinguish "cannot find the binary" from "found it and it errored" -- collapsing
121
+ // both to 'error' told every public npx user their Codex was broken when the real
122
+ // problem was a bare argv resolving through a PATH they do not have.
123
+ const resolved = resolveProviderBinary('codex')
124
+ if (!resolved.ok) return { value: `unresolved (${resolved.detail})`, available: false }
125
+ const result = await execute(resolved.path, ['--version'])
120
126
  const combined = `${result.stdout}\n${result.stderr}`.trim()
121
127
  const versionLine = combined.split(/\r?\n/).map(line => line.trim())
122
128
  .find(line => /^codex(?:-cli)?\s+/i.test(line))
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Where a provider's executable actually is.
3
+ *
4
+ * Extracted from `attached-provider-adapter.ts` 2026-08-19, unchanged. It moved because
5
+ * three other modules spawn providers by BARE NAME and need this resolution, and importing
6
+ * the adapter to get it closes a cycle: adapter -> codex-run-ledger -> codex-model-catalog
7
+ * -> adapter. This module imports nothing from the repo, so it can never be in one.
8
+ *
9
+ * Bare `'codex'` in argv resolves through PATH, and a Finder- or launchd-spawned process
10
+ * gets a minimal one. On this machine the bare sites work ONLY because COS Control injects
11
+ * ChatGPT.app onto the managed plist PATH -- they are broken for every public npx user.
12
+ */
13
+
14
+ import { accessSync, constants as fsConstants, statSync } from 'node:fs'
15
+ import { delimiter, isAbsolute, join } from 'node:path'
16
+ import { homedir } from 'node:os'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Binary resolution
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export type BinaryResolutionFailure = 'env_override_unusable' | 'not_found'
23
+
24
+ export type BinaryResolution =
25
+ | { ok: true; path: string; source: 'env' | 'absolute' | 'path' }
26
+ | { ok: false; binary: string; detail: BinaryResolutionFailure }
27
+
28
+ /**
29
+ * Path prefixes that can never be a usable provider binary.
30
+ *
31
+ * `codex` resolved through PATH (or a shell alias) points at
32
+ * `/Applications/Codex.app/Contents/Resources/codex` on this machine, and that
33
+ * app no longer exists. Today the shim is dangling, so an existence check
34
+ * already rejects it — but a reinstalled or partially-removed Codex.app puts a
35
+ * real, executable file back on that path, and then only this list stands
36
+ * between an attached turn and a binary that cannot serve it.
37
+ */
38
+ export const STALE_SHIM_PREFIXES: readonly string[] = ['/Applications/Codex.app/']
39
+
40
+ export function isKnownStaleShimPath(
41
+ candidate: string,
42
+ prefixes: readonly string[] = STALE_SHIM_PREFIXES,
43
+ ): boolean {
44
+ // A non-array argument falls back to the known list rather than to "exclude
45
+ // nothing" — the classic version of this bug is `list.some(isKnownStaleShimPath)`,
46
+ // where `.some` passes the INDEX as the second argument and every exclusion
47
+ // silently disappears.
48
+ const list = Array.isArray(prefixes) ? prefixes : STALE_SHIM_PREFIXES
49
+ return list.some(prefix => typeof prefix === 'string' && prefix.length > 0 && candidate.startsWith(prefix))
50
+ }
51
+
52
+ function isUsableExecutable(
53
+ candidate: string,
54
+ excludePrefixes: readonly string[] = STALE_SHIM_PREFIXES,
55
+ ): boolean {
56
+ try {
57
+ if (!isAbsolute(candidate) || candidate.includes('\0')) return false
58
+ if (isKnownStaleShimPath(candidate, excludePrefixes)) return false
59
+ if (!statSync(candidate).isFile()) return false
60
+ accessSync(candidate, fsConstants.X_OK)
61
+ return true
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ export interface BinarySpec {
68
+ name: string
69
+ envKeys: readonly string[]
70
+ /** Tried in order, BEFORE any PATH scan. */
71
+ absolutes: readonly string[]
72
+ /**
73
+ * Paths that must never be selected, from any source. Defaults to
74
+ * `STALE_SHIM_PREFIXES`.
75
+ *
76
+ * On the spec rather than hardcoded because the exclusion is the only guard
77
+ * standing between resolution and a known-bad binary, and a guard whose input
78
+ * cannot be constructed is a guard nobody can prove works: the real prefix
79
+ * lives under `/Applications`, which no fixture can write to.
80
+ */
81
+ excludePrefixes?: readonly string[]
82
+ }
83
+
84
+ /**
85
+ * Where each provider's binary is looked for, in precedence order.
86
+ *
87
+ * Exported as data rather than kept private so the precedence itself is
88
+ * testable: on a machine where a stale shim sits on PATH, "ChatGPT.app is tried
89
+ * before PATH" is the property that decides whether an attached Codex turn
90
+ * launches the real binary or a dangling one.
91
+ */
92
+ /**
93
+ * Takes a plain string, not `AttachedProvider`, deliberately. That type is declared in
94
+ * `attached-provider-adapter.ts`, and importing it back would recreate exactly the cycle
95
+ * this extraction exists to break. Callers pass a string-union value, which is assignable.
96
+ */
97
+ export function providerBinarySpec(provider: string): BinarySpec {
98
+ const home = (() => {
99
+ try {
100
+ return homedir()
101
+ } catch {
102
+ return ''
103
+ }
104
+ })()
105
+ if (provider === 'claude') {
106
+ return {
107
+ name: 'claude',
108
+ envKeys: ['COS_ATTACHED_CLAUDE_BIN', 'COS_CLAUDE_BIN'],
109
+ absolutes: [
110
+ '/opt/homebrew/bin/claude',
111
+ '/usr/local/bin/claude',
112
+ home ? join(home, '.local', 'bin', 'claude') : '',
113
+ ].filter(Boolean),
114
+ }
115
+ }
116
+ return {
117
+ name: 'codex',
118
+ envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
119
+ absolutes: [
120
+ // Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lives here, and there is
121
+ // no `codex` on PATH at all on this machine.
122
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
123
+ home ? join(home, '.codex', 'bin', 'codex') : '',
124
+ '/opt/homebrew/bin/codex',
125
+ '/usr/local/bin/codex',
126
+ ].filter(Boolean),
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Resolve a provider binary to a verified absolute path, or refuse.
132
+ *
133
+ * Never returns a bare name. A bare name is a silent PATH lookup, and the two
134
+ * environments this server runs in disagree about PATH: a login shell finds the
135
+ * CLI, a Finder- or launchd-spawned process gets a minimal PATH and does not
136
+ * (hit twice in COS Control). The PATH scan below is done by us, entry by
137
+ * entry, and still yields an absolute path we have stat'ed — so a failure names
138
+ * the missing binary instead of surfacing as ENOENT from inside a spawn.
139
+ *
140
+ * An unusable env override REFUSES rather than falling through to the
141
+ * candidates: an operator who set it wrongly needs to be told, not overridden.
142
+ */
143
+ export function resolveProviderBinary(
144
+ provider: string,
145
+ env: NodeJS.ProcessEnv = process.env,
146
+ ): BinaryResolution {
147
+ return resolveBinaryFromSpec(providerBinarySpec(provider), env)
148
+ }
149
+
150
+ /**
151
+ * The resolution algorithm itself, separated from the provider tables.
152
+ *
153
+ * Not a testing seam bolted on: on any developer machine at least one real
154
+ * absolute candidate exists, so the PATH-scan and not-found branches of
155
+ * `resolveProviderBinary` are unreachable from a test and would ship
156
+ * unexercised — which is exactly how a launchd-only failure hides. Driving the
157
+ * REAL algorithm with a fixture spec exercises them for real.
158
+ */
159
+ export function resolveBinaryFromSpec(spec: BinarySpec, env: NodeJS.ProcessEnv): BinaryResolution {
160
+ const excluded = spec.excludePrefixes ?? STALE_SHIM_PREFIXES
161
+
162
+ for (const key of spec.envKeys) {
163
+ const raw = env[key]
164
+ if (typeof raw !== 'string' || raw.trim().length === 0) continue
165
+ const candidate = raw.trim()
166
+ if (!isUsableExecutable(candidate, excluded)) {
167
+ return { ok: false, binary: spec.name, detail: 'env_override_unusable' }
168
+ }
169
+ return { ok: true, path: candidate, source: 'env' }
170
+ }
171
+
172
+ for (const candidate of spec.absolutes) {
173
+ if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'absolute' }
174
+ }
175
+
176
+ const pathValue = typeof env.PATH === 'string' ? env.PATH : ''
177
+ for (const dir of pathValue.split(delimiter)) {
178
+ // A relative PATH entry resolves against the server's cwd, which is not a
179
+ // location we control. Skipped rather than resolved.
180
+ //
181
+ // Redundant with the `isAbsolute` inside `isUsableExecutable` — verified by
182
+ // mutation: removing EITHER one alone changes no outcome, and only removing
183
+ // BOTH lets a relative entry through. Kept because two independent guards
184
+ // on "never resolve against the server cwd" is the correct amount for a
185
+ // path that ends up as a spawned executable.
186
+ if (!dir || !isAbsolute(dir)) continue
187
+ const candidate = join(dir, spec.name)
188
+ if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'path' }
189
+ }
190
+
191
+ return { ok: false, binary: spec.name, detail: 'not_found' }
192
+ }
@@ -11,8 +11,10 @@
11
11
  // `?sort=opened` keeps the same window on session start instead.
12
12
  // Search scans titles, sidebar names, first prompts, and transcript heads
13
13
  // without the 7-day list window. Literal /search is registered first.
14
- // Does not need COS_SCRIPTS_DIR. Codex subagents stay out. Files over 32 MB
15
- // still appear in the list; detail remains capped.
14
+ // Does not need COS_SCRIPTS_DIR. Codex subagents stay out.
15
+ // Codex files over 32 MB still appear in the list. Cursor files over 32 MB
16
+ // are skipped and counted on `dropped.oversized`. The list payload now says
17
+ // what each cap hid.
16
18
 
17
19
  import { Router } from 'express'
18
20
  import { stat } from 'node:fs/promises'
@@ -23,6 +25,7 @@ import {
23
25
  agentSessionRoots,
24
26
  findAgentSessionFile,
25
27
  listAgentSessions,
28
+ emptySessionListDropped,
26
29
  loadCursorComposerNames,
27
30
  parseAgentSession,
28
31
  type AgentProvider,
@@ -268,7 +271,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
268
271
  const limit = boundedInteger(req.query.limit, AGENT_SESSION_LIST_LIMIT, 1, AGENT_SESSION_LIST_MAX)
269
272
  const sort = asSort(req.query.sort)
270
273
  const live = await liveClaudeRows()
271
- const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
274
+ const dropped = emptySessionListDropped()
275
+ const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort, dropped)
272
276
  const scan = runningThreads(sessions)
273
277
  // Freshness is layered on AFTER occupancy, and only over what occupancy
274
278
  // found. `Date.now()` is read once so every row in a payload is judged
@@ -283,6 +287,10 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
283
287
  // True when a probe could not see clearly. The client must render "unknown"
284
288
  // rather than treating a quiet scan as "nothing is running".
285
289
  runningDegraded: running.degraded,
290
+ // LIST caps that previously dropped rows with no signal. Additive: an older
291
+ // client ignores this key. Zero means the walk found nothing to hide, not
292
+ // that the caps are off.
293
+ dropped,
286
294
  })
287
295
  } catch (error) {
288
296
  console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)