@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,229 @@
|
|
|
1
|
+
// The composition root for session hooks: the one signal store, the ledger, the spool
|
|
2
|
+
// ingester, and the per-session facts the routes read.
|
|
3
|
+
//
|
|
4
|
+
// `COS_SESSION_HOOKS` (default: on whenever `COS_CLAUDE_SESSIONS_ENABLED=1`) gates the
|
|
5
|
+
// APPLY, never the drain: with the flag off the spool is still ledgered, unlinked and
|
|
6
|
+
// stamped, and nothing reaches a row. Control's env allowlist carries the flag across
|
|
7
|
+
// Update Server (main.swift `providerEnvironmentKeys`).
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from 'node:child_process'
|
|
10
|
+
import { mkdirSync } from 'node:fs'
|
|
11
|
+
import { cosSpawnedPids } from './agent-session-ownership-store.js'
|
|
12
|
+
import { dataPath } from './data-dir.js'
|
|
13
|
+
import { SessionHookLedger } from './session-hook-ledger.js'
|
|
14
|
+
import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
|
|
15
|
+
import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
|
|
16
|
+
import { deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
|
|
17
|
+
import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
|
|
18
|
+
|
|
19
|
+
export function sessionHooksEnabled(): boolean {
|
|
20
|
+
const raw = process.env.COS_SESSION_HOOKS
|
|
21
|
+
if (raw === '0' || raw === 'false' || raw === 'off') return false
|
|
22
|
+
if (raw === '1' || raw === 'true' || raw === 'on') return true
|
|
23
|
+
return process.env.COS_CLAUDE_SESSIONS_ENABLED === '1'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function spoolDir(): string {
|
|
27
|
+
return hookSpoolDir()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deskIdleSeconds(): number {
|
|
31
|
+
const raw = Number(process.env.COS_PERMISSION_BROKER_DESK_IDLE_S)
|
|
32
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.trunc(raw) : 90
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Replay this much ledger history into the store at boot. */
|
|
36
|
+
export const LEDGER_REPLAY_WINDOW_MS = 6 * 60 * 60_000
|
|
37
|
+
|
|
38
|
+
// A hook's ppid is the claude process itself: macOS `sh -c '<script> <Event>'` execs the
|
|
39
|
+
// single command, and every recorded session (fixtures, 2026-09-15) shows one ppid across all
|
|
40
|
+
// of its events. The `ps` parent lookup below is the fallback for a shell that does not exec;
|
|
41
|
+
// it runs once per pid and is remembered for a minute.
|
|
42
|
+
const parentCache = new Map<number, { parent: number | null; at: number }>()
|
|
43
|
+
const PARENT_CACHE_MS = 60_000
|
|
44
|
+
|
|
45
|
+
function parentPid(pid: number): number | null {
|
|
46
|
+
const cached = parentCache.get(pid)
|
|
47
|
+
if (cached && Date.now() - cached.at < PARENT_CACHE_MS) return cached.parent
|
|
48
|
+
let parent: number | null = null
|
|
49
|
+
try {
|
|
50
|
+
const out = execFileSync('/bin/ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf-8', timeout: 1_000 })
|
|
51
|
+
const n = Number(out.trim())
|
|
52
|
+
parent = Number.isInteger(n) && n > 0 ? n : null
|
|
53
|
+
} catch { parent = null }
|
|
54
|
+
if (parentCache.size > 512) parentCache.clear()
|
|
55
|
+
parentCache.set(pid, { parent, at: Date.now() })
|
|
56
|
+
return parent
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A child's synchronous SessionEnd is spooled ~150 ms before the child exits, and the spawn
|
|
60
|
+
// ledger forgets the pid the moment it does; the sweep that reads the file can run after.
|
|
61
|
+
// So every pid the ledger ever vouched for is remembered here for a grace window.
|
|
62
|
+
const recentCosPids = new Map<number, number>()
|
|
63
|
+
export const COS_PID_TOMBSTONE_MS = 60_000
|
|
64
|
+
|
|
65
|
+
export function isCosSpawnedPid(pid: number | null, nowMs = Date.now()): boolean {
|
|
66
|
+
if (pid === null) return false
|
|
67
|
+
const spawned = cosSpawnedPids()
|
|
68
|
+
for (const p of spawned.keys()) recentCosPids.set(p, nowMs)
|
|
69
|
+
if (recentCosPids.size > 256) {
|
|
70
|
+
for (const [p, at] of recentCosPids) if (nowMs - at > COS_PID_TOMBSTONE_MS) recentCosPids.delete(p)
|
|
71
|
+
}
|
|
72
|
+
const remembered = (p: number) => { const at = recentCosPids.get(p); return at !== undefined && nowMs - at <= COS_PID_TOMBSTONE_MS }
|
|
73
|
+
if (spawned.has(pid) || remembered(pid)) return true
|
|
74
|
+
if (spawned.size === 0 && recentCosPids.size === 0) return false
|
|
75
|
+
const parent = parentPid(pid)
|
|
76
|
+
return parent !== null && (spawned.has(parent) || remembered(parent))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const sessionSignalStore = new SessionSignalStore({ isCosSpawnedPid })
|
|
80
|
+
|
|
81
|
+
let ledger: SessionHookLedger | null = null
|
|
82
|
+
let ingester: SpoolIngester | null = null
|
|
83
|
+
let replayed: { rows: number; applied: number } | null = null
|
|
84
|
+
/** The two-scan memory: consecutive dead observations and when the first one was. */
|
|
85
|
+
const deadById = new Map<string, { scans: number; since: number | null; at: number }>()
|
|
86
|
+
const DEAD_MEMORY_MS = 60 * 60_000
|
|
87
|
+
|
|
88
|
+
export interface SessionHooksRuntime {
|
|
89
|
+
store: SessionSignalStore
|
|
90
|
+
stop(): void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function startSessionHooksRuntime(options: { port: number }): SessionHooksRuntime {
|
|
94
|
+
const dir = spoolDir()
|
|
95
|
+
try { mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch { /* the ingester reports it */ }
|
|
96
|
+
ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
|
|
97
|
+
const enabled = sessionHooksEnabled()
|
|
98
|
+
const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
|
|
99
|
+
const replay = ledger.replay(since, (env, _key, child) => { if (enabled) sessionSignalStore.apply(env, child) })
|
|
100
|
+
replayed = { rows: replay.rows, applied: replay.applied }
|
|
101
|
+
ingester = startSpoolIngester({
|
|
102
|
+
dir,
|
|
103
|
+
ledger,
|
|
104
|
+
seenKeys: replay.keys,
|
|
105
|
+
isChild: env => isCosSpawnedPid(env.ppid),
|
|
106
|
+
apply: enabled ? (env, child) => { sessionSignalStore.apply(env, child) } : undefined,
|
|
107
|
+
})
|
|
108
|
+
// Runtime files the script reads. The port can change per install; the token never does.
|
|
109
|
+
// The script is copied only when MISSING here: a boot must never downgrade what a newer
|
|
110
|
+
// `--hooks install` put at the stable path.
|
|
111
|
+
try {
|
|
112
|
+
ensureHookRuntimeFiles(options.port, deskIdleSeconds())
|
|
113
|
+
ensureStableHookScript(undefined, undefined, true)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`[session-hooks] runtime files: ${error instanceof Error ? error.message : error}`)
|
|
116
|
+
}
|
|
117
|
+
const pruneTimer = setInterval(() => { sessionSignalStore.prune() }, 10 * 60_000)
|
|
118
|
+
pruneTimer.unref()
|
|
119
|
+
return {
|
|
120
|
+
store: sessionSignalStore,
|
|
121
|
+
stop() {
|
|
122
|
+
clearInterval(pruneTimer)
|
|
123
|
+
ingester?.stop()
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SessionHooksHealth {
|
|
129
|
+
enabled: boolean
|
|
130
|
+
/** The install state word; `installed` below is its boolean. */
|
|
131
|
+
state: HookStatus['state']
|
|
132
|
+
installed: boolean
|
|
133
|
+
scriptSha: string | null
|
|
134
|
+
tokenPresent: boolean
|
|
135
|
+
lastEventAt: string | null
|
|
136
|
+
spoolBacklog: number
|
|
137
|
+
spoolStuck: number
|
|
138
|
+
applyErrors: number
|
|
139
|
+
ledgerBytes: number
|
|
140
|
+
/** Error CODES only (ENOSPC, EACCES); a message would carry the home path onto public health. */
|
|
141
|
+
ledgerError: string | null
|
|
142
|
+
spoolError: string | null
|
|
143
|
+
signals: number
|
|
144
|
+
replayed: { rows: number; applied: number } | null
|
|
145
|
+
spool: SpoolStats | null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Health is polled by three clients every few seconds; the status read (settings parse,
|
|
149
|
+
// two hashes) is cheap but not free, and it cannot change faster than this.
|
|
150
|
+
let statusCache: { at: number; value: HookStatus } | null = null
|
|
151
|
+
const STATUS_CACHE_MS = 5_000
|
|
152
|
+
|
|
153
|
+
export function cachedHookStatus(nowMs = Date.now()): HookStatus {
|
|
154
|
+
if (statusCache && nowMs - statusCache.at < STATUS_CACHE_MS) return statusCache.value
|
|
155
|
+
const value = hookStatus()
|
|
156
|
+
statusCache = { at: nowMs, value }
|
|
157
|
+
return value
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function invalidateHookStatus(): void {
|
|
161
|
+
statusCache = null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth } {
|
|
165
|
+
const status = cachedHookStatus()
|
|
166
|
+
const spool = ingester?.stats() ?? null
|
|
167
|
+
const ledgerStats = ledger?.stats() ?? null
|
|
168
|
+
const newest = sessionSignalStore.newestEventAt()
|
|
169
|
+
return {
|
|
170
|
+
sessionHooks: {
|
|
171
|
+
enabled: sessionHooksEnabled(),
|
|
172
|
+
state: status.state,
|
|
173
|
+
installed: status.installed,
|
|
174
|
+
scriptSha: status.scriptSha,
|
|
175
|
+
tokenPresent: status.tokenPresent,
|
|
176
|
+
lastEventAt: newest ? new Date(newest).toISOString() : null,
|
|
177
|
+
spoolBacklog: spool?.backlog ?? 0,
|
|
178
|
+
spoolStuck: spool?.stuck ?? 0,
|
|
179
|
+
applyErrors: spool?.applyErrors ?? 0,
|
|
180
|
+
ledgerBytes: ledgerStats?.bytes ?? 0,
|
|
181
|
+
ledgerError: ledgerStats?.lastError ?? null,
|
|
182
|
+
spoolError: spool?.lastError ?? null,
|
|
183
|
+
signals: sessionSignalStore.size(),
|
|
184
|
+
replayed,
|
|
185
|
+
spool,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Signal for a row id that may be the registry's eight-character form. */
|
|
191
|
+
export function signalFor(sessionId: string): SessionSignal | undefined {
|
|
192
|
+
if (!sessionHooksEnabled()) return undefined
|
|
193
|
+
return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Derive with the two-scan memory kept here, keyed by full id when known. Rows the
|
|
198
|
+
* caller has no facts for at all get nothing, so an older payload stays byte-identical.
|
|
199
|
+
*/
|
|
200
|
+
export function deriveForRow(input: { sessionId: string; registry?: RegistryFacts; transcript?: TranscriptFacts; now?: number }): DerivedSessionState | undefined {
|
|
201
|
+
// Off means off: with the feature disabled every row is byte-identical to 6.47.0.
|
|
202
|
+
if (!sessionHooksEnabled()) return undefined
|
|
203
|
+
const signal = signalFor(input.sessionId)
|
|
204
|
+
if (!signal && !input.registry && !input.transcript) return undefined
|
|
205
|
+
const now = input.now ?? Date.now()
|
|
206
|
+
const key = signal?.sessionId ?? input.sessionId.toLowerCase()
|
|
207
|
+
const prev = deadById.get(key)
|
|
208
|
+
const derived = deriveSessionState({
|
|
209
|
+
signal,
|
|
210
|
+
registry: input.registry,
|
|
211
|
+
transcript: input.transcript,
|
|
212
|
+
now,
|
|
213
|
+
prevDeadScans: prev?.scans ?? 0,
|
|
214
|
+
prevDeadSince: prev?.since ?? null,
|
|
215
|
+
})
|
|
216
|
+
if (deadById.size > 2_048) {
|
|
217
|
+
for (const [k, v] of deadById) if (now - v.at > DEAD_MEMORY_MS) deadById.delete(k)
|
|
218
|
+
}
|
|
219
|
+
deadById.set(key, { scans: derived.deadScans, since: derived.deadSince, at: now })
|
|
220
|
+
return derived
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function __resetSessionHooksForTests(): void {
|
|
224
|
+
sessionSignalStore.__resetForTests()
|
|
225
|
+
deadById.clear()
|
|
226
|
+
parentCache.clear()
|
|
227
|
+
recentCosPids.clear()
|
|
228
|
+
statusCache = null
|
|
229
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// The signal store: what the hooks have said about each Claude session, reduced to
|
|
2
|
+
// one record per full session id.
|
|
3
|
+
//
|
|
4
|
+
// POSITIVE SIGNALS ONLY. Before 6.48.0 every state on a session row was inferred: a
|
|
5
|
+
// registry pid that answers signal 0, a transcript mtime inside a window, a tool_use
|
|
6
|
+
// block with no result behind it. This store holds what the engine itself announced
|
|
7
|
+
// through its hooks (session started, prompt submitted, permission wanted, turn stopped,
|
|
8
|
+
// session ended), and `session-state-derive.ts` ranks it above every inference.
|
|
9
|
+
//
|
|
10
|
+
// The reducer is PURE (`applyHookEvent`), so a recorded sequence of envelopes replays
|
|
11
|
+
// into a deterministic record and the fixtures under `__fixtures__/session-hooks-6.48.0`
|
|
12
|
+
// are the specification. Three rules that came out of the validation rounds:
|
|
13
|
+
//
|
|
14
|
+
// 1. A `waiting` entry clears only on RESOLUTION EVIDENCE for that request: the
|
|
15
|
+
// matching PostToolUse/PostToolUseFailure/PermissionDenied (same tool name + input
|
|
16
|
+
// fingerprint), or a turn boundary (Stop, StopFailure, UserPromptSubmit, SessionEnd, a
|
|
17
|
+
// non-compact SessionStart), or the broker's own decision. A parallel auto-allowed
|
|
18
|
+
// Read or a sub-agent's tool must not clear a real prompt. The deriver adds two more
|
|
19
|
+
// clearers it can see and this store cannot: a registry status that moved after the
|
|
20
|
+
// wait, and transcript activity newer than the wait.
|
|
21
|
+
// 2. A COS-spawned Continue child (`claude -p --resume <id>`) shares the Desktop tab's
|
|
22
|
+
// session id and fires its own SessionStart/Stop/SessionEnd. Events whose spooled
|
|
23
|
+
// ppid is a COS spawn are recorded as `child*` counters and never touch the phase.
|
|
24
|
+
// 3. `ended` is recorded here but RANKED in the deriver, which also sees the registry:
|
|
25
|
+
// an ended signal with an alive registry record is a child that ended, not the tab.
|
|
26
|
+
|
|
27
|
+
import type { HookEnvelope, HookEventName } from './session-hook-events.js'
|
|
28
|
+
import { clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
|
|
29
|
+
import { isKeepWarmSessionTitle } from './agent-session-store.js'
|
|
30
|
+
|
|
31
|
+
export type WaitingKind = 'permission' | 'question' | 'plan' | 'mcp_input'
|
|
32
|
+
|
|
33
|
+
export interface WaitingSignal {
|
|
34
|
+
kind: WaitingKind
|
|
35
|
+
/** "Bash git push", "Use the API error text in the form?", "" */
|
|
36
|
+
detail: string
|
|
37
|
+
toolName: string
|
|
38
|
+
/** sha256(tool_name + canonical tool_input); '' when the event carried no tool. */
|
|
39
|
+
fingerprint: string
|
|
40
|
+
since: number
|
|
41
|
+
/** The broker's minted id when a permission request is pending there. */
|
|
42
|
+
requestId: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FailureSignal {
|
|
46
|
+
kind: string
|
|
47
|
+
at: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SessionSignal {
|
|
51
|
+
sessionId: string
|
|
52
|
+
firstSeenAt: number
|
|
53
|
+
lastEventAt: number
|
|
54
|
+
lastEvent: HookEventName
|
|
55
|
+
cwd: string | null
|
|
56
|
+
transcriptPath: string | null
|
|
57
|
+
permissionMode: string | null
|
|
58
|
+
model: string | null
|
|
59
|
+
/** A prompt was submitted and no Stop/StopFailure/SessionEnd has closed it. */
|
|
60
|
+
turnOpen: boolean
|
|
61
|
+
turnStartedAt: number | null
|
|
62
|
+
promptId: string | null
|
|
63
|
+
/** Stamped by the newest Stop: `state_since` for an idle row. (6.48.1's B6 occupancy clause will compare it to the transcript.) */
|
|
64
|
+
stopAt: number | null
|
|
65
|
+
waiting: WaitingSignal | null
|
|
66
|
+
failure: FailureSignal | null
|
|
67
|
+
lastReply: string
|
|
68
|
+
lastTool: string | null
|
|
69
|
+
lastToolAt: number | null
|
|
70
|
+
subagentsOpen: number
|
|
71
|
+
ended: { at: number; reason: string } | null
|
|
72
|
+
/** The prompt prefix Control already suppresses as a readiness check. */
|
|
73
|
+
keepWarm: boolean
|
|
74
|
+
compactions: number
|
|
75
|
+
/** Events from a COS-spawned child on this id, kept out of the phase. */
|
|
76
|
+
childEvents: number
|
|
77
|
+
/** Hooks have been seen for this session: the row may say `state_source: hook`. */
|
|
78
|
+
hooksSeen: true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ReducerContext {
|
|
82
|
+
/** Is this pid (or its parent) a process COS spawned itself? Consulted only when the
|
|
83
|
+
* envelope was not already classified at ingest (`child`). */
|
|
84
|
+
isCosSpawnedPid: (pid: number | null) => boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The tool fields the reducer reads: the live payload's, or the ledger's projection on replay. */
|
|
88
|
+
function toolFacts(p: Record<string, unknown>): { name: string; target: string; fingerprint: string } {
|
|
89
|
+
const name = typeof p.tool_name === 'string' ? p.tool_name : ''
|
|
90
|
+
const projectedTarget = typeof p.tool_target === 'string' ? p.tool_target : null
|
|
91
|
+
const projectedFingerprint = typeof p.tool_fingerprint === 'string' ? p.tool_fingerprint : null
|
|
92
|
+
return {
|
|
93
|
+
name,
|
|
94
|
+
target: projectedTarget ?? toolTarget(p.tool_input),
|
|
95
|
+
fingerprint: projectedFingerprint ?? toolFingerprint(name, p.tool_input ?? null),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function fresh(env: HookEnvelope): SessionSignal {
|
|
100
|
+
return {
|
|
101
|
+
sessionId: env.sessionId,
|
|
102
|
+
firstSeenAt: env.ts,
|
|
103
|
+
lastEventAt: env.ts,
|
|
104
|
+
lastEvent: env.event,
|
|
105
|
+
cwd: null,
|
|
106
|
+
transcriptPath: null,
|
|
107
|
+
permissionMode: null,
|
|
108
|
+
model: null,
|
|
109
|
+
turnOpen: false,
|
|
110
|
+
turnStartedAt: null,
|
|
111
|
+
promptId: null,
|
|
112
|
+
stopAt: null,
|
|
113
|
+
waiting: null,
|
|
114
|
+
failure: null,
|
|
115
|
+
lastReply: '',
|
|
116
|
+
lastTool: null,
|
|
117
|
+
lastToolAt: null,
|
|
118
|
+
subagentsOpen: 0,
|
|
119
|
+
ended: null,
|
|
120
|
+
keepWarm: false,
|
|
121
|
+
compactions: 0,
|
|
122
|
+
childEvents: 0,
|
|
123
|
+
hooksSeen: true,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
|
|
128
|
+
|
|
129
|
+
/** Waiting kinds a PostToolUse of the same tool resolves without a fingerprint match. */
|
|
130
|
+
const TOOL_WAITING: Record<string, WaitingKind> = { AskUserQuestion: 'question', ExitPlanMode: 'plan' }
|
|
131
|
+
|
|
132
|
+
function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName: string, fingerprint: string): boolean {
|
|
133
|
+
if (waiting.fingerprint && waiting.fingerprint === fingerprint) return true
|
|
134
|
+
// Question/plan tools carry no meaningful input to fingerprint; the tool name is the key.
|
|
135
|
+
if (waiting.kind !== 'permission') return waiting.toolName === toolName
|
|
136
|
+
// A permission dialog is one at a time (canary 11: hooks run serially), and only a
|
|
137
|
+
// denied PROMPT fires PermissionDenied, so a denial naming the waiting tool is that
|
|
138
|
+
// prompt's denial even when the dialog rewrote the input. PostToolUse of the same name
|
|
139
|
+
// is not: a parallel auto-allowed Bash must not clear a prompt that still stands.
|
|
140
|
+
return event === 'PermissionDenied' && waiting.toolName === toolName
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Apply one envelope. Returns a NEW record; the previous one is never mutated, so a
|
|
145
|
+
* subscriber holding the old value sees a consistent snapshot.
|
|
146
|
+
*/
|
|
147
|
+
export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
|
|
148
|
+
const base = prev ? { ...prev } : fresh(env)
|
|
149
|
+
const p = env.payload
|
|
150
|
+
const common = {
|
|
151
|
+
cwd: str(p.cwd) ?? base.cwd,
|
|
152
|
+
transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
|
|
153
|
+
permissionMode: str(p.permission_mode) ?? base.permissionMode,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Rule 2: a COS-spawned child on this session id is counted, never applied. The verdict
|
|
157
|
+
// is taken at ingest (and remembered on the ledger row) because the spawn ledger forgets
|
|
158
|
+
// a pid the moment the child exits.
|
|
159
|
+
if (child ?? ctx.isCosSpawnedPid(env.ppid)) {
|
|
160
|
+
return { ...base, ...common, childEvents: base.childEvents + 1 }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const next: SessionSignal = { ...base, ...common, lastEventAt: Math.max(base.lastEventAt, env.ts), lastEvent: env.event }
|
|
164
|
+
|
|
165
|
+
switch (env.event) {
|
|
166
|
+
case 'SessionStart': {
|
|
167
|
+
const source = str(p.source) ?? 'startup'
|
|
168
|
+
next.model = str(p.model) ?? next.model
|
|
169
|
+
if (source === 'compact') { next.compactions += 1; return next }
|
|
170
|
+
// startup | resume | clear | fork: a fresh conversation surface, nothing in flight.
|
|
171
|
+
return { ...next, turnOpen: false, turnStartedAt: null, waiting: null, failure: null, ended: null, subagentsOpen: 0 }
|
|
172
|
+
}
|
|
173
|
+
case 'UserPromptSubmit': {
|
|
174
|
+
const prompt = clipText(p.prompt)
|
|
175
|
+
return {
|
|
176
|
+
...next,
|
|
177
|
+
turnOpen: true,
|
|
178
|
+
turnStartedAt: env.ts,
|
|
179
|
+
promptId: str(p.prompt_id),
|
|
180
|
+
waiting: null,
|
|
181
|
+
failure: null,
|
|
182
|
+
// The same predicate the session list uses to hide readiness checks.
|
|
183
|
+
keepWarm: prompt.length > 0 && isKeepWarmSessionTitle(prompt),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
case 'PreToolUse': {
|
|
187
|
+
const tool = toolFacts(p)
|
|
188
|
+
const kind = TOOL_WAITING[tool.name]
|
|
189
|
+
if (kind) {
|
|
190
|
+
return {
|
|
191
|
+
...next,
|
|
192
|
+
turnOpen: true,
|
|
193
|
+
waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: null },
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { ...next, lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
|
|
197
|
+
}
|
|
198
|
+
case 'PermissionRequest': {
|
|
199
|
+
const tool = toolFacts(p)
|
|
200
|
+
return {
|
|
201
|
+
...next,
|
|
202
|
+
turnOpen: true,
|
|
203
|
+
waiting: {
|
|
204
|
+
kind: 'permission',
|
|
205
|
+
detail: tool.target ? `${tool.name} ${tool.target}` : tool.name,
|
|
206
|
+
toolName: tool.name,
|
|
207
|
+
fingerprint: tool.fingerprint,
|
|
208
|
+
since: env.ts,
|
|
209
|
+
requestId: null,
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
case 'PermissionDenied':
|
|
214
|
+
case 'PostToolUse':
|
|
215
|
+
case 'PostToolUseFailure': {
|
|
216
|
+
const tool = toolFacts(p)
|
|
217
|
+
const waiting = next.waiting && resolvesWaiting(next.waiting, env.event, tool.name, tool.fingerprint) ? null : next.waiting
|
|
218
|
+
const ran = env.event !== 'PermissionDenied'
|
|
219
|
+
return { ...next, waiting, ...(ran ? { lastTool: tool.name || next.lastTool, lastToolAt: env.ts } : {}) }
|
|
220
|
+
}
|
|
221
|
+
case 'Notification': {
|
|
222
|
+
const type = str(p.notification_type) ?? ''
|
|
223
|
+
if (type === 'idle_prompt') return { ...next, turnOpen: false }
|
|
224
|
+
if (next.waiting) return next // a dialog already owns the attention; never downgrade its kind
|
|
225
|
+
const message = clipText(p.message, 120)
|
|
226
|
+
const kind: WaitingKind | null =
|
|
227
|
+
type === 'permission_prompt' ? 'permission'
|
|
228
|
+
: type === 'agent_needs_input' ? 'question'
|
|
229
|
+
: type === 'elicitation_dialog' ? 'mcp_input'
|
|
230
|
+
: null
|
|
231
|
+
if (!kind) return next
|
|
232
|
+
return { ...next, turnOpen: true, waiting: { kind, detail: message, toolName: '', fingerprint: '', since: env.ts, requestId: null } }
|
|
233
|
+
}
|
|
234
|
+
case 'Stop':
|
|
235
|
+
return {
|
|
236
|
+
...next,
|
|
237
|
+
turnOpen: false,
|
|
238
|
+
stopAt: env.ts,
|
|
239
|
+
waiting: null,
|
|
240
|
+
lastReply: clipText(p.last_assistant_message) || next.lastReply,
|
|
241
|
+
}
|
|
242
|
+
case 'StopFailure':
|
|
243
|
+
return {
|
|
244
|
+
...next,
|
|
245
|
+
turnOpen: false,
|
|
246
|
+
waiting: null,
|
|
247
|
+
failure: { kind: str(p.matcher) ?? str(p.error_type) ?? (clipText(p.error, 60) || 'unknown'), at: env.ts },
|
|
248
|
+
}
|
|
249
|
+
case 'SubagentStart':
|
|
250
|
+
return { ...next, subagentsOpen: next.subagentsOpen + 1 }
|
|
251
|
+
case 'SubagentStop':
|
|
252
|
+
return { ...next, subagentsOpen: Math.max(0, next.subagentsOpen - 1) }
|
|
253
|
+
case 'PostCompact':
|
|
254
|
+
return { ...next, compactions: next.compactions + 1 }
|
|
255
|
+
case 'PostModelSwitch':
|
|
256
|
+
return { ...next, model: str(p.to_model) ?? next.model }
|
|
257
|
+
case 'SessionEnd':
|
|
258
|
+
return { ...next, turnOpen: false, waiting: null, ended: { at: env.ts, reason: str(p.reason) ?? 'other' } }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type SignalListener = (signal: SessionSignal, env: HookEnvelope) => void
|
|
263
|
+
|
|
264
|
+
/** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
|
|
265
|
+
export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
|
|
266
|
+
/** A record with no event at all for this long is a tab that died without a SessionEnd. */
|
|
267
|
+
export const SIGNAL_PRUNE_SILENT_MS = 24 * 60 * 60_000
|
|
268
|
+
|
|
269
|
+
export class SessionSignalStore {
|
|
270
|
+
private readonly signals = new Map<string, SessionSignal>()
|
|
271
|
+
private readonly listeners = new Set<SignalListener>()
|
|
272
|
+
private readonly ctx: ReducerContext
|
|
273
|
+
|
|
274
|
+
constructor(ctx: ReducerContext) {
|
|
275
|
+
this.ctx = ctx
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
apply(env: HookEnvelope, child?: boolean): SessionSignal {
|
|
279
|
+
const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
|
|
280
|
+
this.signals.set(env.sessionId, next)
|
|
281
|
+
for (const listener of this.listeners) {
|
|
282
|
+
try { listener(next, env) } catch (error) {
|
|
283
|
+
console.error(`[session-signals] listener failed: ${error instanceof Error ? error.message : error}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return next
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. Attach the broker's
|
|
290
|
+
* minted id so rows can carry `pending_permission_id`. */
|
|
291
|
+
attachPermissionRequestId(sessionId: string, requestId: string | null): void {
|
|
292
|
+
const current = this.signals.get(sessionId)
|
|
293
|
+
if (!current?.waiting || current.waiting.kind !== 'permission') return
|
|
294
|
+
this.signals.set(sessionId, { ...current, waiting: { ...current.waiting, requestId } })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. The broker decided
|
|
298
|
+
* (allow or deny): that is resolution evidence. */
|
|
299
|
+
resolveWaiting(sessionId: string): void {
|
|
300
|
+
const current = this.signals.get(sessionId)
|
|
301
|
+
if (!current?.waiting) return
|
|
302
|
+
this.signals.set(sessionId, { ...current, waiting: null })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
get(sessionId: string): SessionSignal | undefined {
|
|
306
|
+
return this.signals.get(sessionId.toLowerCase())
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Eight-char prefix lookup for the registry's short ids. Two matches means no answer. */
|
|
310
|
+
getByPrefix(prefix: string): SessionSignal | undefined {
|
|
311
|
+
const needle = prefix.toLowerCase()
|
|
312
|
+
let found: SessionSignal | undefined
|
|
313
|
+
for (const [id, signal] of this.signals) {
|
|
314
|
+
if (!id.startsWith(needle)) continue
|
|
315
|
+
if (found) return undefined
|
|
316
|
+
found = signal
|
|
317
|
+
}
|
|
318
|
+
return found
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
subscribe(listener: SignalListener): () => void {
|
|
322
|
+
this.listeners.add(listener)
|
|
323
|
+
return () => { this.listeners.delete(listener) }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Drop records that ended long ago, and records silent for a day (a tab that died with no SessionEnd). */
|
|
327
|
+
prune(nowMs = Date.now()): number {
|
|
328
|
+
let dropped = 0
|
|
329
|
+
for (const [id, signal] of this.signals) {
|
|
330
|
+
const endedLongAgo = !!signal.ended && !signal.turnOpen && nowMs - signal.ended.at > SIGNAL_PRUNE_AFTER_END_MS
|
|
331
|
+
const silentForADay = nowMs - signal.lastEventAt > SIGNAL_PRUNE_SILENT_MS
|
|
332
|
+
if (endedLongAgo || silentForADay) {
|
|
333
|
+
this.signals.delete(id)
|
|
334
|
+
dropped++
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return dropped
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
size(): number {
|
|
341
|
+
return this.signals.size
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Every record, newest event first. A copy: callers cannot reach the map. */
|
|
345
|
+
snapshot(): SessionSignal[] {
|
|
346
|
+
return [...this.signals.values()].sort((a, b) => b.lastEventAt - a.lastEventAt)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
newestEventAt(): number | null {
|
|
350
|
+
let newest: number | null = null
|
|
351
|
+
for (const signal of this.signals.values()) {
|
|
352
|
+
if (newest === null || signal.lastEventAt > newest) newest = signal.lastEventAt
|
|
353
|
+
}
|
|
354
|
+
return newest
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
__resetForTests(): void {
|
|
358
|
+
this.signals.clear()
|
|
359
|
+
this.listeners.clear()
|
|
360
|
+
}
|
|
361
|
+
}
|