@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 +33 -0
- package/package.json +1 -1
- package/server/lib/agent-session-store.ts +54 -13
- package/server/lib/attached-provider-adapter.ts +21 -166
- package/server/lib/codex-bridge.ts +25 -2
- package/server/lib/codex-model-catalog.ts +13 -1
- package/server/lib/health-static-probes.ts +7 -1
- package/server/lib/provider-binary.ts +192 -0
- package/server/routes/agent-sessions.ts +11 -3
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
|
@@ -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)
|
|
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 (
|
|
892
|
-
if (rows.length >= Math.max(0, limit))
|
|
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)
|
|
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 (
|
|
961
|
-
if (rows.length >= Math.max(0, limit))
|
|
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)
|
|
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)
|
|
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 (
|
|
1049
|
-
if (rows.length >= Math.max(0, limit))
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
15
|
-
// still appear in the list
|
|
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
|
|
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}`)
|