@gotcos/glasses-server 6.47.0 → 6.48.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,211 @@
1
+ // ONE derivation of a session's state, for every surface.
2
+ //
3
+ // Before 6.48.0 three readers derived state on their own: this server (`running` and
4
+ // `running_active` from occupancy and a 30 s transcript window), the Control helper
5
+ // (transcript tail + a 15 minute "in flight, then waiting" guess), and the EHPK (four
6
+ // states from the row's flags). The pet, the Sessions tab, the phone and the lens could
7
+ // disagree about the same tab. This function is the only place a state is decided;
8
+ // the rows carry its answer and its provenance.
9
+ //
10
+ // PRECEDENCE, in order, each with the evidence that makes it yield:
11
+ //
12
+ // hook the engine's own hook events for this session (`session-signal-store`)
13
+ // yields to registry when the registry's status moved more recently and the
14
+ // hooks have been silent for HOOK_SILENCE_MS
15
+ // registry `~/.claude/sessions/<pid>.json` status busy|idle|waiting with a live pid
16
+ // yields to `ended` after MISS_LIMIT consecutive derives with a dead pid
17
+ // transcript whatever the transcript tail says (activity clock, in-flight marker)
18
+ //
19
+ // `ended` from a SessionEnd hook is honoured only when no alive registry record remains:
20
+ // a COS Continue child shares the tab's session id and ends while the tab stays open.
21
+ //
22
+ // The wire key is `agent_state`, never `state`: `state` already exists on every row as
23
+ // `running|recent` and Control renders anything else as "Running" (validation round 1).
24
+
25
+ import type { SessionSignal, WaitingKind } from './session-signal-store.js'
26
+
27
+ export type AgentState = 'running' | 'waiting' | 'idle' | 'failed' | 'ended'
28
+ export type StateSource = 'hook' | 'registry' | 'transcript'
29
+
30
+ export interface DerivedSessionState {
31
+ agent_state: AgentState
32
+ state_source: StateSource
33
+ /** ISO time of the event that produced the current state. */
34
+ state_since: string
35
+ waiting_kind?: WaitingKind
36
+ waiting_detail?: string
37
+ failure?: string
38
+ last_reply?: string
39
+ pending_permission_id?: string
40
+ /** Consecutive derives that saw a dead registry pid; carried by the caller between polls. */
41
+ deadScans: number
42
+ /** When the pid was first seen dead, so two observations must also be DEAD_GRACE_MS apart. */
43
+ deadSince: number | null
44
+ }
45
+
46
+ /** The registry facts the deriver reads; a projection of the peer record, never the wire. */
47
+ export interface RegistryFacts {
48
+ alive: boolean
49
+ status: string | null
50
+ waitingFor: string | null
51
+ statusUpdatedAt: number | null
52
+ lastActiveAt: number | null
53
+ }
54
+
55
+ /** What the transcript tail says, when nothing better is known. */
56
+ export interface TranscriptFacts {
57
+ /** The newest assistant record is a tool_use with no result, or a user prompt awaits. */
58
+ inFlight: boolean
59
+ lastActivityAt: number | null
60
+ }
61
+
62
+ export interface DeriveInput {
63
+ signal: SessionSignal | undefined
64
+ registry: RegistryFacts | undefined
65
+ transcript: TranscriptFacts | undefined
66
+ now: number
67
+ /** The previous derive's `deadScans`, so the two-scan rule survives between polls. */
68
+ prevDeadScans?: number
69
+ /** The previous derive's `deadSince`. */
70
+ prevDeadSince?: number | null
71
+ }
72
+
73
+ /** Harness `MISS_LIMIT`: a pid must be dead on two consecutive scans before a row ends. */
74
+ export const MISS_LIMIT = 2
75
+ /**
76
+ * The two scans must also be this far apart in wall-clock time. A derive runs per HTTP
77
+ * request, and one client refresh is two requests in milliseconds; a registry file that
78
+ * is being rewritten must not read as a death.
79
+ */
80
+ export const DEAD_GRACE_MS = 5_000
81
+ /** A hook-derived wait with no live registry record behind it is not trusted past this. */
82
+ export const WAITING_CEILING_MS = 30 * 60_000
83
+ /** Transcript activity this much newer than a wait means the tool ran: the wait is over. */
84
+ export const WAITING_TRANSCRIPT_VETO_MS = 60_000
85
+ /** Hooks silent this long while the registry moved: the registry wins. */
86
+ export const HOOK_SILENCE_MS = 30 * 60_000
87
+ /** A turn that has been open this long with no event at all is no longer trusted as running. */
88
+ export const OPEN_TURN_CEILING_MS = 30 * 60_000
89
+
90
+ const iso = (ms: number) => new Date(ms).toISOString()
91
+
92
+ export function deriveSessionState(input: DeriveInput): DerivedSessionState {
93
+ const { signal, registry, transcript, now } = input
94
+ const prevDead = input.prevDeadScans ?? 0
95
+ const dead = !!registry && !registry.alive
96
+ const deadScans = dead ? prevDead + 1 : 0
97
+ const deadSince = dead ? (input.prevDeadSince ?? now) : null
98
+ const deadLongEnough = dead && deadScans >= MISS_LIMIT && deadSince !== null && now - deadSince >= DEAD_GRACE_MS
99
+ const registryMovedLater = !!(registry?.statusUpdatedAt && signal && registry.statusUpdatedAt > signal.lastEventAt)
100
+ const hooksSilent = !!signal && now - signal.lastEventAt > HOOK_SILENCE_MS
101
+ const carry = { deadScans, deadSince }
102
+
103
+ // A dead pid on two scans at least DEAD_GRACE_MS apart ends the row whatever the hooks
104
+ // last said, unless a hook event is newer than the registry's last movement (a resumed
105
+ // tab under a new pid).
106
+ if (deadLongEnough && !(signal && registry.lastActiveAt && signal.lastEventAt > registry.lastActiveAt)) {
107
+ return { agent_state: 'ended', state_source: 'registry', state_since: iso(registry.lastActiveAt ?? now), ...carry, ...replyOf(signal) }
108
+ }
109
+
110
+ if (signal && !(registryMovedLater && hooksSilent)) {
111
+ const fromHook = deriveFromSignal(signal, registry, transcript, now)
112
+ if (fromHook) return { ...fromHook, ...carry }
113
+ }
114
+
115
+ if (registry?.alive) {
116
+ const since = iso(registry.statusUpdatedAt ?? registry.lastActiveAt ?? now)
117
+ if (registry.status === 'waiting') {
118
+ return { agent_state: 'waiting', state_source: 'registry', state_since: since, waiting_kind: registryWaitingKind(registry.waitingFor), waiting_detail: registry.waitingFor ?? '', ...carry, ...replyOf(signal) }
119
+ }
120
+ if (registry.status === 'busy') return { agent_state: 'running', state_source: 'registry', state_since: since, ...carry, ...replyOf(signal) }
121
+ if (registry.status === 'idle') return { agent_state: 'idle', state_source: 'registry', state_since: since, ...carry, ...replyOf(signal) }
122
+ }
123
+
124
+ if (transcript) {
125
+ const since = iso(transcript.lastActivityAt ?? now)
126
+ return { agent_state: transcript.inFlight ? 'running' : 'idle', state_source: 'transcript', state_since: since, ...carry, ...replyOf(signal) }
127
+ }
128
+
129
+ // Only a signal that was ruled stale above, or nothing at all: a stale open turn is not
130
+ // evidence of work, so it reads idle.
131
+ if (signal) return { agent_state: 'idle', state_source: 'hook', state_since: iso(signal.stopAt ?? signal.lastEventAt), ...carry, ...replyOf(signal) }
132
+ return { agent_state: 'idle', state_source: 'transcript', state_since: iso(now), ...carry }
133
+ }
134
+
135
+ /** The registry writes `waitingFor: "dialog open"` for a permission dialog on this Mac. */
136
+ function registryWaitingKind(waitingFor: string | null): WaitingKind {
137
+ return waitingFor && /dialog|permission|approv/i.test(waitingFor) ? 'permission' : 'question'
138
+ }
139
+
140
+ function replyOf(signal: SessionSignal | undefined): { last_reply?: string } {
141
+ return signal?.lastReply ? { last_reply: signal.lastReply } : {}
142
+ }
143
+
144
+ type HookVerdict = Omit<DerivedSessionState, 'deadScans' | 'deadSince'>
145
+
146
+ function deriveFromSignal(signal: SessionSignal, registry: RegistryFacts | undefined, transcript: TranscriptFacts | undefined, now: number): HookVerdict | null {
147
+ const reply = replyOf(signal)
148
+ if (signal.ended && !(registry?.alive)) {
149
+ return { agent_state: 'ended', state_source: 'hook', state_since: iso(signal.ended.at), ...reply }
150
+ }
151
+ if (signal.waiting && waitStillStands(signal.waiting.since, registry, transcript, now)) {
152
+ return {
153
+ agent_state: 'waiting',
154
+ state_source: 'hook',
155
+ state_since: iso(signal.waiting.since),
156
+ waiting_kind: signal.waiting.kind,
157
+ waiting_detail: signal.waiting.detail,
158
+ ...(signal.waiting.requestId ? { pending_permission_id: signal.waiting.requestId } : {}),
159
+ ...reply,
160
+ }
161
+ }
162
+ if (signal.waiting) {
163
+ // The wait was overtaken by evidence the store cannot see (registry moved on, the
164
+ // transcript moved on, or nothing alive stands behind it): fall through to whoever can.
165
+ return null
166
+ }
167
+ if (signal.failure && !signal.turnOpen) {
168
+ return { agent_state: 'failed', state_source: 'hook', state_since: iso(signal.failure.at), failure: signal.failure.kind, ...reply }
169
+ }
170
+ if (signal.turnOpen) {
171
+ // An interrupt (Esc) fires no Stop: the registry moving to idle AFTER the last hook
172
+ // event is the only signal, and it must not wait for the half-hour ceiling.
173
+ if (registry?.alive && registry.status === 'idle' && registry.statusUpdatedAt && registry.statusUpdatedAt > signal.lastEventAt) return null
174
+ // An open turn with no event for half an hour is not evidence of work any more;
175
+ // let the registry or the transcript answer.
176
+ if (now - signal.lastEventAt > OPEN_TURN_CEILING_MS) return null
177
+ return { agent_state: 'running', state_source: 'hook', state_since: iso(signal.turnStartedAt ?? signal.lastEventAt), ...reply }
178
+ }
179
+ // A child that ended while the tab is alive reads idle, since the tab's own hooks are
180
+ // what would say otherwise.
181
+ return { agent_state: 'idle', state_source: 'hook', state_since: iso(signal.stopAt ?? signal.lastEventAt), ...reply }
182
+ }
183
+
184
+ /**
185
+ * A hook-derived wait stands while nothing contradicts it. Three things do: the registry
186
+ * moved after the wait began and no longer says waiting (the dialog closed); the
187
+ * transcript moved on well after the wait began (the tool ran); nothing alive stands
188
+ * behind a wait older than the ceiling (a tab that died with its dialog up, or a
189
+ * PermissionRequest spooled during an outage whose close half the guard dropped).
190
+ */
191
+ function waitStillStands(since: number, registry: RegistryFacts | undefined, transcript: TranscriptFacts | undefined, now: number): boolean {
192
+ if (registry?.alive && registry.statusUpdatedAt && registry.statusUpdatedAt > since && registry.status !== 'waiting' && registry.status !== null) return false
193
+ if (transcript?.lastActivityAt && transcript.lastActivityAt > since + WAITING_TRANSCRIPT_VETO_MS) return false
194
+ if (!(registry?.alive) && now - since > WAITING_CEILING_MS) return false
195
+ return true
196
+ }
197
+
198
+ /** The additive row fields, ready to spread onto a list or detail entry. */
199
+ export function derivedRowFields(derived: DerivedSessionState | undefined): Record<string, unknown> {
200
+ if (!derived) return {}
201
+ return {
202
+ agent_state: derived.agent_state,
203
+ state_source: derived.state_source,
204
+ state_since: derived.state_since,
205
+ ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
206
+ ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
207
+ ...(derived.failure ? { failure: derived.failure } : {}),
208
+ ...(derived.last_reply ? { last_reply: derived.last_reply } : {}),
209
+ ...(derived.pending_permission_id ? { pending_permission_id: derived.pending_permission_id } : {}),
210
+ }
211
+ }
@@ -37,7 +37,10 @@ import {
37
37
  type AgentSessionSort,
38
38
  } from '../lib/agent-session-store.js'
39
39
  import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
40
- import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
40
+ import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
41
+ import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
42
+ import { deriveForRow } from '../lib/session-hooks-runtime.js'
43
+ import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
41
44
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
42
45
  import {
43
46
  occupiedThreads,
@@ -261,7 +264,7 @@ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: nu
261
264
  }
262
265
  }
263
266
 
264
- function toEntry(row: AgentSessionRow, activity?: SessionActivity | null) {
267
+ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState) {
265
268
  return {
266
269
  session_id: row.session_id,
267
270
  provider: row.provider,
@@ -286,12 +289,19 @@ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null) {
286
289
  // unknown, so an older client and a Cursor row see exactly the payload they did.
287
290
  ...(activity?.lastActivityAt ? { last_activity_at: activity.lastActivityAt } : {}),
288
291
  ...(activity?.lastTool ? { last_tool: activity.lastTool } : {}),
292
+ // 6.48.0: the one derived state (hook > registry > transcript) with its provenance.
293
+ // Under a NEW key: `state` above is `running|recent` and Control renders any other
294
+ // value there as "Running". Omitted entirely when nothing is known, like the two above.
295
+ ...derivedRowFields(derived),
289
296
  }
290
297
  }
291
298
 
292
- async function liveClaudeRows(): Promise<AgentSessionRow[]> {
299
+ async function liveClaudePeerRecords(): Promise<ClaudePeerRecord[]> {
293
300
  if (!claudeSessionsEnabled()) return []
294
- const peers = await readClaudePeers(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
301
+ return readClaudePeerRecords(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
302
+ }
303
+
304
+ function liveClaudeRows(peers: ClaudePeerRecord[]): AgentSessionRow[] {
295
305
  return peers.filter(peer => peer.alive).map(peer => ({
296
306
  session_id: peer.id,
297
307
  provider: 'claude' as const,
@@ -322,7 +332,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
322
332
  // ONE alias load per request, shared by the walk, every live row and every
323
333
  // held thread. Measured 2026-09-10: sixteen loads of 119 MB before this line.
324
334
  const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
325
- const live = await liveClaudeRows()
335
+ const peers = await liveClaudePeerRecords()
336
+ const live = liveClaudeRows(peers)
326
337
  const dropped = emptySessionListDropped()
327
338
  const sessions = await listAgentSessions(roots, new Date(), live, limit, sort, dropped, aliases)
328
339
  // 6.45.5: each row's last real activity, read from its transcript records. Memoized on
@@ -342,8 +353,35 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
342
353
  if (mtimeMs !== null) clocks.set(threadId, activityClockMs(mtimeMs, activityById.get(threadId)))
343
354
  }
344
355
  const running = withActiveRecently(scan, clocks, Date.now())
356
+ // Derived AFTER the walk: live rows carry the registry's eight-character id until
357
+ // `enrichLiveClaude` finds their transcript, and the deriver wants the full one where
358
+ // it exists. The registry facts are joined back by prefix; a transcript-only row (an
359
+ // ended job, a tab closed hours ago) still gets a state from its activity clock.
360
+ const now = Date.now()
361
+ // First wins, and `readClaudePeerRecords` sorts alive first: a dead predecessor's file
362
+ // (a resumed tab, a finished Continue child) must never shadow the live record.
363
+ const peersByPrefix = new Map<string, ClaudePeerRecord>()
364
+ for (const peer of peers) {
365
+ const prefix = peer.sessionId.slice(0, 8)
366
+ if (!peersByPrefix.has(prefix)) peersByPrefix.set(prefix, peer)
367
+ }
368
+ const derivedById = new Map<string, DerivedSessionState | undefined>()
369
+ for (const row of sessions) {
370
+ if (row.provider !== 'claude') continue
371
+ const peer = peersByPrefix.get(row.session_id.slice(0, 8).toLowerCase())
372
+ const hint = running.occupied.get(row.session_id)
373
+ derivedById.set(row.session_id, deriveForRow({
374
+ sessionId: row.session_id,
375
+ registry: peer ? registryFacts(peer) : undefined,
376
+ transcript: {
377
+ inFlight: hint?.activeRecently === true,
378
+ lastActivityAt: activityById.get(row.session_id)?.lastActivityAt ? Date.parse(activityById.get(row.session_id)!.lastActivityAt!) : null,
379
+ },
380
+ now,
381
+ }))
382
+ }
345
383
  res.json({
346
- sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index]), running)),
384
+ sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id)), running)),
347
385
  total: sessions.length,
348
386
  windowHours: AGENT_SESSION_WINDOW_HOURS,
349
387
  sort,
@@ -417,8 +455,20 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
417
455
  const modified = st.mtime.toISOString()
418
456
  const activity = await readSessionActivity(provider, found)
419
457
  const running = runningForThread(provider, parsed.session_id, activityClockMs(st.mtimeMs, activity))
458
+ // 6.48.0: the detail carries the same derived state as its list row. The registry is
459
+ // read once here (a few hundred small files at most) because the detail has no walk.
460
+ let derived: DerivedSessionState | undefined
461
+ if (provider === 'claude') {
462
+ const peer = (await liveClaudePeerRecords()).find(p => p.sessionId === parsed.session_id.toLowerCase()) // alive first
463
+ derived = deriveForRow({
464
+ sessionId: parsed.session_id,
465
+ registry: peer ? registryFacts(peer) : undefined,
466
+ transcript: { inFlight: running.occupied.get(parsed.session_id)?.activeRecently === true, lastActivityAt: activity.lastActivityAt ? Date.parse(activity.lastActivityAt) : null },
467
+ })
468
+ }
420
469
  res.json({
421
470
  ...withRunning({ session_id: parsed.session_id }, running),
471
+ ...derivedRowFields(derived),
422
472
  // The client must be able to tell "this server stamped nothing" from "this
423
473
  // server stamped false", because the two demand opposite behaviour: an old
424
474
  // server's silence means keep using the hint borrowed from the list row, and
@@ -28,7 +28,12 @@ import {
28
28
  type ClaudePeer,
29
29
  type PeerProbes,
30
30
  type RawClaudeSession,
31
+ type ClaudePeerRecord,
32
+ peerRecordFacts,
33
+ toWirePeer,
31
34
  } from '../lib/claude-session-registry.js'
35
+ import { deriveForRow } from '../lib/session-hooks-runtime.js'
36
+ import { derivedRowFields, type RegistryFacts } from '../lib/session-state-derive.js'
32
37
 
33
38
  export const claudeSessionsRouter = Router()
34
39
 
@@ -92,6 +97,15 @@ export async function readClaudePeers(
92
97
  probes: PeerProbes = realProbes,
93
98
  showNames = claudeSessionNamesVisible(),
94
99
  ): Promise<ClaudePeer[]> {
100
+ return (await readClaudePeerRecords(dir, probes, showNames)).map(toWirePeer)
101
+ }
102
+
103
+ /** The peers with the deriver's facts attached. Server-side only; see `toWirePeer`. */
104
+ export async function readClaudePeerRecords(
105
+ dir: string,
106
+ probes: PeerProbes = realProbes,
107
+ showNames = claudeSessionNamesVisible(),
108
+ ): Promise<ClaudePeerRecord[]> {
95
109
  let names: string[]
96
110
  try {
97
111
  names = await readdir(dir)
@@ -101,7 +115,7 @@ export async function readClaudePeers(
101
115
  // separately so this cannot be mistaken for "the feature is off".
102
116
  return []
103
117
  }
104
- const peers: ClaudePeer[] = []
118
+ const peers: ClaudePeerRecord[] = []
105
119
  for (const name of names.filter(n => REGISTRY_FILENAME.test(n)).slice(0, MAX_REGISTRY_FILES)) {
106
120
  const full = join(dir, name)
107
121
  try {
@@ -114,7 +128,8 @@ export async function readClaudePeers(
114
128
  let mtimeMs: number | null = null
115
129
  try { mtimeMs = (await stat(full)).mtimeMs } catch { /* raced the reaper */ }
116
130
  const peer = toPeer(raw, probes, mtimeMs, showNames)
117
- if (peer) peers.push(peer)
131
+ const facts = peerRecordFacts(raw)
132
+ if (peer && facts) peers.push({ ...peer, ...facts })
118
133
  } catch {
119
134
  // ENOENT between readdir and read is NORMAL here — the reaper is actively
120
135
  // unlinking these — and a torn read is expected because writes are
@@ -122,7 +137,12 @@ export async function readClaudePeers(
122
137
  continue
123
138
  }
124
139
  }
125
- return sortPeers(peers)
140
+ return sortPeers(peers) as ClaudePeerRecord[]
141
+ }
142
+
143
+ /** The deriver's view of a registry record. */
144
+ export function registryFacts(record: ClaudePeerRecord): RegistryFacts {
145
+ return { alive: record.alive, status: record.status, waitingFor: record.waitingFor, statusUpdatedAt: record.statusUpdatedAt, lastActiveAt: record.lastActiveAt }
126
146
  }
127
147
 
128
148
  function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
@@ -148,9 +168,16 @@ claudeSessionsRouter.get('/claude-sessions', async (req, res) => {
148
168
  }
149
169
  try {
150
170
  const limit = boundedInteger(req.query.limit, 30, 1, 100)
151
- const peers = await readClaudePeers(claudeSessionsDir())
171
+ const records = await readClaudePeerRecords(claudeSessionsDir())
172
+ const peers = records.map(toWirePeer)
173
+ // 6.48.0: the same derived state every surface reads, stamped ADDITIVELY on the wire
174
+ // peer. `toPeer` stays byte-identical (its key set is pinned); the eight extra keys
175
+ // come from the signal store and the registry facts an older client never sees.
152
176
  res.json({
153
- peers: peers.slice(0, limit),
177
+ peers: records.slice(0, limit).map(record => ({
178
+ ...toWirePeer(record),
179
+ ...derivedRowFields(deriveForRow({ sessionId: record.sessionId, registry: registryFacts(record) })),
180
+ })),
154
181
  counts: countPeers(peers),
155
182
  enabled: true,
156
183
  generatedAt: Date.now(),
@@ -79,6 +79,7 @@ import {
79
79
  threadAttachCapability,
80
80
  threadAttachHealthFields,
81
81
  } from '../lib/thread-attach-capability.js'
82
+ import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
82
83
 
83
84
  export const healthRouter = Router()
84
85
 
@@ -349,6 +350,7 @@ healthRouter.get('/health', async (_req, res) => {
349
350
  ...checks,
350
351
  server_version: managedServerVersion(),
351
352
  ...threadAttachHealthFields(threadAttach),
353
+ ...sessionHooksHealthFields(),
352
354
  server_instance_id: getServerInstanceId(),
353
355
  boot_id: serverMetrics.bootId,
354
356
  generation_id: getServerGenerationId(),
@@ -0,0 +1,70 @@
1
+ // Session hooks: status, install, uninstall, and today's runs.
2
+ //
3
+ // `GET /api/session-hooks/status` is what Control's banner keys off. Five outcomes on
4
+ // the client side and this route owns three of them: 200 with `installed` (no banner),
5
+ // 200 with `drift`, `missing`, `script_outdated`, `settings_*` (banner with Install). A
6
+ // 404 means a server older than 6.48.0 (`route_absent`), and no answer at all is
7
+ // `unreachable`; neither is "not installed", and Control must never say so for them.
8
+
9
+ import { Router } from 'express'
10
+ import { installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
11
+ import { deskIdleSeconds, invalidateHookStatus, sessionHooksHealthFields, sessionSignalStore } from '../lib/session-hooks-runtime.js'
12
+ import { workspaceFromCwd } from '../lib/claude-session-registry.js'
13
+
14
+ export function createSessionHooksRouter(options: { port: number }): Router {
15
+ const router = Router()
16
+
17
+ router.get('/session-hooks/status', (_req, res) => {
18
+ res.set('Cache-Control', 'private, no-store')
19
+ res.json({ ok: true, ...sessionHooksHealthFields() })
20
+ })
21
+
22
+ router.post('/session-hooks/install', (req, res) => {
23
+ const dryRun = req.query.dryRun === '1' || (req.body && typeof req.body === 'object' && (req.body as { dryRun?: unknown }).dryRun === true)
24
+ const result = installClaudeHooks({ port: options.port, deskIdleSeconds: deskIdleSeconds(), dryRun })
25
+ invalidateHookStatus()
26
+ if (!result.ok) {
27
+ res.status(409).json({ ok: false, reason: result.reason ?? 'install_failed', status: result.status })
28
+ return
29
+ }
30
+ res.json({ ok: true, changed: result.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
31
+ })
32
+
33
+ router.post('/session-hooks/uninstall', (req, res) => {
34
+ const dryRun = req.query.dryRun === '1'
35
+ const result = uninstallClaudeHooks({ dryRun })
36
+ invalidateHookStatus()
37
+ if (!result.ok) {
38
+ res.status(409).json({ ok: false, reason: result.reason ?? 'uninstall_failed', status: result.status })
39
+ return
40
+ }
41
+ res.json({ ok: true, changed: result.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
42
+ })
43
+
44
+ // Sessions the hooks saw start and end, for Control's scheduled-job ledger. A run
45
+ // shorter than the pet's 20 s poll is recorded here where the poll never saw it.
46
+ router.get('/session-hooks/runs', (req, res) => {
47
+ res.set('Cache-Control', 'private, no-store')
48
+ const since = Number(req.query.since)
49
+ const sinceMs = Number.isFinite(since) && since > 0 ? since : Date.now() - 24 * 60 * 60_000
50
+ const runs: Array<Record<string, unknown>> = []
51
+ for (const signal of sessionSignalStore.snapshot()) {
52
+ if (signal.firstSeenAt < sinceMs && !(signal.ended && signal.ended.at >= sinceMs)) continue
53
+ runs.push({
54
+ session_id: signal.sessionId,
55
+ started_at: new Date(signal.firstSeenAt).toISOString(),
56
+ ended_at: signal.ended ? new Date(signal.ended.at).toISOString() : null,
57
+ end_reason: signal.ended?.reason ?? null,
58
+ // The registry route reduces cwd to a workspace name on the wire; so does this one.
59
+ workspace: workspaceFromCwd(signal.cwd),
60
+ keep_warm: signal.keepWarm,
61
+ child_events: signal.childEvents,
62
+ last_reply: signal.lastReply || null,
63
+ })
64
+ }
65
+ runs.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at)))
66
+ res.json({ ok: true, runs, since: new Date(sinceMs).toISOString() })
67
+ })
68
+
69
+ return router
70
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env tsx
2
+ // Install, inspect or remove the COS session hook in ~/.claude/settings.json.
3
+ //
4
+ // npx --yes @gotcos/glasses-server@latest --hooks install [--dry-run] [--port 3141]
5
+ // npx --yes @gotcos/glasses-server@latest --hooks status
6
+ // npx --yes @gotcos/glasses-server@latest --hooks uninstall [--dry-run]
7
+ //
8
+ // Prints one JSON document. Exit 0 on success, 2 on a refusal (unparseable or symlinked
9
+ // settings, a missing packaged script), 64 on a bad argument. Never prints the token.
10
+
11
+ import { hookStatus, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
12
+ import { sessionHooksEnabled } from '../lib/session-hooks-runtime.js'
13
+
14
+ const args = process.argv.slice(2)
15
+ const action = args.find(a => !a.startsWith('--'))
16
+ const dryRun = args.includes('--dry-run')
17
+ const portIndex = args.indexOf('--port')
18
+ const port = portIndex >= 0 ? Number(args[portIndex + 1]) : Number(process.env.PORT ?? 3141)
19
+ const deskIndex = args.indexOf('--desk-idle-s')
20
+ const deskIdleSeconds = deskIndex >= 0 ? Number(args[deskIndex + 1]) : Number(process.env.COS_PERMISSION_BROKER_DESK_IDLE_S ?? 90)
21
+
22
+ function print(value: unknown): void {
23
+ console.log(JSON.stringify(value, null, 2))
24
+ }
25
+
26
+ // `serverApplies` says whether the running server would READ the spool into rows: the
27
+ // hooks can be installed while the feature is off (COS_CLAUDE_SESSIONS_ENABLED unset),
28
+ // and a status that said only "installed" would hide that.
29
+ const serverApplies = sessionHooksEnabled()
30
+ const flagsNote = serverApplies ? undefined : 'Rows change only when the server runs with COS_CLAUDE_SESSIONS_ENABLED=1 (or COS_SESSION_HOOKS=1).'
31
+
32
+ if (action === 'status') {
33
+ print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...hookStatus() })
34
+ process.exit(0)
35
+ }
36
+ if (action === 'install') {
37
+ if (!Number.isFinite(port) || port <= 0) { console.error('Bad --port'); process.exit(64) }
38
+ const result = installClaudeHooks({ port, deskIdleSeconds: Number.isFinite(deskIdleSeconds) ? deskIdleSeconds : 90, dryRun })
39
+ print({ action, dryRun, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...result })
40
+ process.exit(result.ok ? 0 : 2)
41
+ }
42
+ if (action === 'uninstall') {
43
+ const result = uninstallClaudeHooks({ dryRun })
44
+ print({ action, dryRun, ...result })
45
+ process.exit(result.ok ? 0 : 2)
46
+ }
47
+ console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N] [--desk-idle-s N]')
48
+ process.exit(64)
@@ -1,2 +0,0 @@
1
- {"schemaVersion":1,"recordId":"02b09947-ed7d-47ef-99c5-4985af3ec924","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"43b397bd-dd67-4312-9a5c-4c5d4b246f1d","clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"turnId":"a456fd1d-1e82-4b2d-8a20-5d34681201fd","requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"query":"written by 6.43.3","sessionId":"session-6-43-3","model":"opus","effort":"high","cursorExecutionMode":"ask","messageEra":"era1","globalMsgNum":78,"reference":{"query":"earlier q","response":"earlier a"},"handoffCode":"ABCD","handoffLatest":true,"clientQueueItemId":"q1","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95"}}
2
- {"schemaVersion":1,"recordId":"a1145a40-2ced-4da3-9cf7-76d84cffc946","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"64c5b502-13f2-4871-b6c4-007b91be2414","clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"turnId":"843e633f-0f92-4fe3-b2a1-9dc683715791","requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"query":"minimal by 6.43.3","sessionId":"session-6-43-3-min","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638"}}