@gotcos/glasses-server 6.28.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.
@@ -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
+ }
@@ -0,0 +1,200 @@
1
+ // Does this server offer Continue at all, and for what?
2
+ //
3
+ // The Continue/Fork affordance is a UI decision that has to be made BEFORE any
4
+ // thread is named, so COS Control and the phone cannot discover it the way they
5
+ // discover everything else about a thread — by asking
6
+ // /api/agent-sessions/:provider/:threadId/attachability. When the feature is off
7
+ // the two write routes are not registered at all, so a client that reaches for
8
+ // them gets a bare 404 with no reason and no copy, which is indistinguishable
9
+ // from a typo, a proxy, or a dead server. This module is what a client reads
10
+ // instead, once, up front.
11
+ //
12
+ // ---------------------------------------------------------------- the rule
13
+ //
14
+ // ABSENT MEANS DISABLED. NEVER ENABLED.
15
+ //
16
+ // These fields did not exist before this build. Every client older than it will
17
+ // read `undefined` from all three, and `undefined` MUST resolve to off — the same
18
+ // fail-closed posture as the rest of Continue Original Agent Thread, for the same
19
+ // reason: a client that guesses "the field is missing, so probably fine" offers a
20
+ // Continue button that writes into a real human's conversation, and the cost of
21
+ // guessing the other way is one Fork.
22
+ //
23
+ // That rule cannot be enforced by the wire format, because absence is exactly what
24
+ // an old client sees and an old client cannot be changed. So it is enforced by
25
+ // `readThreadAttachCapability`, which is the ONLY sanctioned way to interpret this
26
+ // surface, and by the test that feeds it a genuine older-shaped /api/health body
27
+ // and requires all three answers to come back off.
28
+ //
29
+ // `supported` exists solely to make the two OFFs distinguishable. Without it,
30
+ // "this server has never heard of Continue" and "this server can Continue but the
31
+ // user has not switched it on" are the same empty answer, and a client cannot tell
32
+ // a user to flip a setting that may not exist. It is a compile-time constant: this
33
+ // build has the code. Whether the routes are reachable is `enabled`.
34
+ //
35
+ // ------------------------------------------------------------ anti-drift
36
+ //
37
+ // `enabled` reads `threadAttachEnabled()` — the SAME function
38
+ // routes/agent-session-bindings.ts calls to decide whether to register the two
39
+ // POST routes. Not a second copy of `process.env.COS_THREAD_ATTACH_ENABLED === '1'`.
40
+ // A copy is how this surface comes to advertise a write path that 404s, and the
41
+ // repo already carries that lesson one import above the call site in
42
+ // routes/health.ts: MEDIA_CHUNKED_UPLOAD_ENABLED is imported from the route that
43
+ // owns the endpoints "so it cannot drift from whether they are actually
44
+ // registered."
45
+ //
46
+ // `providers` is sourced from BINDABLE_PROVIDERS for the same reason. Cursor must
47
+ // not appear (plan 2.5 makes it Fork-only), and the way to guarantee that is to
48
+ // read the list that `isBindableProvider` — the check the attach route itself
49
+ // applies — is built from, rather than restating two names here and hoping.
50
+ //
51
+ // ------------------------------------------------- why providers empties out
52
+ //
53
+ // When the gate is off, `providers` is `[]` rather than the list this build could
54
+ // drive if it were on. A client that reads only `threadAttachProviders` and never
55
+ // looks at `threadAttachEnabled` is then still correct. Every field independently
56
+ // resolves to "no" when the answer is no; there is no combination of fields a
57
+ // careless reader can pick that yields a Continue button pointing at an
58
+ // unregistered route.
59
+ //
60
+ // The cost is that a disabled server cannot tell an operator WHICH providers it
61
+ // would support. That is a settings-copy problem, and `supported: true` is enough
62
+ // to say "this build can do it, turn it on."
63
+
64
+ import {
65
+ BINDABLE_PROVIDERS,
66
+ isBindableProvider,
67
+ type BindableProvider,
68
+ } from './agent-session-binding-store.js'
69
+ import { threadAttachEnabled } from '../routes/agent-session-bindings.js'
70
+
71
+ /**
72
+ * This build has the feature compiled in.
73
+ *
74
+ * A constant, not a probe. There is no configuration under which the code is
75
+ * absent from a build that contains this file; the reachable/unreachable question
76
+ * is `enabled`. It is published so that `undefined` (an older server) and `false`
77
+ * (this build, switched off) are different answers on the wire.
78
+ */
79
+ export const THREAD_ATTACH_SUPPORTED = true
80
+
81
+ export interface ThreadAttachCapability {
82
+ /** The build knows what Continue is. False only when read off an older payload. */
83
+ supported: boolean
84
+ /** The write routes are registered and a Continue may be attempted. */
85
+ enabled: boolean
86
+ /**
87
+ * Providers that can be CONTINUED, not merely browsed or forked.
88
+ *
89
+ * Empty whenever `enabled` is false. Cursor is never a member: it is Fork-only.
90
+ */
91
+ providers: BindableProvider[]
92
+ /**
93
+ * Fork is available. NOT gated by `enabled`.
94
+ *
95
+ * Fork creates a new thread and leaves the source byte-identical, so the flag
96
+ * that protects an existing conversation does not apply. Gating it produced an
97
+ * incoherent default: every refusal recommends Fork while the route 404s.
98
+ */
99
+ forkSupported: boolean
100
+ }
101
+
102
+ /**
103
+ * The capability as this server should publish it.
104
+ *
105
+ * @param gate Tests only. Production takes the default, which is the same
106
+ * function that decides whether the write routes exist. Passing anything else
107
+ * from production code re-opens the drift this module was written to close.
108
+ */
109
+ export function threadAttachCapability(
110
+ gate: () => boolean = threadAttachEnabled,
111
+ ): ThreadAttachCapability {
112
+ let enabled = false
113
+ try {
114
+ // Anything other than an exact `true` is off. A gate that answers "maybe" is
115
+ // answering no, and a gate that throws has not answered at all.
116
+ enabled = gate() === true
117
+ } catch (error) {
118
+ console.error(
119
+ `[thread-attach-capability] gate threw: ${error instanceof Error ? error.message : error}`,
120
+ )
121
+ enabled = false
122
+ }
123
+ return {
124
+ supported: THREAD_ATTACH_SUPPORTED,
125
+ enabled,
126
+ providers: enabled ? [...BINDABLE_PROVIDERS] : [],
127
+ // NOT gated by `enabled`: fork creates a new thread and leaves the source
128
+ // byte-identical, so the flag protecting an existing conversation does not
129
+ // apply. Gating it made every refusal recommend a route that 404s.
130
+ forkSupported: THREAD_ATTACH_SUPPORTED,
131
+ }
132
+ }
133
+
134
+ /** The three keys as they appear on /api/health and /api/models. */
135
+ export interface ThreadAttachHealthFields {
136
+ threadAttachSupported: boolean
137
+ threadAttachEnabled: boolean
138
+ /** Fork is available whenever the build supports it, gate or no gate. */
139
+ threadForkSupported: boolean
140
+ threadAttachProviders: BindableProvider[]
141
+ }
142
+
143
+ /** Flatten the capability onto the health payload's key names. */
144
+ export function threadAttachHealthFields(
145
+ capability: ThreadAttachCapability,
146
+ ): ThreadAttachHealthFields {
147
+ return {
148
+ threadAttachSupported: capability.supported,
149
+ threadAttachEnabled: capability.enabled,
150
+ threadAttachProviders: capability.providers,
151
+ threadForkSupported: capability.forkSupported,
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Interpret a health payload from ANY server, including one that predates these
157
+ * fields entirely.
158
+ *
159
+ * The whole point of this function is the defaults, so read them as the contract:
160
+ *
161
+ * - a payload that is not an object at all -> everything off
162
+ * - `threadAttachSupported` anything but exactly `true` -> not supported, and
163
+ * therefore not enabled, whatever the other two fields say
164
+ * - `threadAttachEnabled` anything but exactly `true` -> off, so `'true'`, `1`
165
+ * and `'yes'` from a hand-rolled or proxied payload all fail closed
166
+ * - `providers` dropped entirely unless enabled, and filtered to names THIS
167
+ * build can actually drive, so a newer server naming a fourth provider does
168
+ * not make an older client offer a Continue it cannot perform
169
+ *
170
+ * A payload that contradicts itself — enabled without supported, or providers
171
+ * while disabled — is a defect somewhere, and a defect resolves to refuse.
172
+ */
173
+ export function readThreadAttachCapability(payload: unknown): ThreadAttachCapability {
174
+ const row =
175
+ payload && typeof payload === 'object' && !Array.isArray(payload)
176
+ ? (payload as Record<string, unknown>)
177
+ : null
178
+
179
+ const supported = row?.threadAttachSupported === true
180
+ const enabled = supported && row?.threadAttachEnabled === true
181
+
182
+ const providers: BindableProvider[] = []
183
+ const raw = row?.threadAttachProviders
184
+ if (enabled && Array.isArray(raw)) {
185
+ for (const entry of raw) {
186
+ // `isBindableProvider` is the attach route's own check. Anything it rejects
187
+ // is a provider this build has no code path for, so offering it would
188
+ // produce a Continue that cannot be honoured.
189
+ if (!isBindableProvider(entry)) continue
190
+ if (providers.includes(entry)) continue
191
+ providers.push(entry)
192
+ }
193
+ }
194
+
195
+ // Read off a payload from another server. An older build has no fork route, and
196
+ // its payload carries no such field, so absent must read as NOT supported — the
197
+ // same fail-closed rule the rest of this module runs on.
198
+ const forkSupported = row?.threadForkSupported === true
199
+ return { supported, enabled, providers, forkSupported }
200
+ }