@gotcos/glasses-server 6.27.13 → 6.29.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.
@@ -0,0 +1,367 @@
1
+ // Is anyone else writing to this thread right now?
2
+ //
3
+ // Phase 0 of Continue Original Agent Thread. Protocol 1 attaches to a native
4
+ // desktop thread ONLY when no live process owns it (plan 4.3, resolved to
5
+ // option B). This module answers that one question: attachable, or Fork-only
6
+ // with a reason.
7
+ //
8
+ // THE RULE THAT GOVERNS EVERY LINE BELOW: "I found no owner" is NOT "there is
9
+ // no owner." Returning attachable requires POSITIVE proof of an empty registry
10
+ // — a validated id, a detector that demonstrably exists, a readable directory,
11
+ // and every candidate record parsed. Anything less is occupied.
12
+ //
13
+ // The first version of this file got that backwards and QA caught six inputs
14
+ // where a live desktop owner was present and it still returned attachable:
15
+ // a truncated id, a path-traversal id, corrupt JSON, an unreadable record, a
16
+ // malformed pid, and an empty directory on a build with no registry at all.
17
+ // Every one was "no match found" quietly becoming "free". That is the same
18
+ // absence-inference failure the project's own rules call out by name, so the
19
+ // verdict logic is now inverted: `claudeOwners` reports what it could NOT
20
+ // establish, and `threadOccupancy` refuses on any doubt.
21
+ //
22
+ // SIX THINGS THAT LOOK TRUE AND ARE NOT, all probed on this machine 2026-08-15:
23
+ //
24
+ // 1. A lock FILE is not a held lock. `~/.codex/thread-writer-locks/` keeps
25
+ // `.coordination.lock` permanently, held by nobody, and a thread lock file
26
+ // survives its `codex exec` by minutes. Only an open descriptor counts.
27
+ // 2. A socket file is not liveness — the same trap one layer over, already
28
+ // documented in claude-session-registry.ts after an orphaned `.sock` was
29
+ // found with a dead PID.
30
+ // 3. `entrypoint` and `kind` cannot identify our own spawns. A CLI `claude -p`
31
+ // reports `entrypoint=claude-desktop, kind=interactive`, byte-identical to a
32
+ // real desktop window. Self-exclusion MUST come from a spawn ledger.
33
+ // 4. A bare PID is not an identity. The ledger is keyed pid -> process start,
34
+ // because a recycled PID would otherwise forge self-ownership — and
35
+ // self-ownership is the ONLY path that turns a live owner into attachable.
36
+ // 5. `lsof` on a Claude transcript returns nothing. Claude appends and closes.
37
+ // Codex is the opposite: its writer lock is held for the life of the turn.
38
+ // 6. An empty directory is not an empty registry. Claude Code only writes
39
+ // `sessions/<pid>.json` from 2.1.224; on an older build the directory is
40
+ // absent while fully attachable-looking threads exist in `projects/`.
41
+
42
+ import { isValidNativeThreadId } from './native-thread-id.js'
43
+
44
+ /** Providers that have a certified occupancy detector. Anything else is occupied. */
45
+ export type OccupancyProvider = 'claude' | 'codex'
46
+
47
+ export interface ThreadOwner {
48
+ provider: OccupancyProvider
49
+ /** Full thread id. Never truncated. */
50
+ threadId: string
51
+ pid: number
52
+ source: 'claude-registry' | 'codex-writer-lock'
53
+ /** True when the spawn ledger proves this PID is ours (plan 4.4). */
54
+ selfOwned: boolean
55
+ }
56
+
57
+ export type OccupancyReason =
58
+ | 'live_desktop_process'
59
+ | 'unsupported_provider'
60
+ | 'invalid_thread_id'
61
+ | 'detector_unavailable'
62
+ | 'registry_unreadable'
63
+ | 'unverifiable_process_start'
64
+ | 'unverifiable_liveness_socket'
65
+ | 'probe_failed'
66
+ // Not an occupancy finding: the write feature is switched off (plan 4.9). Set
67
+ // before any probe runs, so a disabled install does no filesystem work and can
68
+ // never report a thread free that it has no way to write to.
69
+ | 'attach_disabled'
70
+
71
+ export interface Occupancy {
72
+ attachable: boolean
73
+ owners: ThreadOwner[]
74
+ /** Null only when attachable. Drives the Control/lens footer copy. */
75
+ reason: OccupancyReason | null
76
+ }
77
+
78
+ /**
79
+ * What a scan could not establish. Any non-null value forbids attaching, even
80
+ * with zero owners — that is the whole point of the type.
81
+ */
82
+ type Doubt = Exclude<OccupancyReason, 'live_desktop_process' | 'unsupported_provider' | 'invalid_thread_id'> | null
83
+
84
+ interface ScanResult {
85
+ owners: ThreadOwner[]
86
+ doubt: Doubt
87
+ }
88
+
89
+ export interface OccupancyProbes {
90
+ /** signal-0 liveness. See the EPERM note on `isAliveMeansPresent` below. */
91
+ isAlive: (pid: number) => boolean
92
+ /** Actual process start, epoch ms. Null when it cannot be determined. */
93
+ processStartMs: (pid: number) => number | null
94
+ fileExists: (path: string) => boolean
95
+ /**
96
+ * Does this directory exist? Required before any "nobody is here" verdict:
97
+ * it is the only evidence the detection mechanism applies to this install.
98
+ */
99
+ dirExists: (path: string) => boolean
100
+ /** Entry names only. MUST throw on an unreadable directory, never return []. */
101
+ readDir: (path: string) => string[]
102
+ /** File contents. Null means UNREADABLE and is treated as doubt, not absence. */
103
+ readFile: (path: string) => string | null
104
+ /** PIDs holding an open descriptor, e.g. `lsof -t`. No holders is [], not a throw. */
105
+ lockHolders: (path: string) => number[]
106
+ /**
107
+ * PIDs COS spawned, mapped to their process start in epoch ms.
108
+ *
109
+ * A Set of bare pids is NOT sufficient. This is the only input that can turn
110
+ * a live owner into attachable, so a recycled PID must not be able to forge
111
+ * membership — the start time is what makes the claim checkable.
112
+ */
113
+ cosSpawnedPids: () => ReadonlyMap<number, number>
114
+ }
115
+
116
+ /** A Claude registry filename is exactly `<pid>.json`. Not `*.json`. */
117
+ export const CLAUDE_REGISTRY_FILENAME = /^\d+\.json$/
118
+
119
+ /** Upper bound on registry entries scanned, mirroring routes/claude-sessions.ts. */
120
+ export const MAX_REGISTRY_FILES = 500
121
+
122
+ /**
123
+ * Tolerance when comparing a recorded process start against the live one.
124
+ * `procStart` has one-second resolution, so an exact epoch comparison would
125
+ * reject a valid match on rounding alone.
126
+ */
127
+ export const PROC_START_TOLERANCE_MS = 1500
128
+
129
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
130
+
131
+ /**
132
+ * Parse Claude's `procStart` to epoch ms.
133
+ *
134
+ * The field is `ps lstart` formatting expressed in UTC, while `ps` itself prints
135
+ * local time. A live record read `Sun Aug 16 02:03:05 2026` for a process `ps`
136
+ * showed starting `Sat Aug 15 21:03:05 2026` — the same instant at UTC-5. An
137
+ * earlier version compared the strings directly and reported a false PID-reuse
138
+ * hit, which is the bug this function exists to prevent.
139
+ *
140
+ * The mirror-image hazard lives in the `processStartMs` probe: it must NOT parse
141
+ * localized `ps -o lstart` output, or the same bug reappears one layer down.
142
+ */
143
+ export function parseProcStartUtcMs(procStart: unknown): number | null {
144
+ if (typeof procStart !== 'string') return null
145
+ const m = /^[A-Za-z]{3}\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/.exec(
146
+ procStart.trim().replace(/\s+/g, ' '),
147
+ )
148
+ if (!m) return null
149
+ const month = MONTHS.indexOf(m[1]!)
150
+ if (month < 0) return null
151
+ const ms = Date.UTC(Number(m[6]), month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]))
152
+ return Number.isFinite(ms) ? ms : null
153
+ }
154
+
155
+ /** Do two process-start readings describe the same process? Unverifiable is false. */
156
+ export function sameProcessStart(recordedMs: number | null, actualMs: number | null): boolean {
157
+ if (recordedMs === null || actualMs === null) return false
158
+ return Math.abs(recordedMs - actualMs) <= PROC_START_TOLERANCE_MS
159
+ }
160
+
161
+ export function processStartMatches(recordedProcStart: unknown, actualStartMs: number | null): boolean {
162
+ return sameProcessStart(parseProcStartUtcMs(recordedProcStart), actualStartMs)
163
+ }
164
+
165
+ /**
166
+ * Is this PID provably one of ours?
167
+ *
168
+ * Membership alone is not enough: the ledger outlives the process, and a
169
+ * recycled PID would inherit the claim. The recorded start must match the live
170
+ * one, using the same tolerance as the registry check.
171
+ */
172
+ export function isSelfOwned(
173
+ pid: number,
174
+ actualStartMs: number | null,
175
+ ledger: ReadonlyMap<number, number>,
176
+ ): boolean {
177
+ // A duck-typed `{ get: () => Date.now() }` satisfies the TYPE and was verified to
178
+ // grant ownership over a fully live foreign owner. Production is saved one layer
179
+ // up by occupancy-probes.sanitizeLedger, but `probes` is an INJECTED dependency,
180
+ // so any other wiring loses that guard while this function's own doc promises a
181
+ // recycled pid cannot forge membership. Self-ownership is the only path that
182
+ // turns a live owner into attachable, so it validates its own input.
183
+ if (!(ledger instanceof Map)) return false
184
+ const spawnedAt = ledger.get(pid)
185
+ if (typeof spawnedAt !== 'number') return false
186
+ return sameProcessStart(spawnedAt, actualStartMs)
187
+ }
188
+
189
+ function parseJson(raw: string): Record<string, unknown> | null {
190
+ try {
191
+ const parsed: unknown = JSON.parse(raw)
192
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
193
+ ? (parsed as Record<string, unknown>)
194
+ : null
195
+ } catch {
196
+ return null
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Scan the Claude registry for live owners of one thread.
202
+ *
203
+ * Every skip AFTER the id matches records doubt. A record naming this exact
204
+ * thread is positive evidence that some process claims it, so discarding one
205
+ * silently — for corrupt JSON, an unreadable file, or a malformed pid — is how
206
+ * the first version produced a false "free".
207
+ */
208
+ export function claudeOwners(
209
+ threadId: string,
210
+ probes: OccupancyProbes,
211
+ sessionsDir: string,
212
+ ): ScanResult {
213
+ if (!probes.dirExists(sessionsDir)) return { owners: [], doubt: 'detector_unavailable' }
214
+
215
+ const entries = probes.readDir(sessionsDir).filter(e => CLAUDE_REGISTRY_FILENAME.test(e))
216
+ if (entries.length > MAX_REGISTRY_FILES) return { owners: [], doubt: 'registry_unreadable' }
217
+
218
+ const owners: ThreadOwner[] = []
219
+ let doubt: Doubt = null
220
+ const ledger = probes.cosSpawnedPids()
221
+
222
+ for (const entry of entries) {
223
+ const raw = probes.readFile(`${sessionsDir}/${entry}`)
224
+ // Null is the documented "unreadable" signal. It cannot be distinguished
225
+ // from a record for THIS thread, so it is doubt, not absence. (A file that
226
+ // vanished between readDir and readFile is benign but indistinguishable
227
+ // here; erring toward Fork costs one fork, the other way costs a
228
+ // conversation.)
229
+ if (raw === null) { doubt ??= 'registry_unreadable'; continue }
230
+
231
+ const record = parseJson(raw)
232
+ if (!record) { doubt ??= 'registry_unreadable'; continue }
233
+ if (record.sessionId !== threadId) continue
234
+
235
+ // From here the record claims THIS thread. Nothing may be dropped quietly.
236
+ if (typeof record.pid !== 'number' || !Number.isInteger(record.pid) || record.pid <= 0) {
237
+ doubt ??= 'registry_unreadable'
238
+ continue
239
+ }
240
+ const pid = record.pid
241
+
242
+ if (!probes.isAlive(pid)) continue // genuinely dead: reaping missed it, no owner
243
+
244
+ const startMs = probes.processStartMs(pid)
245
+ if (!processStartMatches(record.procStart, startMs)) {
246
+ doubt ??= 'unverifiable_process_start'
247
+ continue
248
+ }
249
+
250
+ const socketPath = typeof record.messagingSocketPath === 'string' ? record.messagingSocketPath : null
251
+ if (socketPath === null || !probes.fileExists(socketPath)) {
252
+ doubt ??= 'unverifiable_liveness_socket'
253
+ continue
254
+ }
255
+
256
+ owners.push({
257
+ provider: 'claude',
258
+ threadId,
259
+ pid,
260
+ source: 'claude-registry',
261
+ selfOwned: isSelfOwned(pid, startMs, ledger),
262
+ })
263
+ }
264
+
265
+ return { owners, doubt }
266
+ }
267
+
268
+ /** Path of the Codex per-thread writer lock. */
269
+ export function codexLockPath(threadId: string, locksDir: string): string {
270
+ return `${locksDir}/${threadId}.lock`
271
+ }
272
+
273
+ /**
274
+ * Scan the Codex writer lock for live owners of one thread.
275
+ *
276
+ * Stronger than the Claude path and simpler for it: this is a real lock held
277
+ * open for the life of the turn by both the desktop app and a CLI `codex exec`,
278
+ * so an open descriptor is definitionally live and no process-start guard is
279
+ * needed on the record side. The ledger check still needs one.
280
+ *
281
+ * The lock FILE outlives the run, so its existence proves nothing — but the
282
+ * DIRECTORY's existence is what proves the detector applies to this install.
283
+ */
284
+ export function codexOwners(
285
+ threadId: string,
286
+ probes: OccupancyProbes,
287
+ locksDir: string,
288
+ ): ScanResult {
289
+ if (!probes.dirExists(locksDir)) return { owners: [], doubt: 'detector_unavailable' }
290
+
291
+ const path = codexLockPath(threadId, locksDir)
292
+ // No fileExists pre-check: it adds a TOCTOU window and buys nothing, since
293
+ // lockHolders on an absent file is simply empty.
294
+ const holders = probes.lockHolders(path)
295
+ const ledger = probes.cosSpawnedPids()
296
+
297
+ const owners: ThreadOwner[] = []
298
+ let doubt: Doubt = null
299
+
300
+ for (const pid of holders) {
301
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
302
+ doubt ??= 'registry_unreadable'
303
+ continue
304
+ }
305
+ owners.push({
306
+ provider: 'codex',
307
+ threadId,
308
+ pid,
309
+ source: 'codex-writer-lock',
310
+ selfOwned: isSelfOwned(pid, probes.processStartMs(pid), ledger),
311
+ })
312
+ }
313
+
314
+ return { owners, doubt }
315
+ }
316
+
317
+ export interface OccupancyDirs {
318
+ /** `<CLAUDE_CONFIG_DIR|~/.claude>/sessions` */
319
+ claudeSessionsDir: string
320
+ /** `<CODEX_HOME|~/.codex>/thread-writer-locks` */
321
+ codexLocksDir: string
322
+ }
323
+
324
+ /**
325
+ * The Phase 0 attach precondition.
326
+ *
327
+ * Returns attachable ONLY when a supported provider proved its detector exists,
328
+ * read every candidate record, and found no foreign owner. Every other outcome
329
+ * names why. The whole function is wrapped: a throwing probe — including the
330
+ * spawn ledger, which is the most safety-critical of them — is `probe_failed`,
331
+ * never an exception escaping into a route.
332
+ */
333
+ export function threadOccupancy(
334
+ provider: string,
335
+ threadId: string,
336
+ probes: OccupancyProbes,
337
+ dirs: OccupancyDirs,
338
+ ): Occupancy {
339
+ if (provider !== 'claude' && provider !== 'codex') {
340
+ // Cursor and anything unrecognised: an honest capability gap, not a failure.
341
+ return { attachable: false, owners: [], reason: 'unsupported_provider' }
342
+ }
343
+ // Validated BEFORE any scan. A truncated or malformed id matches no record,
344
+ // and "matched nothing" must never reach the empty-means-free path. It also
345
+ // reaches a filesystem path in codexLockPath.
346
+ if (!isValidNativeThreadId(threadId)) {
347
+ return { attachable: false, owners: [], reason: 'invalid_thread_id' }
348
+ }
349
+
350
+ let result: ScanResult
351
+ try {
352
+ result = provider === 'claude'
353
+ ? claudeOwners(threadId, probes, dirs.claudeSessionsDir)
354
+ : codexOwners(threadId, probes, dirs.codexLocksDir)
355
+ } catch {
356
+ return { attachable: false, owners: [], reason: 'probe_failed' }
357
+ }
358
+
359
+ const foreign = result.owners.filter(o => !o.selfOwned)
360
+ if (foreign.length > 0) {
361
+ return { attachable: false, owners: result.owners, reason: 'live_desktop_process' }
362
+ }
363
+ if (result.doubt !== null) {
364
+ return { attachable: false, owners: result.owners, reason: result.doubt }
365
+ }
366
+ return { attachable: true, owners: result.owners, reason: null }
367
+ }