@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.
- package/CHANGELOG.md +12 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +3 -1
- package/server/index.ts +9 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/health.ts +2 -0
- package/server/routes/session-hooks.ts +70 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -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,
|
|
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
|
|
299
|
+
async function liveClaudePeerRecords(): Promise<ClaudePeerRecord[]> {
|
|
293
300
|
if (!claudeSessionsEnabled()) return []
|
|
294
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(),
|
package/server/routes/health.ts
CHANGED
|
@@ -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"}}
|