@gotcos/glasses-server 6.36.11 → 6.36.13
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 +81 -0
- package/package.json +2 -2
- package/server/lib/agent-session-search.ts +118 -40
- package/server/lib/agent-session-store.ts +23 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.36.13
|
|
4
|
+
- **Codex was spawned by bare name in four places, and it only worked here by accident.**
|
|
5
|
+
`codex` on PATH is a shell alias to `/Applications/Codex.app`, which does not exist; the real
|
|
6
|
+
binary lives in ChatGPT.app. Every bare `spawn('codex', …)` resolved through a PATH that COS
|
|
7
|
+
Control injects into the managed plist — so it worked on this machine and was ENOENT for every
|
|
8
|
+
public npx user, and for anything launchd- or Finder-spawned. The sites: the model catalog, the
|
|
9
|
+
`--add-dir` capability probe, the health probe, and `callCodexStreaming`, which is the **live
|
|
10
|
+
turn-execution path**. Each now resolves first, and each refuses in the way that suits it: the
|
|
11
|
+
live turn throws with the reason, the catalog rejects to its existing `cli-default` degradation,
|
|
12
|
+
the capability probe reports unsupported, and the health probe reports `unresolved (…)` instead
|
|
13
|
+
of collapsing "cannot find it" and "found it and it errored" into one `error`.
|
|
14
|
+
- **Binary resolution moved to its own leaf module.** `provider-binary.ts` imports nothing from the
|
|
15
|
+
repo, deliberately: reaching the resolver through `attached-provider-adapter.ts` would close the
|
|
16
|
+
cycle adapter → codex-run-ledger → codex-model-catalog → adapter. The adapter re-exports it, so
|
|
17
|
+
no existing importer changed.
|
|
18
|
+
- **Three source-text assertions replaced with fixtures.** Two tests asserted on this repo's own
|
|
19
|
+
characters — `not.toMatch('AGENT_SESSION_MAX_FILE_BYTES')` and `toMatch('end = HEAD_BYTES - 1')`
|
|
20
|
+
— which go stale on any refactor and cannot observe the property they name. A Codex rollout is
|
|
21
|
+
now made genuinely larger than the 32 MB gate (sparse, via `truncateSync`) and asserted to still
|
|
22
|
+
be listed. Verified by mutation: adding that gate to `listCodexSessions` fails the new test.
|
|
23
|
+
|
|
24
|
+
## 6.36.12
|
|
25
|
+
- **Claude and Codex now rank candidates by recency before spending the budget.** Both
|
|
26
|
+
walked in raw `readdir` order, so which sessions were reachable came down to filesystem
|
|
27
|
+
layout — `collectCursorDocs` had ranked for a while and these two were the inconsistent
|
|
28
|
+
ones. Statting every candidate first costs ~41ms across 2,118 files. Cursor also charged
|
|
29
|
+
its budget before the keep-warm filter, the same defect, now fixed.
|
|
30
|
+
- **The reach claim in the module docstring was never true and is now measured.** It said
|
|
31
|
+
"older chats stay findable". Ranked and measured on this machine, search reaches roughly
|
|
32
|
+
24 days of Claude, 89 days of Codex and 19 days of Cursor, because once candidates are
|
|
33
|
+
ordered by recency the per-provider doc budget IS the horizon. `EXAMINE_MULTIPLE` was
|
|
34
|
+
re-swept and raised to 12 — the point where examining more files stops finding anything
|
|
35
|
+
older and the doc budget takes over. A sampled older stratum was considered and
|
|
36
|
+
rejected: partial coverage makes a miss uninterpretable, and a search that silently
|
|
37
|
+
samples cannot tell you whether something is absent or merely unsampled.
|
|
38
|
+
- **Embedding batches go out together instead of one after another.** The loop awaited
|
|
39
|
+
each batch in turn, so cost was a round trip per 64 docs and grew as the collector
|
|
40
|
+
returned more — 389 docs is 7 serialized trips at the old size. Now 128 per request,
|
|
41
|
+
all in flight at once. Structural; not measured end to end, because this harness has no
|
|
42
|
+
OpenAI key and the running server was left alone.
|
|
43
|
+
- **Session search was 68% scaffolding, and the budget was the reason.** Of the 1,296
|
|
44
|
+
Claude transcripts on this machine the collector indexed 41, and 28 of those 41 were
|
|
45
|
+
machine prompts — 22 Slack Bridge proxy, 4 reply-with, 2 slack_search_users. Roughly 13
|
|
46
|
+
real conversations were searchable, which is why searching an exact session title
|
|
47
|
+
returned nothing. Two changes, which do not work apart: `isKeepWarmSessionTitle` now
|
|
48
|
+
recognises the machine families by anchored prefix, and both the Claude and Codex
|
|
49
|
+
collectors run that filter — and the Codex `thread_source === 'subagent'` check — BEFORE
|
|
50
|
+
charging the doc budget rather than after. Measured against the real corpus: 41 indexed
|
|
51
|
+
Claude docs with 28 junk becomes **79 indexed with 0 junk**.
|
|
52
|
+
- **The budget now counts docs kept, not files opened.** That is the whole fix: a run of
|
|
53
|
+
machine transcripts used to consume the 134-file allowance and return nothing, so the
|
|
54
|
+
newest real transcript on disk was never reached. A second `examined` ceiling
|
|
55
|
+
(`EXAMINE_MULTIPLE`, 5x the doc budget) stops a pathological corpus walking all 1,296
|
|
56
|
+
files, and the expensive transcript-body read is deferred until a file is being kept.
|
|
57
|
+
- **This also removes rows from COS Control's session LIST.** The predicate is shared by
|
|
58
|
+
all four collectors and the list path, so keep-warm and Slack Bridge entries stop
|
|
59
|
+
appearing there too. That is intended, not a side effect to fix.
|
|
60
|
+
- **Control may not see any of this yet.** The search route's median is ~2.27s against
|
|
61
|
+
Control's 2s client timeout, of which ~1.7s is two sequential embedding round trips;
|
|
62
|
+
this work adds ~350ms on top. Until that timeout and `EMBED_BATCH` are addressed,
|
|
63
|
+
Control falls back to its local scanner and reports a fabricated `server_too_old`.
|
|
64
|
+
- **The scan budget had no test coverage at all.** `collectAgentSessionSearchDocs` was
|
|
65
|
+
never called by any test and its `cap` was never exercised, so the branch that spends
|
|
66
|
+
the budget had never run. 21 tests added, each verified to FAIL against the previous
|
|
67
|
+
code before being kept.
|
|
68
|
+
|
|
3
69
|
## 6.36.11
|
|
4
70
|
- **Fences now record WHY, so the population can be measured before anything resolves
|
|
5
71
|
automatically.** Two plans designed an automatic fence resolver and both were rejected —
|
|
@@ -54,6 +120,21 @@
|
|
|
54
120
|
default install (`COS_THREAD_FENCE_DURABLE` unset) nothing is written to disk at all;
|
|
55
121
|
and reading the distribution means reading `thread-fences.json` or the server log —
|
|
56
122
|
there is no UI for it.
|
|
123
|
+
- **A live session was reporting itself hours idle.** Separate from the fence work above.
|
|
124
|
+
`liveClaudeRows` builds a row's `modified` from the peer registry's `lastActiveAt`, which
|
|
125
|
+
tracks the REGISTRY record and not the transcript — so a session that is actively writing
|
|
126
|
+
keeps reporting whenever the registry last moved. Measured on three live sessions
|
|
127
|
+
2026-08-18: the wire said 55.3m / 407.7m / 435.0m old while their transcripts had been
|
|
128
|
+
written 0.1m / 0.2m / 5.1m earlier. Under-reporting by up to 7.2 hours. Shipped in
|
|
129
|
+
66dff88; `enrichLiveClaude` already resolves the transcript path and reads the file twice,
|
|
130
|
+
so the true mtime costs one `stat`. A resolved file that fails to stat keeps the
|
|
131
|
+
heartbeat; a session with no transcript at all (2 of 6 measured) returns early on the
|
|
132
|
+
existing guard. Prerequisite for any surface that renders a real date — without it,
|
|
133
|
+
showing the timestamp displays an actively-writing session as seven hours stale.
|
|
134
|
+
Coverage: `enrichLiveClaude` had NO execution coverage before this (every existing test
|
|
135
|
+
passes an empty live array). Two tests now drive it through `listAgentSessions`. Three
|
|
136
|
+
mutations, two caught; the third (the stat-failure fallback) SURVIVES because the
|
|
137
|
+
missing-file guard returns first, so that branch is unreached. The code says so.
|
|
57
138
|
|
|
58
139
|
## 6.36.10
|
|
59
140
|
- **A fenced thread had no exit and left no trace.** An ambiguous delivery fences the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.36.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.36.13",
|
|
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": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
* Keyword: local title / sidebar name / first prompt / transcript scan. No model.
|
|
5
5
|
* Semantic: one OpenAI query embedding scored against those same texts.
|
|
6
6
|
* Sessions have no Qdrant collection — this is not meeting or memory search.
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* REACH, measured 2026-08-18 rather than claimed. The 7-day list window does not apply,
|
|
9
|
+
* but this is not unbounded either: each provider gets a doc budget of MAX_SCAN_FILES/3,
|
|
10
|
+
* and once candidates are ranked by recency that budget IS the horizon. On this machine
|
|
11
|
+
* that is roughly 24 days of Claude, 89 days of Codex and 19 days of Cursor. An older
|
|
12
|
+
* chat is reachable only if its provider has not filled its budget with newer ones.
|
|
13
|
+
*
|
|
14
|
+
* This previously read "older chats stay findable", which was written before ranking and
|
|
15
|
+
* was never measured. Widening it means a bigger budget, which costs embedding time on
|
|
16
|
+
* the route -- not a deeper examine ceiling, which saturates. A sampled older stratum was
|
|
17
|
+
* considered and rejected: partial coverage makes a miss uninterpretable, and a search
|
|
18
|
+
* that silently samples cannot tell you whether a thing is absent or merely unsampled.
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
import { join } from 'node:path'
|
|
@@ -46,11 +57,51 @@ import {
|
|
|
46
57
|
|
|
47
58
|
const HEAD_TEXT = 8_000
|
|
48
59
|
const MAX_SCAN_FILES = 400
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How many files a provider may OPEN per search, as a multiple of the docs it may KEEP.
|
|
63
|
+
*
|
|
64
|
+
* Before the filter moved ahead of the decrement, `remaining` meant "files read" -- so a
|
|
65
|
+
* run of machine transcripts consumed the whole allowance and returned nothing. Now it
|
|
66
|
+
* means "docs kept", which on its own would let a pathological corpus walk all 1,296
|
|
67
|
+
* files. This is the ceiling that stops it.
|
|
68
|
+
*
|
|
69
|
+
* Swept 2026-08-18 against the real corpus (1,296 Claude transcripts, 822 Codex
|
|
70
|
+
* rollouts). Docs kept vs collector wall time, median of 5 warm runs:
|
|
71
|
+
*
|
|
72
|
+
* Re-swept after recency ranking landed, since ranking changes which files the ceiling
|
|
73
|
+
* is spent on -- the newest region of the corpus is the densest in machine transcripts:
|
|
74
|
+
*
|
|
75
|
+
* x5 329 docs 74 claude 11.4d window 1287 ms
|
|
76
|
+
* x8 389 docs 134 claude 21.3d window 1585 ms
|
|
77
|
+
* x12 389 docs 134 claude 24.5d window 1697 ms <- chosen
|
|
78
|
+
* x16 389 docs 134 claude 24.5d window 1637 ms
|
|
79
|
+
*
|
|
80
|
+
* x12 is where the ceiling stops being the constraint and the 134-doc budget takes over:
|
|
81
|
+
* past it, examining more files finds nothing older. That is the principled stopping
|
|
82
|
+
* point -- raise until the other limit binds, then stop.
|
|
83
|
+
*/
|
|
84
|
+
const EXAMINE_MULTIPLE = 12
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `remaining` counts docs KEPT. `examined` counts files OPENED. They are different
|
|
88
|
+
* numbers because most files on this machine are filtered out after their title is read.
|
|
89
|
+
*/
|
|
90
|
+
type ScanBudget = { remaining: number; examined: number }
|
|
49
91
|
const SEMANTIC_DOC_CAP = 120
|
|
50
92
|
const SEMANTIC_MIN = 0.28
|
|
51
93
|
const EMBED_MODEL = 'text-embedding-3-small'
|
|
52
94
|
const EMBED_TIMEOUT_MS = 12_000
|
|
53
|
-
|
|
95
|
+
/**
|
|
96
|
+
* 128 inputs per request, and the requests go out TOGETHER.
|
|
97
|
+
*
|
|
98
|
+
* This loop used to `await` each batch in turn, so the embedding cost was a round trip
|
|
99
|
+
* per 64 docs -- about 1.7s of a 2.3s route, and it got worse as the collector started
|
|
100
|
+
* returning more docs (389 docs is 7 serialized trips at the old batch size, 4 at this
|
|
101
|
+
* one). Serialization was the expensive part, not the batch size, so both changed:
|
|
102
|
+
* fewer requests, and one round trip of latency instead of N.
|
|
103
|
+
*/
|
|
104
|
+
const EMBED_BATCH = 128
|
|
54
105
|
|
|
55
106
|
export interface AgentSessionSearchHit extends AgentSessionRow {
|
|
56
107
|
snippet: string
|
|
@@ -139,7 +190,9 @@ export async function defaultEmbedTexts(texts: string[]): Promise<number[][] | {
|
|
|
139
190
|
if (!key) return { reason: 'no_session_embeddings' }
|
|
140
191
|
if (texts.length === 0) return []
|
|
141
192
|
const vectors: number[][] = new Array(texts.length)
|
|
142
|
-
|
|
193
|
+
const offsets: number[] = []
|
|
194
|
+
for (let offset = 0; offset < texts.length; offset += EMBED_BATCH) offsets.push(offset)
|
|
195
|
+
const failed = await Promise.all(offsets.map(async offset => {
|
|
143
196
|
const slice = texts.slice(offset, offset + EMBED_BATCH).map(text => text.slice(0, HEAD_TEXT))
|
|
144
197
|
try {
|
|
145
198
|
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
|
@@ -151,17 +204,19 @@ export async function defaultEmbedTexts(texts: string[]): Promise<number[][] | {
|
|
|
151
204
|
body: JSON.stringify({ model: EMBED_MODEL, input: slice }),
|
|
152
205
|
signal: AbortSignal.timeout(EMBED_TIMEOUT_MS),
|
|
153
206
|
})
|
|
154
|
-
if (!response.ok) return
|
|
207
|
+
if (!response.ok) return true
|
|
155
208
|
const parsed = await response.json() as { data?: Array<{ embedding?: number[]; index?: number }> }
|
|
156
209
|
const rows = Array.isArray(parsed.data) ? parsed.data : []
|
|
157
210
|
for (const row of rows) {
|
|
158
211
|
const index = typeof row.index === 'number' ? row.index : 0
|
|
159
212
|
if (Array.isArray(row.embedding)) vectors[offset + index] = row.embedding
|
|
160
213
|
}
|
|
214
|
+
return false
|
|
161
215
|
} catch {
|
|
162
|
-
return
|
|
216
|
+
return true
|
|
163
217
|
}
|
|
164
|
-
}
|
|
218
|
+
}))
|
|
219
|
+
if (failed.some(Boolean)) return { reason: 'embeddings_unreachable' }
|
|
165
220
|
if (vectors.some(row => !Array.isArray(row) || row.length === 0)) return { reason: 'embeddings_unreachable' }
|
|
166
221
|
return vectors
|
|
167
222
|
}
|
|
@@ -180,40 +235,53 @@ function pushDoc(docs: SearchDoc[], next: SearchDoc) {
|
|
|
180
235
|
}
|
|
181
236
|
}
|
|
182
237
|
|
|
183
|
-
async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget:
|
|
238
|
+
async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget: ScanBudget) {
|
|
184
239
|
const starred = await loadClaudeStarredIds(roots.claudeDesktopConfig)
|
|
240
|
+
// Stat every candidate and rank by recency BEFORE spending anything, the shape
|
|
241
|
+
// `collectCursorDocs` already used. Claude and Codex were the inconsistent ones: they
|
|
242
|
+
// walked in raw `readdir` order, so which sessions were reachable came down to
|
|
243
|
+
// filesystem layout and the newest transcript on disk was routinely never opened.
|
|
244
|
+
// Statting is not the expensive part -- measured at 41ms across 2,118 candidates.
|
|
245
|
+
const candidates: Array<{ file: string; native: string; folder: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
185
246
|
for (const folder of await dirents(roots.claudeProjects)) {
|
|
186
247
|
const dir = join(roots.claudeProjects, folder)
|
|
187
248
|
for (const name of await dirents(dir)) {
|
|
188
|
-
if (!CLAUDE_UUID_JSONL.test(name)
|
|
249
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
189
250
|
const file = join(dir, name)
|
|
190
251
|
const st = await fileStat(file)
|
|
191
252
|
if (!st?.isFile) continue
|
|
192
|
-
|
|
193
|
-
const native = name.slice(0, -6)
|
|
194
|
-
const custom = await lastCustomTitle(file)
|
|
195
|
-
const first = await firstClaudeUserTitle(file)
|
|
196
|
-
const users = await transcriptHaystack('claude', file)
|
|
197
|
-
const title = custom || first || 'Claude session'
|
|
198
|
-
if (isKeepWarmSessionTitle(title)) continue
|
|
199
|
-
const haystack = clip(`${title}\n${custom || ''}\n${first || ''}\n${workspaceLabel(folder)}\n${users}`)
|
|
200
|
-
pushDoc(docs, {
|
|
201
|
-
title,
|
|
202
|
-
haystack,
|
|
203
|
-
row: {
|
|
204
|
-
session_id: native,
|
|
205
|
-
provider: 'claude',
|
|
206
|
-
display_label: title,
|
|
207
|
-
project: workspaceLabel(folder),
|
|
208
|
-
modified: isoFromMtime(st.mtimeMs),
|
|
209
|
-
created: isoFromMtime(st.birthtimeMs),
|
|
210
|
-
alive: false,
|
|
211
|
-
state: 'recent',
|
|
212
|
-
pinned: starred.has(native.toLowerCase()),
|
|
213
|
-
},
|
|
214
|
-
})
|
|
253
|
+
candidates.push({ file, native: name.slice(0, -6), folder, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs })
|
|
215
254
|
}
|
|
216
255
|
}
|
|
256
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
257
|
+
for (const candidate of candidates) {
|
|
258
|
+
if (budget.remaining <= 0 || budget.examined <= 0) break
|
|
259
|
+
budget.examined -= 1
|
|
260
|
+
const custom = await lastCustomTitle(candidate.file)
|
|
261
|
+
const first = await firstClaudeUserTitle(candidate.file)
|
|
262
|
+
const title = custom || first || 'Claude session'
|
|
263
|
+
// Both filters run BEFORE the doc budget is charged, and the haystack -- by far the
|
|
264
|
+
// most expensive read here -- runs only for a file we are actually keeping.
|
|
265
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
266
|
+
budget.remaining -= 1
|
|
267
|
+
const users = await transcriptHaystack('claude', candidate.file)
|
|
268
|
+
const haystack = clip(`${title}\n${custom || ''}\n${first || ''}\n${workspaceLabel(candidate.folder)}\n${users}`)
|
|
269
|
+
pushDoc(docs, {
|
|
270
|
+
title,
|
|
271
|
+
haystack,
|
|
272
|
+
row: {
|
|
273
|
+
session_id: candidate.native,
|
|
274
|
+
provider: 'claude',
|
|
275
|
+
display_label: title,
|
|
276
|
+
project: workspaceLabel(candidate.folder),
|
|
277
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
278
|
+
created: isoFromMtime(candidate.birthtimeMs),
|
|
279
|
+
alive: false,
|
|
280
|
+
state: 'recent',
|
|
281
|
+
pinned: starred.has(candidate.native.toLowerCase()),
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
}
|
|
217
285
|
for (const starredId of starred) {
|
|
218
286
|
if (docs.some(doc => doc.row.provider === 'claude' && doc.row.session_id.toLowerCase() === starredId)) continue
|
|
219
287
|
if (budget.remaining <= 0) break
|
|
@@ -221,10 +289,10 @@ async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], bu
|
|
|
221
289
|
if (!desktop) continue
|
|
222
290
|
const st = await fileStat(desktop)
|
|
223
291
|
if (!st?.isFile) continue
|
|
224
|
-
budget.remaining -= 1
|
|
225
292
|
const head = peekClaudeDesktopHead(await readWindow(desktop, false))
|
|
226
293
|
const title = head.title || 'Claude session'
|
|
227
294
|
if (isKeepWarmSessionTitle(title)) continue
|
|
295
|
+
budget.remaining -= 1
|
|
228
296
|
pushDoc(docs, {
|
|
229
297
|
title,
|
|
230
298
|
haystack: clip(`${title}\n${head.cwd}\n${workspaceLabel(head.cwd)}`),
|
|
@@ -243,23 +311,31 @@ async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], bu
|
|
|
243
311
|
}
|
|
244
312
|
}
|
|
245
313
|
|
|
246
|
-
async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget:
|
|
314
|
+
async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget: ScanBudget) {
|
|
247
315
|
const names = await loadCodexThreadNames(roots.codexSessions)
|
|
248
316
|
const pinned = await loadCodexPinnedIds(roots.codexSessions)
|
|
317
|
+
const candidates: Array<{ file: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
249
318
|
for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
|
|
250
|
-
if (budget.remaining <= 0) break
|
|
251
319
|
const st = await fileStat(file)
|
|
252
320
|
if (!st?.isFile) continue
|
|
253
|
-
|
|
321
|
+
candidates.push({ file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs })
|
|
322
|
+
}
|
|
323
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
324
|
+
for (const { file, mtimeMs, birthtimeMs } of candidates) {
|
|
325
|
+
if (budget.remaining <= 0 || budget.examined <= 0) break
|
|
326
|
+
budget.examined -= 1
|
|
254
327
|
const meta = await peekCodexMeta(file)
|
|
328
|
+
// Subagent rollouts are the Codex analogue of the keep-warm transcripts: 427 of 822
|
|
329
|
+
// on this machine per the 2026-08-18 census. Filtered before the doc budget is charged.
|
|
255
330
|
if (!meta || meta.subagent) continue
|
|
256
331
|
const name = file.split('/').pop() || file
|
|
257
332
|
const native = meta.id || name.slice(0, -6)
|
|
258
333
|
const thread = names.get(native) || ''
|
|
259
334
|
const title = thread || meta.title || 'Codex session'
|
|
260
335
|
if (isKeepWarmSessionTitle(title)) continue
|
|
336
|
+
budget.remaining -= 1
|
|
261
337
|
const users = await transcriptHaystack('codex', file)
|
|
262
|
-
const created = meta.created || createdFromCodexFilename(name) || isoFromMtime(
|
|
338
|
+
const created = meta.created || createdFromCodexFilename(name) || isoFromMtime(birthtimeMs)
|
|
263
339
|
pushDoc(docs, {
|
|
264
340
|
title,
|
|
265
341
|
haystack: clip(`${title}\n${thread}\n${meta.title}\n${meta.cwd}\n${users}`),
|
|
@@ -268,7 +344,7 @@ async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], bud
|
|
|
268
344
|
provider: 'codex',
|
|
269
345
|
display_label: title,
|
|
270
346
|
project: workspaceLabel(meta.cwd),
|
|
271
|
-
modified: isoFromMtime(
|
|
347
|
+
modified: isoFromMtime(mtimeMs),
|
|
272
348
|
created,
|
|
273
349
|
alive: false,
|
|
274
350
|
state: 'recent',
|
|
@@ -305,11 +381,12 @@ async function collectCursorDocs(
|
|
|
305
381
|
const ranked = [...byId.entries()].sort((a, b) => b[1].mtimeMs - a[1].mtimeMs)
|
|
306
382
|
for (const [sessionDir, candidate] of ranked) {
|
|
307
383
|
if (budget.remaining <= 0) break
|
|
308
|
-
budget.remaining -= 1
|
|
309
384
|
const sidebar = composerNames.get(sessionDir) || ''
|
|
310
385
|
const users = await transcriptHaystack('cursor', candidate.file)
|
|
311
386
|
const title = sidebar || users.split('\n')[0] || 'Cursor session'
|
|
387
|
+
// Cursor already ranked, but it charged the budget before this filter too.
|
|
312
388
|
if (isKeepWarmSessionTitle(title)) continue
|
|
389
|
+
budget.remaining -= 1
|
|
313
390
|
pushDoc(docs, {
|
|
314
391
|
title,
|
|
315
392
|
haystack: clip(`${title}\n${sidebar}\n${candidate.project}\n${users}`),
|
|
@@ -335,8 +412,9 @@ export async function collectAgentSessionSearchDocs(
|
|
|
335
412
|
): Promise<SearchDoc[]> {
|
|
336
413
|
const docs: SearchDoc[] = []
|
|
337
414
|
const share = Math.max(1, Math.ceil(Math.min(cap, MAX_SCAN_FILES) / 3))
|
|
338
|
-
|
|
339
|
-
await
|
|
415
|
+
const examined = share * EXAMINE_MULTIPLE
|
|
416
|
+
await collectClaudeDocs(roots, docs, { remaining: share, examined })
|
|
417
|
+
await collectCodexDocs(roots, docs, { remaining: share, examined })
|
|
340
418
|
await collectCursorDocs(roots, docs, { remaining: share }, now)
|
|
341
419
|
docs.sort((a, b) => (b.row.modified || '').localeCompare(a.row.modified || ''))
|
|
342
420
|
return docs.slice(0, Math.max(1, Math.min(cap, MAX_SCAN_FILES)))
|
|
@@ -97,10 +97,32 @@ export function isScratchCursorProject(folder: string): boolean {
|
|
|
97
97
|
|
|
98
98
|
export const isSkippedCursorFolder = isScratchCursorProject
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Titles that belong to a MACHINE, not to a conversation Miles had.
|
|
102
|
+
*
|
|
103
|
+
* Measured 2026-08-18 by driving the real collector over the 1,296 Claude transcripts on
|
|
104
|
+
* this machine: of the 41 Claude docs it indexed, 28 were machine prompts -- 22 Slack
|
|
105
|
+
* Bridge proxy, 4 reply-with, 2 slack_search_users. Two thirds of Claude search was
|
|
106
|
+
* answering with scaffolding.
|
|
107
|
+
*
|
|
108
|
+
* Every `you are ...` prefix here is ANCHORED to a specific known caller. A bare
|
|
109
|
+
* `startsWith('you are')` would also swallow any human sentence beginning that way
|
|
110
|
+
* ("You are right that ..."), and the anchored pair covers the measured volume without
|
|
111
|
+
* that risk.
|
|
112
|
+
*
|
|
113
|
+
* Shared by all four search collectors AND the session-list path, so a title added here
|
|
114
|
+
* also stops appearing as a row in COS Control's session list. That is the intent.
|
|
115
|
+
*/
|
|
100
116
|
export function isKeepWarmSessionTitle(title: string): boolean {
|
|
101
117
|
const t = title.trim().toLowerCase()
|
|
102
118
|
if (t === 'ready') return true
|
|
103
|
-
|
|
119
|
+
if (t.startsWith('this is an automated local readiness check')) return true
|
|
120
|
+
if (t.startsWith('you are the cos slack bridge proxy')) return true
|
|
121
|
+
if (t.startsWith('you are a post-processing editor')) return true
|
|
122
|
+
if (t.startsWith('call mcp__claude_ai_slack__slack_search_users')) return true
|
|
123
|
+
if (/^reply with (exactly|the single word)\b/.test(t)) return true
|
|
124
|
+
if (t === 'say ok' || t === 'say: ok' || t === 'reply ok') return true
|
|
125
|
+
return false
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
/**
|
|
@@ -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
|
+
}
|