@gotcos/glasses-server 6.47.0 → 6.48.1

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.
@@ -18,8 +18,7 @@
18
18
  import { Router } from 'express'
19
19
  import { existsSync } from 'node:fs'
20
20
  import { lstat, readdir, readFile, stat } from 'node:fs/promises'
21
- import { homedir } from 'node:os'
22
- import { join, resolve } from 'node:path'
21
+ import { join } from 'node:path'
23
22
  import {
24
23
  REGISTRY_FILENAME,
25
24
  countPeers,
@@ -28,7 +27,15 @@ import {
28
27
  type ClaudePeer,
29
28
  type PeerProbes,
30
29
  type RawClaudeSession,
30
+ type ClaudePeerRecord,
31
+ peerRecordFacts,
32
+ toWirePeer,
33
+ claudeSessionsDir,
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'
37
+ import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
38
+ import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
32
39
 
33
40
  export const claudeSessionsRouter = Router()
34
41
 
@@ -56,20 +63,8 @@ export function claudeSessionNamesVisible(): boolean {
56
63
  return process.env.COS_CLAUDE_SESSIONS_SHOW_NAMES === '1'
57
64
  }
58
65
 
59
- /**
60
- * Where the registry lives.
61
- *
62
- * `COS_CLAUDE_SESSIONS_DIR` first because it is both the override for a non-standard
63
- * install AND the test seam — `homedir()` is not mockable, so without an env hook the
64
- * only testable path would be the real one. Then CLAUDE_CONFIG_DIR, which real
65
- * installs do set; hardcoding ~/.claude breaks those.
66
- */
67
- export function claudeSessionsDir(): string {
68
- const explicit = process.env.COS_CLAUDE_SESSIONS_DIR
69
- if (explicit) return resolve(explicit)
70
- const configDir = process.env.CLAUDE_CONFIG_DIR
71
- return join(configDir ? resolve(configDir) : join(homedir(), '.claude'), 'sessions')
72
- }
66
+ /** Where the registry lives; the definition moved to the registry lib in 6.48.1. */
67
+ export { claudeSessionsDir }
73
68
 
74
69
  const realProbes: PeerProbes = {
75
70
  isAlive: pid => {
@@ -92,6 +87,15 @@ export async function readClaudePeers(
92
87
  probes: PeerProbes = realProbes,
93
88
  showNames = claudeSessionNamesVisible(),
94
89
  ): Promise<ClaudePeer[]> {
90
+ return (await readClaudePeerRecords(dir, probes, showNames)).map(toWirePeer)
91
+ }
92
+
93
+ /** The peers with the deriver's facts attached. Server-side only; see `toWirePeer`. */
94
+ export async function readClaudePeerRecords(
95
+ dir: string,
96
+ probes: PeerProbes = realProbes,
97
+ showNames = claudeSessionNamesVisible(),
98
+ ): Promise<ClaudePeerRecord[]> {
95
99
  let names: string[]
96
100
  try {
97
101
  names = await readdir(dir)
@@ -101,7 +105,7 @@ export async function readClaudePeers(
101
105
  // separately so this cannot be mistaken for "the feature is off".
102
106
  return []
103
107
  }
104
- const peers: ClaudePeer[] = []
108
+ const peers: ClaudePeerRecord[] = []
105
109
  for (const name of names.filter(n => REGISTRY_FILENAME.test(n)).slice(0, MAX_REGISTRY_FILES)) {
106
110
  const full = join(dir, name)
107
111
  try {
@@ -114,7 +118,8 @@ export async function readClaudePeers(
114
118
  let mtimeMs: number | null = null
115
119
  try { mtimeMs = (await stat(full)).mtimeMs } catch { /* raced the reaper */ }
116
120
  const peer = toPeer(raw, probes, mtimeMs, showNames)
117
- if (peer) peers.push(peer)
121
+ const facts = peerRecordFacts(raw)
122
+ if (peer && facts) peers.push({ ...peer, ...facts })
118
123
  } catch {
119
124
  // ENOENT between readdir and read is NORMAL here — the reaper is actively
120
125
  // unlinking these — and a torn read is expected because writes are
@@ -122,7 +127,12 @@ export async function readClaudePeers(
122
127
  continue
123
128
  }
124
129
  }
125
- return sortPeers(peers)
130
+ return sortPeers(peers) as ClaudePeerRecord[]
131
+ }
132
+
133
+ /** The deriver's view of a registry record. */
134
+ export function registryFacts(record: ClaudePeerRecord): RegistryFacts {
135
+ return { alive: record.alive, status: record.status, waitingFor: record.waitingFor, statusUpdatedAt: record.statusUpdatedAt, lastActiveAt: record.lastActiveAt }
126
136
  }
127
137
 
128
138
  function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
@@ -148,9 +158,20 @@ claudeSessionsRouter.get('/claude-sessions', async (req, res) => {
148
158
  }
149
159
  try {
150
160
  const limit = boundedInteger(req.query.limit, 30, 1, 100)
151
- const peers = await readClaudePeers(claudeSessionsDir())
161
+ const records = await readClaudePeerRecords(claudeSessionsDir())
162
+ const peers = records.map(toWirePeer)
163
+ const queuedOf = queuedWaitingLookup(Date.now())
164
+ // 6.48.0: the same derived state every surface reads, stamped ADDITIVELY on the wire
165
+ // peer. `toPeer` stays byte-identical (its key set is pinned); the eight extra keys
166
+ // come from the signal store and the registry facts an older client never sees.
167
+ // 6.48.1: queued_turns joins here too so a peer-only row (no agent-sessions hit)
168
+ // still shows a follow-up waiting.
152
169
  res.json({
153
- peers: peers.slice(0, limit),
170
+ peers: records.slice(0, limit).map(record => ({
171
+ ...toWirePeer(record),
172
+ ...derivedRowFields(deriveForRow({ sessionId: record.sessionId, registry: registryFacts(record), attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', record.sessionId)) })),
173
+ ...queuedTurnsFields(queuedOf('claude', record.sessionId)),
174
+ })),
154
175
  counts: countPeers(peers),
155
176
  enabled: true,
156
177
  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,80 @@
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
+ /** The registry entrypoints a person sits at; everything else (`sdk-cli`, unknown) may be a job. */
15
+ export function isInteractiveEntrypoint(entrypoint: string | null): boolean {
16
+ return entrypoint === 'claude-desktop' || entrypoint === 'cli'
17
+ }
18
+
19
+ export function createSessionHooksRouter(options: { port: number }): Router {
20
+ const router = Router()
21
+
22
+ router.get('/session-hooks/status', (_req, res) => {
23
+ res.set('Cache-Control', 'private, no-store')
24
+ res.json({ ok: true, ...sessionHooksHealthFields() })
25
+ })
26
+
27
+ router.post('/session-hooks/install', (req, res) => {
28
+ const dryRun = req.query.dryRun === '1' || (req.body && typeof req.body === 'object' && (req.body as { dryRun?: unknown }).dryRun === true)
29
+ const result = installClaudeHooks({ port: options.port, deskIdleSeconds: deskIdleSeconds(), dryRun })
30
+ invalidateHookStatus()
31
+ if (!result.ok) {
32
+ res.status(409).json({ ok: false, reason: result.reason ?? 'install_failed', status: result.status })
33
+ return
34
+ }
35
+ res.json({ ok: true, changed: result.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
36
+ })
37
+
38
+ router.post('/session-hooks/uninstall', (req, res) => {
39
+ const dryRun = req.query.dryRun === '1'
40
+ const result = uninstallClaudeHooks({ dryRun })
41
+ invalidateHookStatus()
42
+ if (!result.ok) {
43
+ res.status(409).json({ ok: false, reason: result.reason ?? 'uninstall_failed', status: result.status })
44
+ return
45
+ }
46
+ res.json({ ok: true, changed: result.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
47
+ })
48
+
49
+ // Sessions the hooks saw start and end, for Control's scheduled-job ledger. A run
50
+ // shorter than the pet's 20 s poll is recorded here where the poll never saw it.
51
+ router.get('/session-hooks/runs', (req, res) => {
52
+ res.set('Cache-Control', 'private, no-store')
53
+ const since = Number(req.query.since)
54
+ const sinceMs = Number.isFinite(since) && since > 0 ? since : Date.now() - 24 * 60 * 60_000
55
+ // A Desktop tab or a terminal session is not a run (6.48.1): the ledger wants the
56
+ // `claude -p` jobs (`sdk-cli`). `?all=1` lists every session the hooks saw.
57
+ const all = req.query.all === '1'
58
+ const runs: Array<Record<string, unknown>> = []
59
+ for (const signal of sessionSignalStore.snapshot()) {
60
+ if (signal.firstSeenAt < sinceMs && !(signal.ended && signal.ended.at >= sinceMs)) continue
61
+ if (!all && isInteractiveEntrypoint(signal.entrypoint)) continue
62
+ runs.push({
63
+ session_id: signal.sessionId,
64
+ entrypoint: signal.entrypoint,
65
+ started_at: new Date(signal.firstSeenAt).toISOString(),
66
+ ended_at: signal.ended ? new Date(signal.ended.at).toISOString() : null,
67
+ end_reason: signal.ended?.reason ?? null,
68
+ // The registry route reduces cwd to a workspace name on the wire; so does this one.
69
+ workspace: workspaceFromCwd(signal.cwd),
70
+ keep_warm: signal.keepWarm,
71
+ child_events: signal.childEvents,
72
+ last_reply: signal.lastReply || null,
73
+ })
74
+ }
75
+ runs.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at)))
76
+ res.json({ ok: true, runs, since: new Date(sinceMs).toISOString() })
77
+ })
78
+
79
+ return router
80
+ }
@@ -16,6 +16,7 @@
16
16
  // EVERY DEPENDENCY IS INJECTED so the whole path is testable without a live server.
17
17
 
18
18
  import { Router, type Request, type Response } from 'express'
19
+ import { COS_SESSION_ID_RE } from './agent-session-bindings.js'
19
20
  import {
20
21
  admitToQueue, drainDecision, queueableRefusal, queuePosition,
21
22
  MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
@@ -27,6 +28,8 @@ export interface ThreadTurnQueueDeps {
27
28
  occupancy: (provider: string, threadId: string) => { attachable: boolean; reason: string | null }
28
29
  /** Did the holder's last transcript record end a turn? */
29
30
  turnEnded: (provider: string, threadId: string) => boolean
31
+ /** 6.48.1, optional: is the holder's turn positively OPEN right now (see DrainObservation.turnOpen)? */
32
+ turnOpen?: (provider: string, threadId: string) => boolean
30
33
  /** The 30s transcript clock, as a backstop. */
31
34
  activity: (provider: string, threadId: string) => 'working' | 'idle' | 'unknown'
32
35
  /**
@@ -77,6 +80,11 @@ function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unk
77
80
  * return false and spend an attempt. A new refusal added upstream is therefore bounded
78
81
  * by default rather than silently retried forever.
79
82
  */
83
+ /** A throwing turnOpen probe is not evidence either way. */
84
+ function safeTurnOpen(deps: ThreadTurnQueueDeps, provider: string, threadId: string): boolean | undefined {
85
+ try { return deps.turnOpen!(provider, threadId) } catch { return undefined }
86
+ }
87
+
80
88
  function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boolean }): boolean {
81
89
  // The turn route publishes its own verdict; it outranks our inference either way.
82
90
  if (outcome.serverRetryable === false) return false
@@ -116,6 +124,7 @@ export async function drainThread(
116
124
  const seen: DrainObservation = {
117
125
  attachable: gate.attachable,
118
126
  turnEnded: deps.turnEnded(provider, threadId),
127
+ turnOpen: deps.turnOpen ? safeTurnOpen(deps, provider, threadId) : undefined,
119
128
  activity: deps.activity(provider, threadId),
120
129
  reason: gate.reason,
121
130
  }
@@ -212,6 +221,12 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
212
221
  if (!clientTurnId || !cosSessionId || !prompt.trim()) {
213
222
  return res.status(400).json({ error: 'invalid_request' })
214
223
  }
224
+ // 6.48.1: the attach route refuses an id outside COS_SESSION_ID_RE as invalid_request,
225
+ // which the drainer treats as retryable, so a bad id used to sit in the queue for the
226
+ // whole TTL. Refuse it here, where the client can still fix it.
227
+ if (!COS_SESSION_ID_RE.test(cosSessionId)) {
228
+ return res.status(400).json({ error: 'invalid_request', reason: 'invalid_cos_session_id' })
229
+ }
215
230
 
216
231
  if (provider === 'cursor') {
217
232
  return res.status(423).json({ error: 'unsupported_provider', queueable: false })
@@ -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"}}