@gotcos/glasses-server 6.27.13 → 6.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/server/index.ts +141 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +1550 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// Has the desktop thread changed since we last looked?
|
|
2
|
+
//
|
|
3
|
+
// Plan 4.3, the native-head watermark. One question, three answers: a bounded
|
|
4
|
+
// opaque revision token, or null meaning UNKNOWN. There is no third value and
|
|
5
|
+
// there is deliberately no "probably unchanged" — a caller that needs a
|
|
6
|
+
// watermark comparison and gets null must REFUSE the delivery. Refusing costs
|
|
7
|
+
// one Fork; guessing corrupts a real human conversation.
|
|
8
|
+
//
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// WHAT THIS DETECTS, STATED HONESTLY: COMPLETED TURNS ONLY.
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// A desktop turn that is currently GENERATING has written nothing to the
|
|
13
|
+
// transcript yet, so no tail-derived token can see it. The 2.2 canary showed
|
|
14
|
+
// exactly that — the file grew only after completion. This watermark therefore
|
|
15
|
+
// cannot fence a user typing into the desktop app at the same moment, and it
|
|
16
|
+
// must never be described as if it could.
|
|
17
|
+
//
|
|
18
|
+
// That gap is why the feature is gated on no-live-owner (plan 4.3 option B,
|
|
19
|
+
// `thread-occupancy.ts`) rather than on this token. The watermark's job is the
|
|
20
|
+
// narrower one of making a COMPLETED divergence visible between COS turns. It
|
|
21
|
+
// is a second, weaker barrier behind the occupancy gate, not a substitute for
|
|
22
|
+
// it, and it closes no race on its own.
|
|
23
|
+
//
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// MESSAGE-BEARING ROWS ONLY. This is the load-bearing rule.
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// A resumed CLI writes session and mode rows to the transcript AT SPAWN, before
|
|
28
|
+
// any prompt byte reaches stdin (plan 4.3, observed in the 2.2 canary whose
|
|
29
|
+
// final row was `type: "mode"` with a null uuid). So a run that aborts before
|
|
30
|
+
// delivery still advances a NAIVE byte- or line-derived head. The next attempt
|
|
31
|
+
// then reports `native_thread_changed` while its own journal says the delivery
|
|
32
|
+
// was aborted — a spurious change that contradicts its own abort record, and
|
|
33
|
+
// one the user cannot distinguish from a real desktop turn.
|
|
34
|
+
//
|
|
35
|
+
// The filter below therefore counts only rows that actually carry a message,
|
|
36
|
+
// and the row taxonomy is measured, not assumed. Counts from this machine on
|
|
37
|
+
// 2026-08-16, over the six largest real Claude transcripts and the four most
|
|
38
|
+
// recent Codex rollouts (schema keys and type labels only — no content read):
|
|
39
|
+
//
|
|
40
|
+
// Claude, rows WITH a `message` object: user 17,814 assistant 34,948
|
|
41
|
+
// Claude, rows WITHOUT one: queue-operation, attachment,
|
|
42
|
+
// last-prompt, custom-title, mode,
|
|
43
|
+
// system, frame-link (20,653 rows)
|
|
44
|
+
// Claude, message rows missing `uuid`: 0
|
|
45
|
+
// Claude, `isMeta: true`: 190, all on `user` rows
|
|
46
|
+
//
|
|
47
|
+
// Codex top-level types: session_meta, event_msg, response_item,
|
|
48
|
+
// world_state, turn_context, compacted,
|
|
49
|
+
// inter_agent_communication_metadata
|
|
50
|
+
// Codex message payloads: response_item/message role=user|assistant|developer,
|
|
51
|
+
// response_item/agent_message,
|
|
52
|
+
// event_msg/user_message, event_msg/agent_message
|
|
53
|
+
//
|
|
54
|
+
// Two exclusions inside that set are deliberate and are the whole point:
|
|
55
|
+
//
|
|
56
|
+
// Claude `isMeta: true` user rows. These are injected context, not something
|
|
57
|
+
// the user typed, and a resumed CLI can write them at spawn. Excluding them
|
|
58
|
+
// cannot hide a completed desktop turn, because every completed turn also
|
|
59
|
+
// emits assistant message rows.
|
|
60
|
+
//
|
|
61
|
+
// Codex `response_item/message` with `role: "developer"`. That is the
|
|
62
|
+
// instruction block injected when a thread is opened or resumed — the exact
|
|
63
|
+
// Codex analogue of Claude's spawn-time mode row. 37 of the 120 message rows
|
|
64
|
+
// sampled were developer rows.
|
|
65
|
+
//
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// FAIL CLOSED. Every branch below resolves an unknown to null.
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Unsupported provider, malformed id, missing directory, unreadable directory,
|
|
70
|
+
// absent transcript, TWO candidate transcripts, a short read, a corrupt row, a
|
|
71
|
+
// transcript with no message rows at all, a throwing dependency: all null. The
|
|
72
|
+
// only path that returns a token is one where a single transcript was located
|
|
73
|
+
// and fully understood.
|
|
74
|
+
//
|
|
75
|
+
// PATH DERIVATION IS REUSED, NOT REINVENTED. `agentSessionRoots()` and
|
|
76
|
+
// `idFromCodexFilename()` come from agent-session-store.ts, which is the module
|
|
77
|
+
// that already knows a Claude session id does not encode its project slug — the
|
|
78
|
+
// slug is the cwd, so the only way to find `<id>.jsonl` is to scan every folder
|
|
79
|
+
// under `~/.claude/projects`. Reusing those roots also guarantees this module
|
|
80
|
+
// reads the very file discovery listed.
|
|
81
|
+
//
|
|
82
|
+
// `findAgentSessionFile()` itself is deliberately NOT called, for three reasons
|
|
83
|
+
// that all point the same way: it prefix-matches (`id.startsWith(needle)`), so
|
|
84
|
+
// a wrong-but-similar file can win; it returns the FIRST match in directory
|
|
85
|
+
// order, so two candidates resolve silently instead of ambiguously; and its
|
|
86
|
+
// `dirents()` helper swallows readdir errors into `[]`, which turns an
|
|
87
|
+
// unreadable directory into "not found". Each is a fail-open for a watermark.
|
|
88
|
+
//
|
|
89
|
+
// KNOWN LIMIT OF A WHITELIST CLASSIFIER. Because only the row shapes measured
|
|
90
|
+
// above count, a FUTURE provider version that carries a message under a new
|
|
91
|
+
// `type` would be invisible here — the head would sit still through a real
|
|
92
|
+
// desktop turn, which is the fail-open direction. Counting everything instead
|
|
93
|
+
// is not an option, because that is the spurious-advance defect this filter
|
|
94
|
+
// exists for. The mitigation is the one Phase 0 already mandates: re-run the
|
|
95
|
+
// schema census per provider, per version, as part of certification, and treat
|
|
96
|
+
// an unrecognised row taxonomy as a reason to withhold the provider flag.
|
|
97
|
+
//
|
|
98
|
+
// TWO GUARDS ARE DELIBERATELY UNCOVERED, established by mutation on 2026-08-16
|
|
99
|
+
// (24 of 26 mutations caught; these are the two survivors, reported rather than
|
|
100
|
+
// quietly dropped):
|
|
101
|
+
//
|
|
102
|
+
// The short-read refusal in `readTail`. `readSync` fills a regular file, so
|
|
103
|
+
// reaching it needs a concurrent truncation race that a unit test cannot
|
|
104
|
+
// stage. Defensive, unreachable from the suite, kept.
|
|
105
|
+
//
|
|
106
|
+
// The provider salt in the digest. The two classifiers are disjoint on `type`
|
|
107
|
+
// — no row satisfies both — so no fixture can make one thread produce the
|
|
108
|
+
// same identity list under both providers, and removing the salt changes no
|
|
109
|
+
// observable output. Defense-in-depth against a future classifier that
|
|
110
|
+
// overlaps, not a live invariant.
|
|
111
|
+
//
|
|
112
|
+
// KNOWN DIVERGENCE, worth naming rather than hiding. `agentSessionRoots()`
|
|
113
|
+
// honours `COS_AGENT_SESSIONS_HOME`, while the occupancy detector's
|
|
114
|
+
// `claudeSessionsDir()` honours `COS_CLAUDE_SESSIONS_DIR` then
|
|
115
|
+
// `CLAUDE_CONFIG_DIR`. On an install that sets only `CLAUDE_CONFIG_DIR`, this
|
|
116
|
+
// module looks in `~/.claude/projects`, finds nothing, and returns null — the
|
|
117
|
+
// thread becomes Fork-only. That is the safe direction, but it is a real
|
|
118
|
+
// capability gap and the two precedences should be reconciled repo-wide.
|
|
119
|
+
|
|
120
|
+
import { createHash } from 'node:crypto'
|
|
121
|
+
import { closeSync, constants as fsConstants, fstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs'
|
|
122
|
+
import { join } from 'node:path'
|
|
123
|
+
import { agentSessionRoots, idFromCodexFilename } from './agent-session-store.js'
|
|
124
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
125
|
+
|
|
126
|
+
/** Providers with a certified transcript shape. Anything else has no watermark. */
|
|
127
|
+
export type NativeHeadProvider = 'claude' | 'codex'
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Token prefix. Bumping it forces every stored head to compare as CHANGED,
|
|
131
|
+
* which surfaces a refusal rather than a silent mis-comparison across versions.
|
|
132
|
+
*/
|
|
133
|
+
export const NATIVE_HEAD_VERSION = 'nh1'
|
|
134
|
+
|
|
135
|
+
/** `nh1:` + 128 bits of SHA-256. Fixed length for every input, by construction. */
|
|
136
|
+
export const NATIVE_HEAD_TOKEN_RE = /^nh1:[0-9a-f]{32}$/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bytes read from the END of a transcript.
|
|
140
|
+
*
|
|
141
|
+
* MEASURED, not guessed. Across the eight largest real Claude transcripts on
|
|
142
|
+
* this machine (23 MB to 121 MB), the distance from EOF back to the third-most-
|
|
143
|
+
* recent message-bearing row was 13 KB, 58 KB, 83 KB, 295 KB, 302 KB, 321 KB,
|
|
144
|
+
* 345 KB and 637 KB. A 512 KiB window would have failed on the last of those.
|
|
145
|
+
* 2 MiB clears the measured worst case by 3x.
|
|
146
|
+
*/
|
|
147
|
+
export const TAIL_WINDOW_BYTES = 2 * 1024 * 1024
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Per-provider tail windows, MEASURED on this machine 2026-08-16 over every thread
|
|
151
|
+
* touched in the last 30 days. The single 2 MiB constant above was derived from
|
|
152
|
+
* Claude transcripts only and was wrong for BOTH providers.
|
|
153
|
+
*
|
|
154
|
+
* claude n=1262 EOF-to-3rd-most-recent-message-row max 3.63 MiB (1 null at 2 MiB)
|
|
155
|
+
* codex n=167 p90 0.75 MiB, max 46.90 MiB (14 nulls at 2 MiB)
|
|
156
|
+
*
|
|
157
|
+
* Codex rollouts reach 13.6 GB, so the window is what keeps this bounded; it is not
|
|
158
|
+
* an attempt to read the file. Undersizing fails CLOSED — the head reads null, the
|
|
159
|
+
* caller reports native_head_unavailable, and the thread degrades to Fork-only — so
|
|
160
|
+
* the old value cost availability, never safety. A backwards chunked read to a hard
|
|
161
|
+
* cap would beat a fixed window and is the right eventual shape.
|
|
162
|
+
*/
|
|
163
|
+
export const TAIL_WINDOW_BYTES_BY_PROVIDER: Record<NativeHeadProvider, number> = {
|
|
164
|
+
claude: 8 * 1024 * 1024,
|
|
165
|
+
codex: 64 * 1024 * 1024,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* How many trailing message-bearing rows the token is derived from.
|
|
170
|
+
*
|
|
171
|
+
* Not 1, because a single row's identity is thinner than it looks. Not 20,
|
|
172
|
+
* because every extra row is another chance for the window to slide past it and
|
|
173
|
+
* report a false change. Three rows of real transcript is already far more
|
|
174
|
+
* entropy than a 128-bit digest can hold.
|
|
175
|
+
*/
|
|
176
|
+
export const HEAD_ROW_COUNT = 3
|
|
177
|
+
|
|
178
|
+
/** Ceilings that keep a pathological tree from turning attach into a directory walk. */
|
|
179
|
+
export const MAX_CLAUDE_PROJECT_DIRS = 2048
|
|
180
|
+
export const MAX_CODEX_DIR_ENTRIES = 512
|
|
181
|
+
export const MAX_CODEX_FILES_SCANNED = 20_000
|
|
182
|
+
|
|
183
|
+
export interface NativeHeadDirs {
|
|
184
|
+
/** `<COS_AGENT_SESSIONS_HOME|~>/.claude/projects` */
|
|
185
|
+
claudeProjectsDir: string
|
|
186
|
+
/** `<COS_AGENT_SESSIONS_HOME|~>/.codex/sessions` */
|
|
187
|
+
codexSessionsDir: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface TailRead {
|
|
191
|
+
/** UTF-8 decoding of the window. */
|
|
192
|
+
text: string
|
|
193
|
+
/**
|
|
194
|
+
* True when the window does NOT start at byte 0, so its first line is a
|
|
195
|
+
* fragment of a row rather than a row.
|
|
196
|
+
*
|
|
197
|
+
* The probe decides this from the SAME fstat it reads with. Deriving it from
|
|
198
|
+
* a separate size call would race an append and mislabel a real fragment as a
|
|
199
|
+
* whole row.
|
|
200
|
+
*/
|
|
201
|
+
truncated: boolean
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface NativeHeadDeps {
|
|
205
|
+
dirs: NativeHeadDirs
|
|
206
|
+
/** Does this directory exist? False also covers "cannot tell". */
|
|
207
|
+
dirExists: (path: string) => boolean
|
|
208
|
+
/** Entry names only. MUST THROW on an unreadable directory, never return []. */
|
|
209
|
+
readDir: (path: string) => string[]
|
|
210
|
+
/** Is this a readable regular file? */
|
|
211
|
+
fileExists: (path: string) => boolean
|
|
212
|
+
/** Last `maxBytes` of a file. Null means UNREADABLE or SHORT, never "empty". */
|
|
213
|
+
readTail: (path: string, maxBytes: number) => TailRead | null
|
|
214
|
+
/**
|
|
215
|
+
* Window size, injectable ONLY so a test can prove the truncation branch.
|
|
216
|
+
*
|
|
217
|
+
* With the production 2 MiB value, any fixture small enough to write quickly
|
|
218
|
+
* is read whole, so the truncated path would never execute and a test could
|
|
219
|
+
* not tell windowing from no windowing. Same reasoning, and the same hazard,
|
|
220
|
+
* as `agentSessionLines`' injectable head/tail sizes.
|
|
221
|
+
*/
|
|
222
|
+
tailWindowBytes?: number
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Result of comparing two heads.
|
|
227
|
+
*
|
|
228
|
+
* Three values, not a boolean, because null is not "same" and is not "changed".
|
|
229
|
+
* A boolean would force every caller to invent its own meaning for the unknown
|
|
230
|
+
* case, and the cheap invention — treat null as false — is the fail-open this
|
|
231
|
+
* whole module exists to prevent.
|
|
232
|
+
*/
|
|
233
|
+
export type NativeHeadComparison = 'same' | 'changed' | 'unknown'
|
|
234
|
+
|
|
235
|
+
export function isNativeHeadToken(value: unknown): value is string {
|
|
236
|
+
return typeof value === 'string' && NATIVE_HEAD_TOKEN_RE.test(value)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Did the desktop thread change between these two observations?
|
|
241
|
+
*
|
|
242
|
+
* `unknown` for null on either side, and equally for any value that is not a
|
|
243
|
+
* well-formed token: an unrecognised input is an unknown, never a match.
|
|
244
|
+
*/
|
|
245
|
+
export function headsDiffer(a: string | null, b: string | null): NativeHeadComparison {
|
|
246
|
+
if (!isNativeHeadToken(a) || !isNativeHeadToken(b)) return 'unknown'
|
|
247
|
+
return a === b ? 'same' : 'changed'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseJsonObject(raw: string): Record<string, unknown> | null {
|
|
251
|
+
try {
|
|
252
|
+
const parsed: unknown = JSON.parse(raw)
|
|
253
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
254
|
+
? (parsed as Record<string, unknown>)
|
|
255
|
+
: null
|
|
256
|
+
} catch {
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function asObject(value: unknown): Record<string, unknown> | null {
|
|
262
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
263
|
+
? (value as Record<string, unknown>)
|
|
264
|
+
: null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Claude: a row carries a message when it is a user/assistant turn with a
|
|
269
|
+
* `message` object, and is not injected meta context.
|
|
270
|
+
*
|
|
271
|
+
* Keyed on the presence of `message` rather than on a list of excluded types,
|
|
272
|
+
* so a type this repo has never seen cannot default into the head. Measured:
|
|
273
|
+
* only `user` and `assistant` rows have one, and all 52,762 of them had a uuid.
|
|
274
|
+
*/
|
|
275
|
+
function isClaudeMessageRow(row: Record<string, unknown>): boolean {
|
|
276
|
+
if (row.type !== 'user' && row.type !== 'assistant') return false
|
|
277
|
+
if (row.isMeta === true) return false
|
|
278
|
+
return asObject(row.message) !== null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Codex: a row carries a message when its payload is a user-visible message.
|
|
283
|
+
*
|
|
284
|
+
* `role: "developer"` is excluded — that is the instruction block written when
|
|
285
|
+
* a thread is opened or resumed, i.e. spawn-time, i.e. exactly what must not
|
|
286
|
+
* move the head. Everything outside this set (session_meta, turn_context,
|
|
287
|
+
* world_state, token_count, reasoning, tool calls) is excluded by omission.
|
|
288
|
+
*/
|
|
289
|
+
function isCodexMessageRow(row: Record<string, unknown>): boolean {
|
|
290
|
+
const payload = asObject(row.payload)
|
|
291
|
+
if (!payload) return false
|
|
292
|
+
const kind = payload.type
|
|
293
|
+
if (row.type === 'response_item') {
|
|
294
|
+
if (kind === 'message') return payload.role === 'user' || payload.role === 'assistant'
|
|
295
|
+
return kind === 'agent_message'
|
|
296
|
+
}
|
|
297
|
+
if (row.type === 'event_msg') return kind === 'agent_message' || kind === 'user_message'
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function isMessageBearingRow(
|
|
302
|
+
provider: NativeHeadProvider,
|
|
303
|
+
row: Record<string, unknown>,
|
|
304
|
+
): boolean {
|
|
305
|
+
// Exhaustive, not a binary fallthrough. A ternary gave every unrecognised
|
|
306
|
+
// provider Codex row semantics, so a future provider would silently classify
|
|
307
|
+
// against the wrong schema — and the fail-open direction here is a head that
|
|
308
|
+
// does not advance on real messages, i.e. divergence that never reports.
|
|
309
|
+
if (provider === 'claude') return isClaudeMessageRow(row)
|
|
310
|
+
if (provider === 'codex') return isCodexMessageRow(row)
|
|
311
|
+
return false
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function sha256Hex(input: string): string {
|
|
315
|
+
return createHash('sha256').update(input, 'utf8').digest('hex')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Per-row identity feeding the digest.
|
|
320
|
+
*
|
|
321
|
+
* The whole normalized line, hashed. Not the uuid: `uuid` is absent on Codex
|
|
322
|
+
* rows entirely, and hashing the line needs no schema beyond the classifier
|
|
323
|
+
* above while detecting any byte that changed. The output is one-way, so no
|
|
324
|
+
* transcript content survives into the token.
|
|
325
|
+
*/
|
|
326
|
+
function rowIdentity(normalizedLine: string): string {
|
|
327
|
+
return sha256Hex(normalizedLine)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Identities of the message-bearing rows in a window, oldest first.
|
|
332
|
+
*
|
|
333
|
+
* Null means the window could not be fully understood. Two lines are exempt
|
|
334
|
+
* from that, and only two:
|
|
335
|
+
*
|
|
336
|
+
* The FIRST line when the window is truncated. It is a fragment by
|
|
337
|
+
* construction, so it is dropped without being parsed at all — including in
|
|
338
|
+
* the lucky case where the cut lands on a row boundary and it would parse.
|
|
339
|
+
* Dropping it unconditionally keeps the rule one sentence long.
|
|
340
|
+
*
|
|
341
|
+
* The LAST line, when it does not parse. These files are appended to, so a
|
|
342
|
+
* torn final row is normal. It is skipped rather than counted: an incomplete
|
|
343
|
+
* row is not yet a row, and it will move the head when it completes.
|
|
344
|
+
*
|
|
345
|
+
* Any OTHER unparseable non-blank line is corruption in the middle of a file
|
|
346
|
+
* that is supposed to be append-only, and corruption is an unknown.
|
|
347
|
+
*/
|
|
348
|
+
function messageRowIdentities(provider: NativeHeadProvider, tail: TailRead): string[] | null {
|
|
349
|
+
const lines = tail.text.split('\n')
|
|
350
|
+
const first = tail.truncated ? 1 : 0
|
|
351
|
+
const identities: string[] = []
|
|
352
|
+
|
|
353
|
+
for (let i = first; i < lines.length; i++) {
|
|
354
|
+
const line = lines[i]!.trim()
|
|
355
|
+
if (line === '') continue
|
|
356
|
+
|
|
357
|
+
const row = parseJsonObject(line)
|
|
358
|
+
if (row === null) {
|
|
359
|
+
if (i === lines.length - 1) continue
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (isMessageBearingRow(provider, row)) identities.push(rowIdentity(line))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return identities
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The single Claude transcript for this thread, or null.
|
|
371
|
+
*
|
|
372
|
+
* A Claude session id does not encode its project slug, so every folder under
|
|
373
|
+
* `projects/` is a candidate and the file is probed by exact name. Two matches
|
|
374
|
+
* — the same id filed under two project slugs, which a moved or re-rooted
|
|
375
|
+
* workspace can produce — is an ambiguity, and an ambiguity is an unknown. The
|
|
376
|
+
* store's prefix-match-first-wins would have picked one at random.
|
|
377
|
+
*/
|
|
378
|
+
/**
|
|
379
|
+
* Locate the one transcript for a thread, or null when it is not exactly one.
|
|
380
|
+
*
|
|
381
|
+
* Exported so the workspace resolver reuses this scan instead of reimplementing
|
|
382
|
+
* the ambiguity rule. Two modules disagreeing about which file IS the thread is
|
|
383
|
+
* the same class of drift that let a truncated id through the occupancy detector.
|
|
384
|
+
*/
|
|
385
|
+
export function transcriptPathFor(
|
|
386
|
+
provider: string,
|
|
387
|
+
threadId: string,
|
|
388
|
+
deps: NativeHeadDeps,
|
|
389
|
+
): string | null {
|
|
390
|
+
try {
|
|
391
|
+
if (!isValidNativeThreadId(threadId)) return null
|
|
392
|
+
if (provider === 'claude') return claudeTranscriptPath(threadId, deps)
|
|
393
|
+
if (provider === 'codex') return codexRolloutPath(threadId, deps)
|
|
394
|
+
return null
|
|
395
|
+
} catch {
|
|
396
|
+
return null
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function claudeTranscriptPath(threadId: string, deps: NativeHeadDeps): string | null {
|
|
401
|
+
const root = deps.dirs.claudeProjectsDir
|
|
402
|
+
if (!deps.dirExists(root)) return null
|
|
403
|
+
|
|
404
|
+
const folders = deps.readDir(root)
|
|
405
|
+
if (folders.length > MAX_CLAUDE_PROJECT_DIRS) return null
|
|
406
|
+
|
|
407
|
+
const filename = `${threadId}.jsonl`
|
|
408
|
+
let found: string | null = null
|
|
409
|
+
for (const folder of folders) {
|
|
410
|
+
const candidate = join(root, folder, filename)
|
|
411
|
+
if (!deps.fileExists(candidate)) continue
|
|
412
|
+
if (found !== null) return null
|
|
413
|
+
found = candidate
|
|
414
|
+
}
|
|
415
|
+
return found
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The single Codex rollout for this thread, or null.
|
|
420
|
+
*
|
|
421
|
+
* Rollouts live at `sessions/<yyyy>/<mm>/<dd>/rollout-<stamp>-<threadId>.jsonl`,
|
|
422
|
+
* so the id is recoverable from the filename and the date path never has to be
|
|
423
|
+
* guessed. `idFromCodexFilename` is the repo's existing parser for that name;
|
|
424
|
+
* the `rollout-` prefix is additionally required because that is the documented
|
|
425
|
+
* shape, and a file that does not match it is something this module has never
|
|
426
|
+
* certified.
|
|
427
|
+
*/
|
|
428
|
+
function codexRolloutPath(threadId: string, deps: NativeHeadDeps): string | null {
|
|
429
|
+
const root = deps.dirs.codexSessionsDir
|
|
430
|
+
if (!deps.dirExists(root)) return null
|
|
431
|
+
|
|
432
|
+
let scanned = 0
|
|
433
|
+
let found: string | null = null
|
|
434
|
+
|
|
435
|
+
const years = deps.readDir(root)
|
|
436
|
+
if (years.length > MAX_CODEX_DIR_ENTRIES) return null
|
|
437
|
+
for (const year of years) {
|
|
438
|
+
const yearDir = join(root, year)
|
|
439
|
+
if (!deps.dirExists(yearDir)) continue
|
|
440
|
+
const months = deps.readDir(yearDir)
|
|
441
|
+
if (months.length > MAX_CODEX_DIR_ENTRIES) return null
|
|
442
|
+
|
|
443
|
+
for (const month of months) {
|
|
444
|
+
const monthDir = join(yearDir, month)
|
|
445
|
+
if (!deps.dirExists(monthDir)) continue
|
|
446
|
+
const days = deps.readDir(monthDir)
|
|
447
|
+
if (days.length > MAX_CODEX_DIR_ENTRIES) return null
|
|
448
|
+
|
|
449
|
+
for (const day of days) {
|
|
450
|
+
const dayDir = join(monthDir, day)
|
|
451
|
+
if (!deps.dirExists(dayDir)) continue
|
|
452
|
+
|
|
453
|
+
for (const name of deps.readDir(dayDir)) {
|
|
454
|
+
if (++scanned > MAX_CODEX_FILES_SCANNED) return null
|
|
455
|
+
if (!name.startsWith('rollout-')) continue
|
|
456
|
+
if (idFromCodexFilename(name) !== threadId) continue
|
|
457
|
+
// The Claude path has had this filter all along; the Codex path selected
|
|
458
|
+
// on FILENAME alone, so a `rollout-*-<id>.jsonl` FIFO or directory was a
|
|
459
|
+
// valid candidate. The open flags now make that safe, but two sibling
|
|
460
|
+
// resolvers disagreeing about what counts as a transcript is the drift
|
|
461
|
+
// that produced the hazard in the first place.
|
|
462
|
+
if (!deps.fileExists(join(dayDir, name))) continue
|
|
463
|
+
if (found !== null) return null
|
|
464
|
+
found = join(dayDir, name)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return found
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* A bounded opaque revision token for a native desktop thread, or null for
|
|
475
|
+
* UNKNOWN.
|
|
476
|
+
*
|
|
477
|
+
* Null is not "unchanged". A caller whose decision depends on comparing heads
|
|
478
|
+
* must refuse the turn when it gets one, per plan 4.3.
|
|
479
|
+
*
|
|
480
|
+
* The token is a digest over the identities of the last `HEAD_ROW_COUNT`
|
|
481
|
+
* message-bearing rows, salted with the provider and thread id. Consequences,
|
|
482
|
+
* all of them intentional:
|
|
483
|
+
*
|
|
484
|
+
* No transcript content leaves this function. SHA-256 is one-way.
|
|
485
|
+
* No filesystem path leaves it either — the located path is never hashed in,
|
|
486
|
+
* so the same thread moved between project folders keeps its token.
|
|
487
|
+
* No length signal leaves it. Two transcripts sharing their last three
|
|
488
|
+
* message rows produce the same token however different their sizes are, and
|
|
489
|
+
* the token is 36 characters for a 4 KB file and for a 121 MB one.
|
|
490
|
+
*/
|
|
491
|
+
export function nativeHead(
|
|
492
|
+
provider: string,
|
|
493
|
+
threadId: string,
|
|
494
|
+
deps: NativeHeadDeps,
|
|
495
|
+
): string | null {
|
|
496
|
+
try {
|
|
497
|
+
if (provider !== 'claude' && provider !== 'codex') return null
|
|
498
|
+
// Validated before any scan. A truncated or malformed id reaches a
|
|
499
|
+
// filesystem path below, and "matched nothing" must never look like a
|
|
500
|
+
// clean read of a real transcript.
|
|
501
|
+
if (!isValidNativeThreadId(threadId)) return null
|
|
502
|
+
|
|
503
|
+
const path = provider === 'claude'
|
|
504
|
+
? claudeTranscriptPath(threadId, deps)
|
|
505
|
+
: codexRolloutPath(threadId, deps)
|
|
506
|
+
if (path === null) return null
|
|
507
|
+
|
|
508
|
+
const window = deps.tailWindowBytes ?? TAIL_WINDOW_BYTES_BY_PROVIDER[provider] ?? TAIL_WINDOW_BYTES
|
|
509
|
+
const tail = deps.readTail(path, window)
|
|
510
|
+
if (tail === null) return null
|
|
511
|
+
|
|
512
|
+
const identities = messageRowIdentities(provider, tail)
|
|
513
|
+
if (identities === null) return null
|
|
514
|
+
|
|
515
|
+
// No message-bearing rows at all: a transcript that is nothing but session
|
|
516
|
+
// and mode rows. There is nothing to watermark, so the answer is unknown
|
|
517
|
+
// rather than a token that every future spawn row would leave unmoved.
|
|
518
|
+
if (identities.length === 0) return null
|
|
519
|
+
|
|
520
|
+
// Truncated AND short of a full row set means the window slid far enough to
|
|
521
|
+
// matter: a later spawn-time append could push another row out and flip the
|
|
522
|
+
// token without a real turn — the precise false-change this module exists
|
|
523
|
+
// to prevent. Refuse instead of emitting a token that cannot be trusted to
|
|
524
|
+
// stay put. Reachable only when three consecutive rows exceed 2 MiB.
|
|
525
|
+
if (tail.truncated && identities.length < HEAD_ROW_COUNT) return null
|
|
526
|
+
|
|
527
|
+
// A space separates the fields, and it is unambiguous rather than merely
|
|
528
|
+
// conventional: the version is `nh1`, the provider is one of two literals,
|
|
529
|
+
// the thread id is hex-and-hyphen by `isValidNativeThreadId`, and every
|
|
530
|
+
// identity is 64 lowercase hex characters. No field can contain a space, so
|
|
531
|
+
// no two different field lists can concatenate to the same string. (An
|
|
532
|
+
// earlier draft used a literal NUL here, which achieves the same thing while
|
|
533
|
+
// making the source read as a binary file to grep, git and review tools.)
|
|
534
|
+
const digest = createHash('sha256')
|
|
535
|
+
digest.update(NATIVE_HEAD_VERSION)
|
|
536
|
+
digest.update(' ')
|
|
537
|
+
digest.update(provider)
|
|
538
|
+
digest.update(' ')
|
|
539
|
+
digest.update(threadId)
|
|
540
|
+
for (const identity of identities.slice(-HEAD_ROW_COUNT)) {
|
|
541
|
+
digest.update(' ')
|
|
542
|
+
digest.update(identity)
|
|
543
|
+
}
|
|
544
|
+
return `${NATIVE_HEAD_VERSION}:${digest.digest('hex').slice(0, 32)}`
|
|
545
|
+
} catch {
|
|
546
|
+
// Includes a throwing dependency. A watermark that raises into a route is
|
|
547
|
+
// worse than one that says it does not know.
|
|
548
|
+
return null
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
// Real filesystem dependencies.
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
export function realNativeHeadDirs(): NativeHeadDirs {
|
|
557
|
+
const roots = agentSessionRoots()
|
|
558
|
+
return { claudeProjectsDir: roots.claudeProjects, codexSessionsDir: roots.codexSessions }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function dirExists(path: string): boolean {
|
|
562
|
+
// `throwIfNoEntry: false` separates the two cases a bare catch collapsed.
|
|
563
|
+
// ENOENT is genuinely "not there" -> false. EACCES, ELOOP, EIO and friends mean
|
|
564
|
+
// "cannot tell", and those must THROW so `nativeHead`'s outer catch turns them
|
|
565
|
+
// into null. The catch-all previously DROPPED an unreadable candidate from
|
|
566
|
+
// enumeration, which defeats the "two candidate transcripts -> null" ambiguity
|
|
567
|
+
// rule: a transient stat failure on the LIVE transcript silently promoted a
|
|
568
|
+
// stale twin to the only candidate, and its head never moves, so every later
|
|
569
|
+
// comparison reads `same` while the desktop conversation diverges.
|
|
570
|
+
const stat = statSync(path, { throwIfNoEntry: false })
|
|
571
|
+
return stat !== undefined && stat.isDirectory()
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Entry names, THROWING on an unreadable directory.
|
|
576
|
+
*
|
|
577
|
+
* `readdirSync` already does this and it must not be softened. Returning `[]`
|
|
578
|
+
* on EACCES would read as "no project folders", which resolves to "transcript
|
|
579
|
+
* not found", which is a null — same answer here, but the habit is the fail-
|
|
580
|
+
* open that has already shipped in this repo once, and the equivalent probe in
|
|
581
|
+
* occupancy-probes.ts throws for the same reason.
|
|
582
|
+
*/
|
|
583
|
+
function readDir(path: string): string[] {
|
|
584
|
+
return readdirSync(path)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function fileExists(path: string): boolean {
|
|
588
|
+
// Same rule as dirExists: absent is false, unreadable throws. See the note there.
|
|
589
|
+
const stat = statSync(path, { throwIfNoEntry: false })
|
|
590
|
+
return stat !== undefined && stat.isFile()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Last `maxBytes` of a file.
|
|
595
|
+
*
|
|
596
|
+
* One open, one fstat, one positioned read, so `truncated` and the bytes come
|
|
597
|
+
* from the same view of the file. A short read returns null rather than a
|
|
598
|
+
* partial window: a partial window silently drops trailing rows, and dropping
|
|
599
|
+
* trailing rows is how a head fails to notice a real desktop turn.
|
|
600
|
+
*/
|
|
601
|
+
function readTail(path: string, maxBytes: number): TailRead | null {
|
|
602
|
+
let fd = -1
|
|
603
|
+
try {
|
|
604
|
+
// THE THIRD INSTANCE OF THIS BUG, and the one reachable from both write
|
|
605
|
+
// routes. `openSync` on a FIFO with no writer never returns, and it is a
|
|
606
|
+
// synchronous syscall on Node's single thread, so a planted
|
|
607
|
+
// `rollout-*-<id>.jsonl` FIFO wedges the ENTIRE server — health, meeting save,
|
|
608
|
+
// transcribe-stream — not merely this request. Measured: blocked >34s in state
|
|
609
|
+
// SN and had to be SIGKILLed. The `isFile()` check below runs AFTER the open
|
|
610
|
+
// and cannot prevent it.
|
|
611
|
+
//
|
|
612
|
+
// occupancy-probes.readFile and attached-workspace.readHead already carry
|
|
613
|
+
// these flags; this copy was missed. O_NOFOLLOW additionally refuses a
|
|
614
|
+
// symlinked transcript that could point anywhere on disk.
|
|
615
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
|
|
616
|
+
const nonBlock = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0
|
|
617
|
+
fd = openSync(path, fsConstants.O_RDONLY | noFollow | nonBlock)
|
|
618
|
+
const stat = fstatSync(fd)
|
|
619
|
+
if (!stat.isFile()) return null
|
|
620
|
+
|
|
621
|
+
const want = Math.min(stat.size, Math.max(0, maxBytes))
|
|
622
|
+
const start = stat.size - want
|
|
623
|
+
const buffer = Buffer.allocUnsafe(want)
|
|
624
|
+
|
|
625
|
+
let read = 0
|
|
626
|
+
while (read < want) {
|
|
627
|
+
const n = readSync(fd, buffer, read, want - read, start + read)
|
|
628
|
+
if (n <= 0) break
|
|
629
|
+
read += n
|
|
630
|
+
}
|
|
631
|
+
if (read !== want) return null
|
|
632
|
+
|
|
633
|
+
return { text: buffer.toString('utf8'), truncated: start > 0 }
|
|
634
|
+
} catch {
|
|
635
|
+
return null
|
|
636
|
+
} finally {
|
|
637
|
+
if (fd >= 0) {
|
|
638
|
+
try {
|
|
639
|
+
closeSync(fd)
|
|
640
|
+
} catch {
|
|
641
|
+
/* already closed or never opened cleanly; nothing to recover */
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function realNativeHeadDeps(dirs: NativeHeadDirs = realNativeHeadDirs()): NativeHeadDeps {
|
|
648
|
+
return { dirs, dirExists, readDir, fileExists, readTail }
|
|
649
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// One definition of "what is a native thread id", shared by every module that
|
|
2
|
+
// touches one.
|
|
3
|
+
//
|
|
4
|
+
// This exists because two modules written in the same session already disagreed.
|
|
5
|
+
// The occupancy detector accepted any non-empty string and the binding store
|
|
6
|
+
// required a UUID, so a truncated id sailed through occupancy — which reports
|
|
7
|
+
// "no owner found" — and came back attachable while a desktop process held the
|
|
8
|
+
// thread. QA verified that end to end. A single exported regex is the only way
|
|
9
|
+
// the two stay aligned.
|
|
10
|
+
//
|
|
11
|
+
// DELIBERATELY STRICTER THAN BOTH EXISTING REPO VALIDATORS:
|
|
12
|
+
// agent-session-store.ts isSafeSessionId -> hex + hyphen, any length >= 8
|
|
13
|
+
// query-job-types.ts SAFE_ID_RE -> also allows . _ : @ and /
|
|
14
|
+
// A native thread id becomes a mutex key and a filesystem path component, so
|
|
15
|
+
// `/` and `.` are disqualifying, and the 8-character display form
|
|
16
|
+
// (`ClaudePeer.id` is `sessionId.slice(0, 8)`) must never validate.
|
|
17
|
+
|
|
18
|
+
export const NATIVE_THREAD_ID_RE =
|
|
19
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
20
|
+
|
|
21
|
+
export function isValidNativeThreadId(value: unknown): value is string {
|
|
22
|
+
return typeof value === 'string' && NATIVE_THREAD_ID_RE.test(value)
|
|
23
|
+
}
|