@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,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
|
+
}
|