@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 +22 -0
- package/package.json +1 -1
- package/server/lib/occupied-threads.ts +191 -0
- package/server/routes/agent-sessions.ts +62 -1
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.
|
|
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}`)
|