@gotcos/glasses-server 6.30.0 → 6.31.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,53 @@ unsaved capture, and makes batch status stop lying about finished work.
2219
2219
 
2220
2220
  # Changelog
2221
2221
 
2222
+ ## [6.31.0] - 2026-08-16
2223
+
2224
+ ### "Running" meant an open window, not a working agent
2225
+
2226
+ - `GET /api/agent-sessions` adds **`running_active`**: the thread's transcript was
2227
+ written in the last 30 seconds, so an agent is generating in it right now.
2228
+ - This exists because `running` could not answer that question and was being read
2229
+ as though it could. It comes from a process registry, and a Claude Code record
2230
+ describes an open WINDOW (`kind: interactive`, `entrypoint: claude-desktop`) —
2231
+ so a session that finished an hour ago, with the window still up, stayed
2232
+ `running: true` forever. A finished session reported itself active on the
2233
+ glasses and never cleared.
2234
+ - The transcript is the only part of a session with a clock in it. While a
2235
+ session generates, its jsonl mtime tracks the wall clock to within a second;
2236
+ when it stops, the mtime goes stale while the registry record does not. That
2237
+ divergence is the whole signal.
2238
+ - The window is 30 seconds, deliberately much wider than the observed write
2239
+ cadence, so a long tool call between writes does not flap a working session to
2240
+ idle and back.
2241
+ - **Costs one stat per HELD thread, not per listed thread.** Freshness is layered
2242
+ on after the occupancy scan and only over what it found, so a list with nothing
2243
+ running does no extra filesystem work at all.
2244
+ - Anything unmeasurable — no transcript, unreadable file, a timestamp from the
2245
+ future — reads as NOT active. The row then falls back to "open", which is still
2246
+ true of a thread with a live owner. The stronger claim has to be earned by an
2247
+ actual observation.
2248
+ - **Still a display hint and still never a write gate.** `attachability` and
2249
+ `attach` keep probing at the moment of the write, unchanged.
2250
+
2251
+ ### The session detail describes its own liveness
2252
+
2253
+ - `GET /api/agent-sessions/:provider/:sessionId` now carries the same three
2254
+ `running_*` fields plus `running_stamped: true` and `runningDegraded`.
2255
+ - Until now only the LIST stamped occupancy, so the detail page had to borrow the
2256
+ flags from whichever row the user tapped. Those flags froze at the moment of the
2257
+ tap, which is the other half of why a finished session never cleared: the page
2258
+ had no way to ask again. A polling client needs a payload that carries its own
2259
+ liveness.
2260
+ - `running_stamped` exists so a client can tell an old server's silence from a new
2261
+ server's `false`. They demand opposite behaviour: keep the borrowed hint, or drop
2262
+ it as stale.
2263
+ - Nearly free: the handler already resolves and stats the transcript, so only the
2264
+ single-thread occupancy scan is new, and that is the same scan `attachability`
2265
+ runs on every menu open.
2266
+
2267
+ **Required by the COS Glasses build that ships the honest activity line.**
2268
+
2222
2269
  ## [6.30.0] - 2026-08-16
2223
2270
 
2224
2271
  ### Sessions know which threads are live
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.30.0",
3
+ "version": "6.31.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": {
@@ -41,10 +41,15 @@ import { isValidNativeThreadId } from './native-thread-id.js'
41
41
  export interface OccupiedThread {
42
42
  threadId: string
43
43
  /**
44
- * Every process working in this thread, COS's own children included.
44
+ * Every process holding this thread open, COS's own children included.
45
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.
46
+ * WHAT THIS ACTUALLY MEASURES, stated plainly because the first version of the
47
+ * feature got it wrong in user-facing copy: a registry record means A PROCESS
48
+ * HOLDS THIS THREAD OPEN. It does NOT mean an agent is generating. A Claude
49
+ * Code record reads `kind: interactive, entrypoint: claude-desktop` for an open
50
+ * WINDOW, so a session that finished ten minutes ago with the window still up
51
+ * has an owner forever. The lens said "ACTIVE NOW" off this number and was
52
+ * lying; it now says the thread is OPEN, which is all this count can support.
48
53
  */
49
54
  owners: number
50
55
  /**
@@ -55,6 +60,16 @@ export interface OccupiedThread {
55
60
  * window does.
56
61
  */
57
62
  foreignOwners: number
63
+ /**
64
+ * The transcript was WRITTEN inside the freshness window: an agent is
65
+ * generating here, not merely holding the file open.
66
+ *
67
+ * This is the signal `owners` cannot give. Set only from a positive
68
+ * observation of a recent write — see `withActiveRecently`, which is where it
69
+ * is filled in. `occupiedThreads` itself has no path to a transcript and
70
+ * always leaves it false.
71
+ */
72
+ activeRecently: boolean
58
73
  }
59
74
 
60
75
  export interface OccupiedScan {
@@ -178,7 +193,11 @@ export function occupiedThreads(
178
193
  // hide COS's own queued turn from the very screen the user opens to watch it.
179
194
  const foreign = owners.filter(o => !o.selfOwned).length
180
195
  if (owners.length > 0) {
181
- occupied.set(threadId, { threadId, owners: owners.length, foreignOwners: foreign })
196
+ // `activeRecently` is FALSE here and can only be raised by
197
+ // `withActiveRecently`. This function reads a process registry; a registry
198
+ // record cannot tell generating from idling, and inferring one from the
199
+ // other is the exact defect this field exists to fix.
200
+ occupied.set(threadId, { threadId, owners: owners.length, foreignOwners: foreign, activeRecently: false })
182
201
  }
183
202
  }
184
203
 
@@ -189,3 +208,78 @@ export function occupiedThreads(
189
208
  export function noOccupancyKnown(): OccupiedScan {
190
209
  return { occupied: new Map(EMPTY.occupied), degraded: true }
191
210
  }
211
+
212
+ /**
213
+ * How recently the transcript must have been written for a held thread to read
214
+ * as WORKING rather than merely OPEN.
215
+ *
216
+ * WHY A SECOND SIGNAL EXISTS AT ALL. `owners` answers "a process holds this
217
+ * thread", which is not the question the user is asking when he looks at the
218
+ * lens. He watched a session finish on his Mac while the glasses still showed it
219
+ * active, because the window was still open and the registry record therefore
220
+ * still existed. Occupancy has no clock in it; the transcript does.
221
+ *
222
+ * MEASURED, not assumed: while a session generates, its jsonl mtime tracks the
223
+ * wall clock to within a second, and when generation stops the mtime goes stale
224
+ * while the registry record stays exactly where it was. That divergence is the
225
+ * whole signal.
226
+ *
227
+ * 30 seconds is deliberately far wider than the observed sub-second write
228
+ * cadence. The gap this has to survive is a long tool call or a slow first token
229
+ * between writes, and a window sized to the cadence would flap a working session
230
+ * to OPEN and back every time the model paused to think. The cost of the wide
231
+ * window is bounded and known: a session that stops is reported working for up
232
+ * to 30 more seconds, which is a late correction rather than a permanent lie.
233
+ */
234
+ export const ACTIVE_RECENTLY_WINDOW_MS = 30_000
235
+
236
+ /**
237
+ * Was this transcript written inside the window?
238
+ *
239
+ * NULL IS NOT "RECENT". An unresolvable path, an unreadable file, a stat that
240
+ * threw — every one of them arrives here as null and answers false, so the row
241
+ * falls back to OPEN. That is not a fail-open: OPEN is still a true statement
242
+ * about a thread with a live owner. It is the STRONGER claim, "an agent is
243
+ * working in here", that has to be earned by an actual observation.
244
+ *
245
+ * A timestamp far in the FUTURE is refused for the same reason rather than
246
+ * treated as maximally fresh. Skew of a few seconds is normal and lands inside
247
+ * the window; a file dated next week is a clock this server cannot reason about,
248
+ * and letting it manufacture a permanent "working" badge would rebuild the bug
249
+ * from the other direction.
250
+ */
251
+ export function isActiveRecently(mtimeMs: number | null | undefined, nowMs: number): boolean {
252
+ if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return false
253
+ if (!Number.isFinite(nowMs)) return false
254
+ return Math.abs(nowMs - mtimeMs) <= ACTIVE_RECENTLY_WINDOW_MS
255
+ }
256
+
257
+ /**
258
+ * Fill in `activeRecently` for a scan, from transcript write times.
259
+ *
260
+ * PURE, and separate from `occupiedThreads` on purpose. Resolving a thread id to
261
+ * a transcript path is the session STORE's job (it owns the projects/rollouts
262
+ * layout), and this module owns process occupancy; wiring the store in here
263
+ * would make an occupancy scan depend on a filesystem it does not otherwise know
264
+ * about. The caller stats — for HELD THREADS ONLY, which is what keeps this
265
+ * cheap, since it is one stat per already-occupied row and never one per listed
266
+ * row — and hands the readings in.
267
+ *
268
+ * A thread with no entry in the map is not an error and not "recent": it reads
269
+ * exactly like an unreadable one, because from here the two are the same
270
+ * absence of evidence.
271
+ */
272
+ export function withActiveRecently(
273
+ scan: OccupiedScan,
274
+ transcriptMtimes: ReadonlyMap<string, number | null>,
275
+ nowMs: number,
276
+ ): OccupiedScan {
277
+ const occupied = new Map<string, OccupiedThread>()
278
+ for (const [threadId, record] of scan.occupied) {
279
+ occupied.set(threadId, {
280
+ ...record,
281
+ activeRecently: isActiveRecently(transcriptMtimes.get(threadId), nowMs),
282
+ })
283
+ }
284
+ return { occupied, degraded: scan.degraded }
285
+ }
@@ -32,7 +32,13 @@ 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'
35
+ import {
36
+ occupiedThreads,
37
+ noOccupancyKnown,
38
+ withActiveRecently,
39
+ type OccupiedScan,
40
+ type OccupiedThread,
41
+ } from '../lib/occupied-threads.js'
36
42
  import { realOccupancyDirs, realOccupancyProbes } from '../lib/occupancy-probes.js'
37
43
  import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
38
44
 
@@ -105,16 +111,115 @@ function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
105
111
  }
106
112
  }
107
113
 
108
- /** Stamp the running hint onto a projected row. */
109
- function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
114
+ /**
115
+ * When each HELD thread's transcript was last written.
116
+ *
117
+ * ONLY the threads the scan already found occupied. That is the entire cost
118
+ * argument: the occupied set is typically one or two rows out of sixty, so this
119
+ * is one or two path resolutions and stats per request, not sixty. An empty scan
120
+ * does no filesystem work at all.
121
+ *
122
+ * WHY NOT REUSE `row.modified`, WHICH IS ALREADY AN MTIME. Because for the rows
123
+ * that matter it is not one. A live Claude row comes from `liveClaudeRows`,
124
+ * whose `modified` is the registry's `lastActiveAt` (or, absent that, literally
125
+ * `new Date()`), and `enrichLiveClaude` does not replace it with the file's
126
+ * mtime. Reading freshness off that field would report an open window as a fresh
127
+ * write on every single request, which is precisely the bug being fixed.
128
+ *
129
+ * Every failure lands on null, and null is doubt rather than "recent" — see
130
+ * `isActiveRecently`. A row that cannot be measured reads OPEN, which is still
131
+ * true of a thread with a live owner.
132
+ */
133
+ async function transcriptMtimes(
134
+ scan: OccupiedScan,
135
+ rows: readonly AgentSessionRow[],
136
+ ): Promise<Map<string, number | null>> {
137
+ const mtimes = new Map<string, number | null>()
138
+ if (scan.occupied.size === 0) return mtimes
139
+
140
+ const providerById = new Map<string, AgentProvider>()
141
+ for (const row of rows) providerById.set(row.session_id, row.provider)
142
+ const roots = agentSessionRoots()
143
+
144
+ await Promise.all([...scan.occupied.keys()].map(async threadId => {
145
+ try {
146
+ const provider = providerById.get(threadId)
147
+ // An occupied id with no row cannot happen today (the scan is built FROM
148
+ // the rows), but guessing a provider to go looking for a file is how a
149
+ // caller-supplied id reaches the filesystem. Unknown stays unmeasured.
150
+ if (!provider) {
151
+ mtimes.set(threadId, null)
152
+ return
153
+ }
154
+ const file = await findAgentSessionFile(provider, threadId, roots)
155
+ mtimes.set(threadId, file ? (await stat(file)).mtimeMs : null)
156
+ } catch {
157
+ mtimes.set(threadId, null)
158
+ }
159
+ }))
160
+ return mtimes
161
+ }
162
+
163
+ /**
164
+ * Stamp the running hint onto a projected row.
165
+ *
166
+ * EXPORTED so the WIRE KEYS are covered by a behaviour test rather than by
167
+ * reading this file. The client reads `running_active` by exact name
168
+ * (`session-running.ts` accepts only a literal `true` on a known key), so a
169
+ * rename here is silent on this side and renders every session as merely open on
170
+ * the lens. Pure: it takes the scan, it does no I/O.
171
+ */
172
+ export function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
110
173
  const occ = scan.occupied.get(entry.session_id)
111
174
  return {
112
175
  ...entry,
113
- // An agent is working in this thread right now, whoever started it.
176
+ // A process HOLDS this thread open, whoever started it. Not "is generating":
177
+ // a Claude record for an open window outlives the work by however long the
178
+ // window stays up, which is why `running_active` exists below.
114
179
  running: occ !== undefined,
115
180
  // Held by something that is not COS, so a Continue would be refused. The
116
181
  // badge reads `running`; the Continue affordance reads this.
117
182
  running_foreign: (occ?.foreignOwners ?? 0) > 0,
183
+ // The transcript was WRITTEN in the last few seconds: an agent is generating
184
+ // here, not just holding the file. This is the only one of the three that
185
+ // clears on its own when the work stops. Still a display hint, never a gate.
186
+ running_active: occ?.activeRecently === true,
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Occupancy plus freshness for ONE thread, for the detail route.
192
+ *
193
+ * WHY THE DETAIL ROUTE STAMPS THIS AT ALL. It did not, and the lens had to borrow
194
+ * the hint from whichever list row the user tapped. That worked exactly once: the
195
+ * detail page was a single fetch, so the borrowed flags were frozen at the moment
196
+ * it opened and could never clear, which is half of why a finished session stayed
197
+ * "active" on the glasses until Miles navigated away. A page that polls needs a
198
+ * payload that carries its own liveness.
199
+ *
200
+ * NEARLY FREE HERE. The handler has already resolved the transcript path and
201
+ * stat'ed it, so freshness costs nothing extra and only the single-thread
202
+ * occupancy scan is new — the same scan `attachability` already runs on every
203
+ * menu open. Contrast the list, where the same work would be sixty scans.
204
+ *
205
+ * STILL A DISPLAY HINT. Nothing about being on the detail payload makes it a
206
+ * gate; `attach` re-probes at the write, unchanged.
207
+ */
208
+ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: number): OccupiedScan {
209
+ if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
210
+ try {
211
+ const scan = occupiedThreads(
212
+ provider,
213
+ [threadId],
214
+ realOccupancyProbes(cosSpawnedPids),
215
+ realOccupancyDirs(),
216
+ )
217
+ return withActiveRecently(scan, new Map([[threadId, mtimeMs]]), Date.now())
218
+ } catch (error) {
219
+ // The transcript is the point; occupancy is decoration. A probe failure must
220
+ // never cost the user their session.
221
+ console.error(`[agent-sessions] detail occupancy failed: ${error instanceof Error ? error.message : error}`)
222
+ return noOccupancyKnown()
118
223
  }
119
224
  }
120
225
 
@@ -164,7 +269,11 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
164
269
  const sort = asSort(req.query.sort)
165
270
  const live = await liveClaudeRows()
166
271
  const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
167
- const running = runningThreads(sessions)
272
+ const scan = runningThreads(sessions)
273
+ // Freshness is layered on AFTER occupancy, and only over what occupancy
274
+ // found. `Date.now()` is read once so every row in a payload is judged
275
+ // against the same instant.
276
+ const running = withActiveRecently(scan, await transcriptMtimes(scan, sessions), Date.now())
168
277
  res.json({
169
278
  sessions: sessions.map(row => withRunning(toEntry(row), running)),
170
279
  total: sessions.length,
@@ -234,8 +343,19 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
234
343
  if (named) parsed.display_label = named
235
344
  }
236
345
  const modified = st.mtime.toISOString()
346
+ const running = runningForThread(provider, parsed.session_id, st.mtimeMs)
237
347
  res.json({
238
- session_id: parsed.session_id,
348
+ ...withRunning({ session_id: parsed.session_id }, running),
349
+ // The client must be able to tell "this server stamped nothing" from "this
350
+ // server stamped false", because the two demand opposite behaviour: an old
351
+ // server's silence means keep using the hint borrowed from the list row, and
352
+ // a new server's `false` means the thread genuinely went quiet and the
353
+ // borrowed hint is the stale thing. Without this marker the poll could never
354
+ // clear the flag it exists to clear.
355
+ running_stamped: true,
356
+ // Same meaning as on the list: a probe could not see clearly, so render
357
+ // unknown rather than treating a quiet scan as "nothing is running".
358
+ runningDegraded: running.degraded,
239
359
  provider: parsed.provider,
240
360
  slug: parsed.session_id,
241
361
  custom_title: parsed.display_label,