@gotcos/glasses-server 6.29.0 → 6.30.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 CHANGED
@@ -2219,6 +2219,28 @@ unsaved capture, and makes batch status stop lying about finished work.
2219
2219
 
2220
2220
  # Changelog
2221
2221
 
2222
+ ## [6.30.0] - 2026-08-16
2223
+
2224
+ ### Sessions know which threads are live
2225
+
2226
+ - `GET /api/agent-sessions` now stamps each row with `running` (an agent is
2227
+ working in this thread right now) and `running_foreign` (it is held by
2228
+ something that is not COS, so a Continue would be refused), plus a
2229
+ `runningDegraded` flag on the payload when a probe could not see clearly.
2230
+ - `running` counts COS's own queued turn too. Two different questions: whether an
2231
+ agent is working (the badge) and whether a write would be refused (the
2232
+ affordance). Counting only foreign owners would hide your own turn from the
2233
+ screen you open to watch it.
2234
+ - **This is a display hint and never a write gate.** Attach and turn keep probing
2235
+ at the moment of the write, unchanged, so a desktop session opened between the
2236
+ list render and the tap is still caught.
2237
+ - Fails the opposite way to the gate: doubt reports `degraded` rather than
2238
+ painting every session busy.
2239
+ - One scan for the whole page, measured at 7ms for 45 real sessions (was 792ms
2240
+ before a scan-scoped memo and skipping lsof when no Codex writer lock exists).
2241
+
2242
+ **Required by COS Glasses 6.8.364.**
2243
+
2222
2244
  ## [6.29.0] - 2026-08-16
2223
2245
 
2224
2246
  ### Continue is queued instead of holding the phone
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.29.0",
3
+ "version": "6.30.0",
4
4
  "description": "COS Glasses \u2014 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": {
@@ -0,0 +1,191 @@
1
+ // Which threads currently have a live desktop process, in ONE pass.
2
+ //
3
+ // WHY THIS EXISTS SEPARATELY FROM `threadOccupancy`.
4
+ //
5
+ // `claudeOwners(threadId, ...)` re-reads the whole session registry and shells
6
+ // out to `ps` per entry, for ONE thread. That is correct and cheap when a user is
7
+ // deciding whether to write into a single thread. Calling it once per row to
8
+ // decorate a 53-row session list would mean tens of directory scans and hundreds
9
+ // of process spawns on every list request.
10
+ //
11
+ // So this does the opposite shape: scan once, return the set of occupied ids, and
12
+ // let the caller join in memory.
13
+ //
14
+ // THE LOAD-BEARING RULE, AND THE REASON THIS IS SAFE:
15
+ //
16
+ // WHAT THIS PRODUCES IS A DISPLAY HINT. IT MUST NEVER GATE A WRITE.
17
+ //
18
+ // The attach and turn paths keep calling `threadOccupancy` at the moment of the
19
+ // write, unchanged. That is deliberate: a list is rendered seconds or minutes
20
+ // before the user acts, and a desktop session opened in that gap is exactly the
21
+ // race the per-write probe exists to catch. This hint answers "should the UI show
22
+ // this session as busy", never "is it safe to write".
23
+ //
24
+ // Being a hint is also why its failure mode is the opposite of the gate's. The
25
+ // gate fails CLOSED — doubt means refuse. A hint that failed closed would paint
26
+ // every session as running the moment a probe hiccuped, so doubt here means "I do
27
+ // not know", rendered as not-running, and the real gate still refuses at the
28
+ // write. Getting a hint wrong costs a misleading badge; getting the gate wrong
29
+ // costs someone's conversation.
30
+
31
+ import {
32
+ claudeOwners,
33
+ codexLockPath,
34
+ codexOwners,
35
+ type OccupancyDirs,
36
+ type OccupancyProbes,
37
+ type ThreadOwner,
38
+ } from './thread-occupancy.js'
39
+ import { isValidNativeThreadId } from './native-thread-id.js'
40
+
41
+ export interface OccupiedThread {
42
+ threadId: string
43
+ /**
44
+ * Every process working in this thread, COS's own children included.
45
+ *
46
+ * This is the DISPLAY question — "is an agent working in here right now" — and
47
+ * a turn COS queued is just as much a running agent as a desktop window is.
48
+ */
49
+ owners: number
50
+ /**
51
+ * Owners that are NOT COS's own children.
52
+ *
53
+ * The separate count matters because it answers a different question: whether a
54
+ * Continue would be refused. Self-owned work does not block a write; a desktop
55
+ * window does.
56
+ */
57
+ foreignOwners: number
58
+ }
59
+
60
+ export interface OccupiedScan {
61
+ /** threadId -> occupancy, for every thread with at least one owner. */
62
+ occupied: Map<string, OccupiedThread>
63
+ /**
64
+ * True when the scan could not see clearly (unreadable registry, missing
65
+ * detector). The caller should render "unknown", never "everything is running".
66
+ */
67
+ degraded: boolean
68
+ }
69
+
70
+ const EMPTY: OccupiedScan = { occupied: new Map(), degraded: true }
71
+
72
+ /**
73
+ * Probes that answer each identical question once per scan.
74
+ *
75
+ * MEASURED, not assumed. `claudeOwners` re-reads the whole registry directory and
76
+ * re-runs `ps` for every entry, for ONE thread — so scanning 45 real sessions on
77
+ * this machine cost 771ms of repeated identical I/O. With this wrapper the same
78
+ * scan is one directory read, one read per entry, and one `ps` per pid.
79
+ *
80
+ * Correctness comes from reusing `claudeOwners` / `codexOwners` UNCHANGED, so the
81
+ * self-ownership rule and the PID-reuse guard stay in exactly one place. This only
82
+ * removes duplicate work; it makes no decisions.
83
+ *
84
+ * Scoped to a single scan on purpose. A longer-lived cache would answer "is a
85
+ * desktop process holding this thread" from stale data, and liveness has a
86
+ * lifetime of about now.
87
+ */
88
+ function memoize(probes: OccupancyProbes): OccupancyProbes {
89
+ const dirs = new Map<string, string[]>()
90
+ const files = new Map<string, string | null>()
91
+ const starts = new Map<number, number | null>()
92
+ const alive = new Map<number, boolean>()
93
+ const exists = new Map<string, boolean>()
94
+ const dirsExist = new Map<string, boolean>()
95
+ let ledger: ReturnType<OccupancyProbes['cosSpawnedPids']> | undefined
96
+
97
+ const once = <K, V>(cache: Map<K, V>, key: K, compute: () => V): V => {
98
+ if (cache.has(key)) return cache.get(key)!
99
+ const value = compute()
100
+ cache.set(key, value)
101
+ return value
102
+ }
103
+
104
+ return {
105
+ ...probes,
106
+ readDir: (path: string) => once(dirs, path, () => probes.readDir(path)),
107
+ readFile: (path: string) => once(files, path, () => probes.readFile(path)),
108
+ processStartMs: (pid: number) => once(starts, pid, () => probes.processStartMs(pid)),
109
+ isAlive: (pid: number) => once(alive, pid, () => probes.isAlive(pid)),
110
+ fileExists: (path: string) => once(exists, path, () => probes.fileExists(path)),
111
+ dirExists: (path: string) => once(dirsExist, path, () => probes.dirExists(path)),
112
+ cosSpawnedPids: () => (ledger ??= probes.cosSpawnedPids()),
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Scan one provider's registry once and return the threads a desktop process
118
+ * holds.
119
+ *
120
+ * Reuses `claudeOwners` / `codexOwners` rather than reimplementing the parsing,
121
+ * so the self-ownership rule (COS's own spawned children must not read as foreign
122
+ * owners) and the PID-reuse guard stay in exactly one place. Reuse alone would
123
+ * repeat the whole registry read per thread — `memoize` is what removes that, so
124
+ * the saving is real I/O rather than a claim in a comment.
125
+ */
126
+ export function occupiedThreads(
127
+ provider: string,
128
+ threadIds: readonly string[],
129
+ probes: OccupancyProbes,
130
+ dirs: OccupancyDirs,
131
+ ): OccupiedScan {
132
+ if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
133
+
134
+ // Deduplicated and validated up front. An invalid id cannot match a record, and
135
+ // it also reaches a filesystem path in `codexLockPath`.
136
+ const ids = [...new Set(threadIds)].filter(isValidNativeThreadId)
137
+ if (ids.length === 0) return { occupied: new Map(), degraded: false }
138
+
139
+ // Every identical read answered once for the whole scan. See `memoize`.
140
+ const probe = memoize(probes)
141
+ const occupied = new Map<string, OccupiedThread>()
142
+ let degraded = false
143
+
144
+ for (const threadId of ids) {
145
+ // MEASURED: `codexOwners` reaches `lsof` even when no writer lock exists, and
146
+ // lsof costs ~45ms a call — 761ms across 17 codex threads on this machine,
147
+ // against zero locks on disk. A missing lock means no writer, which is the
148
+ // same conclusion lsof reaches the expensive way, so skip it.
149
+ //
150
+ // Only an explicit `false` skips. `fileExists` collapses absent with
151
+ // unreadable, and an unreadable lock must still go through the real check
152
+ // rather than being read as free.
153
+ if (provider === 'codex') {
154
+ let lockPresent = true
155
+ try {
156
+ lockPresent = probe.fileExists(codexLockPath(threadId, dirs.codexLocksDir))
157
+ } catch {
158
+ lockPresent = true
159
+ }
160
+ if (!lockPresent) continue
161
+ }
162
+
163
+ let owners: readonly ThreadOwner[]
164
+ let doubt: string | null
165
+ try {
166
+ const result = provider === 'claude'
167
+ ? claudeOwners(threadId, probe, dirs.claudeSessionsDir)
168
+ : codexOwners(threadId, probe, dirs.codexLocksDir)
169
+ owners = result.owners
170
+ doubt = result.doubt
171
+ } catch {
172
+ // One thread's probe throwing does not invalidate the others.
173
+ degraded = true
174
+ continue
175
+ }
176
+ if (doubt !== null) degraded = true
177
+ // ANY owner means an agent is working here. Counting only foreign ones would
178
+ // hide COS's own queued turn from the very screen the user opens to watch it.
179
+ const foreign = owners.filter(o => !o.selfOwned).length
180
+ if (owners.length > 0) {
181
+ occupied.set(threadId, { threadId, owners: owners.length, foreignOwners: foreign })
182
+ }
183
+ }
184
+
185
+ return { occupied, degraded }
186
+ }
187
+
188
+ /** The safe answer when the caller cannot scan at all. */
189
+ export function noOccupancyKnown(): OccupiedScan {
190
+ return { occupied: new Map(EMPTY.occupied), degraded: true }
191
+ }
@@ -32,6 +32,9 @@ import {
32
32
  import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
33
33
  import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
34
34
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
35
+ import { occupiedThreads, noOccupancyKnown, type OccupiedScan, type OccupiedThread } from '../lib/occupied-threads.js'
36
+ import { realOccupancyDirs, realOccupancyProbes } from '../lib/occupancy-probes.js'
37
+ import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
35
38
 
36
39
  export const agentSessionsRouter = Router()
37
40
 
@@ -61,6 +64,60 @@ function toSearchHit(row: AgentSessionSearchHit) {
61
64
  }
62
65
  }
63
66
 
67
+ /**
68
+ * Which of these sessions a desktop process is holding right now.
69
+ *
70
+ * ONE scan for the whole page. Calling `threadOccupancy` per row would re-read the
71
+ * registry and shell out to `ps` per entry, which at 53 sessions is hundreds of
72
+ * process spawns on a single list request.
73
+ *
74
+ * THIS IS A DISPLAY HINT AND MUST NEVER GATE A WRITE. The attach and turn routes
75
+ * keep probing at the moment of the write, unchanged, because a list is rendered
76
+ * seconds or minutes before the user acts and a desktop session opened in that gap
77
+ * is exactly the race the per-write probe exists to catch.
78
+ */
79
+ function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
80
+ try {
81
+ const dirs = realOccupancyDirs()
82
+ // The spawn ledger is what lets a turn COS itself queued read as ours rather
83
+ // than as a foreign desktop window holding the thread.
84
+ const probes = realOccupancyProbes(cosSpawnedPids)
85
+ const byProvider = new Map<string, string[]>()
86
+ for (const row of rows) {
87
+ if (row.provider !== 'claude' && row.provider !== 'codex') continue
88
+ const list = byProvider.get(row.provider) ?? []
89
+ list.push(row.session_id)
90
+ byProvider.set(row.provider, list)
91
+ }
92
+ const merged = new Map<string, OccupiedThread>()
93
+ let degraded = false
94
+ for (const [provider, ids] of byProvider) {
95
+ const scan = occupiedThreads(provider, ids, probes, dirs)
96
+ for (const [id, occ] of scan.occupied) merged.set(id, occ)
97
+ if (scan.degraded) degraded = true
98
+ }
99
+ return { occupied: merged, degraded }
100
+ } catch (error) {
101
+ // The list is the point; occupancy is decoration. A probe failure must never
102
+ // cost the user their sessions.
103
+ console.error(`[agent-sessions] occupancy scan failed: ${error instanceof Error ? error.message : error}`)
104
+ return noOccupancyKnown()
105
+ }
106
+ }
107
+
108
+ /** Stamp the running hint onto a projected row. */
109
+ function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
110
+ const occ = scan.occupied.get(entry.session_id)
111
+ return {
112
+ ...entry,
113
+ // An agent is working in this thread right now, whoever started it.
114
+ running: occ !== undefined,
115
+ // Held by something that is not COS, so a Continue would be refused. The
116
+ // badge reads `running`; the Continue affordance reads this.
117
+ running_foreign: (occ?.foreignOwners ?? 0) > 0,
118
+ }
119
+ }
120
+
64
121
  function toEntry(row: AgentSessionRow) {
65
122
  return {
66
123
  session_id: row.session_id,
@@ -107,12 +164,16 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
107
164
  const sort = asSort(req.query.sort)
108
165
  const live = await liveClaudeRows()
109
166
  const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
167
+ const running = runningThreads(sessions)
110
168
  res.json({
111
- sessions: sessions.map(toEntry),
169
+ sessions: sessions.map(row => withRunning(toEntry(row), running)),
112
170
  total: sessions.length,
113
171
  windowHours: AGENT_SESSION_WINDOW_HOURS,
114
172
  sort,
115
173
  enabled: true,
174
+ // True when a probe could not see clearly. The client must render "unknown"
175
+ // rather than treating a quiet scan as "nothing is running".
176
+ runningDegraded: running.degraded,
116
177
  })
117
178
  } catch (error) {
118
179
  console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)