@gotcos/glasses-server 6.51.0 → 6.52.3

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.
Files changed (34) hide show
  1. package/.env.example +16 -0
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +56 -3
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +58 -9
  7. package/server/lib/activity-preview.ts +45 -2
  8. package/server/lib/agent-session-search.ts +3 -1
  9. package/server/lib/agent-session-store.ts +35 -5
  10. package/server/lib/agent-session-turns.ts +4 -1
  11. package/server/lib/claude-bridge.ts +9 -0
  12. package/server/lib/client-instance-claim.ts +22 -1
  13. package/server/lib/client-liveness.ts +50 -0
  14. package/server/lib/codex-bridge.ts +6 -0
  15. package/server/lib/cursor-bridge.ts +6 -0
  16. package/server/lib/job-trail.ts +758 -0
  17. package/server/lib/ollama-bridge.ts +17 -0
  18. package/server/lib/permission-broker.ts +1156 -0
  19. package/server/lib/query-job-coordinator.ts +17 -1
  20. package/server/lib/query-job-feature.ts +10 -0
  21. package/server/lib/query-job-runtime.ts +9 -1
  22. package/server/lib/query-job-store.ts +214 -5
  23. package/server/lib/query-job-types.ts +36 -0
  24. package/server/lib/session-signal-store.ts +42 -10
  25. package/server/lib/session-state-derive.ts +14 -4
  26. package/server/lib/session-stream-events.ts +629 -18
  27. package/server/lib/session-stream-producer.ts +25 -6
  28. package/server/lib/session-transcript-watcher.ts +120 -3
  29. package/server/routes/agent-sessions.ts +12 -1
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +14 -7
  32. package/server/routes/health.ts +24 -1
  33. package/server/routes/permission-broker.ts +176 -0
  34. package/server/routes/query-jobs.ts +84 -13
@@ -0,0 +1,1156 @@
1
+ // The permission broker (6.52.0): a Claude session's question card (AskUserQuestion) or
2
+ // tool approval, answered from the COS glasses or the phone while Miles is away from the
3
+ // Mac, and handed straight back to the Mac's own dialog the moment he returns to it.
4
+ //
5
+ // ---------------------------------------------------------------------------
6
+ // THE MECHANISM (canaries, Claude Code 2.1.278, 2026-09-19)
7
+ // ---------------------------------------------------------------------------
8
+ // Claude Code runs a PermissionRequest hook before it shows a permission dialog, and waits
9
+ // for it (the installer gives it 130 s; the script's curl gives up at 125 s). The hook may
10
+ // print a decision:
11
+ // - AskUserQuestion: `allow` with `updatedInput` = the original input plus `answers`, a
12
+ // map from each question's ORIGINAL text to a LIST of labels (Desktop's own shape; an
13
+ // "Other" answer is its own entry, verbatim). The model then reads "The user answered".
14
+ // - any other tool: `allow` runs it; `deny` with a message blocks it (`is_error`).
15
+ // No decision (`{}`, or anything the script does not recognise) means the native dialog,
16
+ // exactly as if no hook existed. Every path here that is not an answer ends in `{}`.
17
+ //
18
+ // ---------------------------------------------------------------------------
19
+ // WHAT GETS PARKED (Miles's Q3 and Q4, 2026-09-19): away AND wearing
20
+ // ---------------------------------------------------------------------------
21
+ // The hook has no matcher, so every permission request at an idle desk reaches the route.
22
+ // Everything outside the gate answers `{}` at once (target: under 100 ms). A request is
23
+ // parked only when ALL of these hold:
24
+ // - the broker is on: `COS_PERMISSION_BROKER` is not `0`/`false`/`off`, and the session
25
+ // hooks are on (the rows and the retraction ride them);
26
+ // - no maintenance drain is under way;
27
+ // - it is a Claude envelope: a Cursor payload (`cursor_version`) is never brokered;
28
+ // - the tool is AskUserQuestion, or approvals are on (`COS_PERMISSION_BROKER=questions`
29
+ // turns approvals off; the default brokers both). ExitPlanMode is never brokered: its
30
+ // dialog chooses a permission MODE, and "allow once" is not a plan approval;
31
+ // - a client that can answer asked for the questions in the last 30 s: an authenticated
32
+ // `GET /api/session-questions?client=glasses|phone` (lib/client-liveness). An app without
33
+ // the question UI never calls it, so for it the broker is inert and every request is `{}`;
34
+ // - the desk has been idle `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30),
35
+ // read here with a bounded `ioreg`, not trusted from the script.
36
+ // The cheap gates run first and the desk read last; the question card or the approval card
37
+ // is built only for a request that will be parked (QA round 1: a 900 KB Write input costs a
38
+ // no_client request nothing).
39
+ //
40
+ // A parked item holds the hook's response open until EXACTLY ONE of these resolves it:
41
+ // an answer from the lens or phone; a hand-back from either ("Leave for the Mac"): `{}`;
42
+ // the desk becoming active (polled every 1.5 s, and two unreadable reads in a row count as
43
+ // active): `{}`; the answering client going quiet for 30 s: `{}`; the deadline
44
+ // (`COS_PERMISSION_BROKER_TIMEOUT_S`, default 110, clamped to 120 so it lands before the
45
+ // hook's 125 s): `{}`; the hook hanging up: `hook_gone`; the session
46
+ // moving on without us (its tool ran, was denied, or the turn ended): `{}`; a drain: `{}`.
47
+ //
48
+ // NEVER: a permission RULE. `updatedPermissions` and the request's suggestions are never
49
+ // returned; the decision objects below are built from nothing but constants and the
50
+ // original input. Question and command text is never logged and never goes on the display
51
+ // bus; the client API is behind the API token. A settled item keeps its codes and what was
52
+ // chosen, never the questions or the card.
53
+
54
+ import { execFile } from 'node:child_process'
55
+ import { randomBytes } from 'node:crypto'
56
+ import { boundForRedaction } from './activity-preview.js'
57
+ import { toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
58
+ import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signal-store.js'
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Configuration
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export type BrokerMode = 'off' | 'questions' | 'all'
65
+
66
+ /**
67
+ * `COS_PERMISSION_BROKER`: `0`/`false`/`off` answers `{}` to everything (the kill switch,
68
+ * no reinstall needed); `questions` brokers AskUserQuestion only; unset or anything else
69
+ * brokers questions AND approvals (Miles's Q4). Off whenever the session hooks are off.
70
+ */
71
+ export function permissionBrokerMode(env: NodeJS.ProcessEnv, sessionHooksOn: boolean): BrokerMode {
72
+ const raw = (env.COS_PERMISSION_BROKER ?? '').trim().toLowerCase()
73
+ if (raw === '0' || raw === 'false' || raw === 'off') return 'off'
74
+ if (!sessionHooksOn) return 'off'
75
+ return raw === 'questions' ? 'questions' : 'all'
76
+ }
77
+
78
+ export const BROKER_TIMEOUT_DEFAULT_S = 110
79
+ /** The hook script's curl gives up at `--max-time 125`. */
80
+ export const HOOK_CURL_MAX_S = 125
81
+ /** The deadline is never at or above the hook's own wait: five seconds of margin. */
82
+ export const BROKER_TIMEOUT_MAX_S = 120
83
+ export const BROKER_TIMEOUT_MIN_S = 5
84
+
85
+ /** `COS_PERMISSION_BROKER_TIMEOUT_S`, default 110, clamped to [5, 120], in ms. */
86
+ export function permissionBrokerTimeoutMs(env: NodeJS.ProcessEnv): number {
87
+ const raw = Number(env.COS_PERMISSION_BROKER_TIMEOUT_S)
88
+ const seconds = Number.isFinite(raw) && raw > 0 ? raw : BROKER_TIMEOUT_DEFAULT_S
89
+ return Math.round(Math.min(BROKER_TIMEOUT_MAX_S, Math.max(BROKER_TIMEOUT_MIN_S, seconds)) * 1000)
90
+ }
91
+
92
+ /**
93
+ * A client must have polled `GET /api/session-questions?client=glasses|phone` this recently,
94
+ * at admission AND while anything is held (QA round 1: 60 s let a phone that locked hold a
95
+ * question nobody could see for a minute). Three polls at the advertised interval.
96
+ */
97
+ export const CLIENT_LIVE_WINDOW_MS = 30_000
98
+ /** The poll interval the questions route advertises to clients. */
99
+ export const QUESTIONS_POLL_INTERVAL_MS = 10_000
100
+ /** A poll stamp this far AHEAD of the server clock (the clock stepped back) is not trusted. */
101
+ export const POLL_FUTURE_SKEW_MS = 5_000
102
+ /** The `client` values of a questions poll that count as a live answerer. */
103
+ export const ANSWERING_CLIENTS: ReadonlySet<string> = new Set(['glasses', 'phone'])
104
+ /** The questions API's own version, advertised at /api/models. */
105
+ export const SESSION_QUESTIONS_PROTOCOL_VERSION = 1
106
+ /** How often a parked item re-reads the desk. */
107
+ export const DESK_POLL_MS = 1_500
108
+ /** Never hold a request for a desk idle less than this, whatever the setting says. */
109
+ export const DESK_IDLE_MIN_S = 30
110
+ /** Parked items at once; past it, `{}`. Checked again after the desk read. */
111
+ export const MAX_PENDING = 32
112
+ /** A settled item is kept this long so a late answer gets the right code and a retry its replay. */
113
+ export const SETTLED_RETENTION_MS = 10 * 60_000
114
+ export const MAX_SETTLED = 256
115
+ /** Free text for "Other". */
116
+ export const OTHER_TEXT_MAX = 2_000
117
+ /** The approval card's short line: the command (or path, URL, query) as the lens shows it. */
118
+ export const APPROVAL_SUMMARY_MAX = 160
119
+ /** The full command, one page back on the lens. */
120
+ export const APPROVAL_DETAIL_MAX = 4_000
121
+ export const DENY_MESSAGE = 'Denied from COS glasses'
122
+ /** Tools whose dialog is not a yes/no: never brokered. */
123
+ export const UNBROKERED_TOOLS: ReadonlySet<string> = new Set(['ExitPlanMode'])
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // The desk
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Seconds since the last keyboard, mouse or trackpad input on this Mac, or null when it
131
+ * cannot be read. One bounded `ioreg` (about 30 ms; `-r -d 1 -k` keeps the output to a
132
+ * few KB), the same counter the hook script reads.
133
+ */
134
+ export function readHidIdleSeconds(): Promise<number | null> {
135
+ return new Promise(resolve => {
136
+ try {
137
+ execFile('/usr/sbin/ioreg', ['-r', '-d', '1', '-k', 'HIDIdleTime', '-c', 'IOHIDSystem'], { timeout: 1_000, maxBuffer: 1 << 20 }, (error, stdout) => {
138
+ if (error) return resolve(null)
139
+ const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(String(stdout))
140
+ const ns = match ? Number(match[1]) : Number.NaN
141
+ resolve(Number.isFinite(ns) ? Math.floor(ns / 1_000_000_000) : null)
142
+ })
143
+ } catch {
144
+ resolve(null)
145
+ }
146
+ })
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Reading the hook's envelope
151
+ // ---------------------------------------------------------------------------
152
+
153
+ export interface PermissionRequestFacts {
154
+ sessionId: string
155
+ toolName: string
156
+ /** Verbatim. The answer is built on top of this object, never a copy of the display. */
157
+ toolInput: Record<string, unknown>
158
+ /** The reducer's fingerprint of the same request (tool name + canonical input). */
159
+ fingerprint: string
160
+ /** The envelope's `ts`: when the hook started, epoch ms from the same Mac clock. */
161
+ hookStartedAtMs: number | null
162
+ }
163
+
164
+ export type EnvelopeVerdict =
165
+ | { ok: true; facts: PermissionRequestFacts }
166
+ | { ok: false; reason: 'malformed' | 'cursor' }
167
+
168
+ function isRecord(value: unknown): value is Record<string, unknown> {
169
+ return !!value && typeof value === 'object' && !Array.isArray(value)
170
+ }
171
+
172
+ type EnvelopeRead =
173
+ | { ok: true; facts: Omit<PermissionRequestFacts, 'fingerprint'> }
174
+ | { ok: false; reason: 'malformed' | 'cursor' }
175
+
176
+ /** The envelope's structure only: no hashing, no regex over the input. */
177
+ function readPermissionRequestEnvelope(body: unknown): EnvelopeRead {
178
+ if (!isRecord(body) || body.event !== 'PermissionRequest' || !isRecord(body.payload)) return { ok: false, reason: 'malformed' }
179
+ const p = body.payload
180
+ if ('cursor_version' in p) return { ok: false, reason: 'cursor' }
181
+ if (typeof p.session_id !== 'string' || !SESSION_ID_RE.test(p.session_id)) return { ok: false, reason: 'malformed' }
182
+ if (typeof p.tool_name !== 'string' || p.tool_name.trim().length === 0) return { ok: false, reason: 'malformed' }
183
+ if (!isRecord(p.tool_input)) return { ok: false, reason: 'malformed' }
184
+ const ts = typeof body.ts === 'number' && Number.isFinite(body.ts) ? body.ts : null
185
+ return {
186
+ ok: true,
187
+ facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts },
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The spool envelope the hook posts (`{"ts","ppid","event","payload"}`), read strictly.
193
+ * Cursor stamps every hook payload with `cursor_version` and Claude Code never does, so
194
+ * the key's presence, in any form, is a Cursor request. The broker itself reads the
195
+ * structure first and fingerprints only a request it will park.
196
+ */
197
+ export function parsePermissionRequestEnvelope(body: unknown): EnvelopeVerdict {
198
+ const read = readPermissionRequestEnvelope(body)
199
+ if (!read.ok) return read
200
+ return { ok: true, facts: { ...read.facts, fingerprint: toolFingerprint(read.facts.toolName, read.facts.toolInput) } }
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Questions
205
+ // ---------------------------------------------------------------------------
206
+
207
+ export interface BrokerQuestionOption { label: string; description: string }
208
+ export interface BrokerQuestion { question: string; header: string; multiSelect: boolean; options: BrokerQuestionOption[] }
209
+
210
+ export const MAX_QUESTIONS = 8
211
+ export const MAX_OPTIONS = 16
212
+
213
+ /**
214
+ * The AskUserQuestion input, read strictly; null when the card cannot be answered from
215
+ * here (no questions, an option without a label, two questions with the same text:
216
+ * answers are keyed by the text, so two alike could never both be answered; or two options
217
+ * of one question with the same label: an answer names an option by its label, so the two
218
+ * could never be told apart, and picking both is refused as a duplicate). Null is the
219
+ * `unsupported_input` fast path: `{}` and the Mac's own dialog, never a held card.
220
+ */
221
+ export function parseQuestions(toolInput: Record<string, unknown>): BrokerQuestion[] | null {
222
+ const raw = toolInput.questions
223
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_QUESTIONS) return null
224
+ const seen = new Set<string>()
225
+ const questions: BrokerQuestion[] = []
226
+ for (const q of raw) {
227
+ if (!isRecord(q) || typeof q.question !== 'string' || q.question.trim().length === 0) return null
228
+ if (seen.has(q.question)) return null
229
+ seen.add(q.question)
230
+ if (!Array.isArray(q.options) || q.options.length === 0 || q.options.length > MAX_OPTIONS) return null
231
+ const options: BrokerQuestionOption[] = []
232
+ const labels = new Set<string>()
233
+ for (const o of q.options) {
234
+ if (!isRecord(o) || typeof o.label !== 'string' || o.label.length === 0) return null
235
+ if (labels.has(o.label)) return null
236
+ labels.add(o.label)
237
+ options.push({ label: o.label, description: typeof o.description === 'string' ? o.description : '' })
238
+ }
239
+ questions.push({ question: q.question, header: typeof q.header === 'string' ? q.header : '', multiSelect: q.multiSelect === true, options })
240
+ }
241
+ return questions
242
+ }
243
+
244
+ /**
245
+ * The one change made to "Other" free text, measured (canary, 2026-09-19): Claude Code
246
+ * joins a list answer with commas in the text the model reads, so `["Red","Blue","Purple,
247
+ * but only on weekends"]` reached the model as `Red,Blue,Purple, but only on weekends` and
248
+ * the model dropped "but only on weekends". Free text that contains a comma is wrapped in
249
+ * double quotes, which keeps it one answer in that text. Option labels are never touched.
250
+ */
251
+ export function quoteFreeTextAnswer(text: string): string {
252
+ return text.includes(',') ? `"${text}"` : text
253
+ }
254
+
255
+ export type AnswerValidation =
256
+ | { ok: true; answers: Record<string, string[]> }
257
+ | { ok: false; reason: 'answers_shape' | 'unanswered' | 'unknown_label' | 'duplicate_label' | 'too_many' | 'other_too_long'; index?: number }
258
+
259
+ /**
260
+ * A client's answers, one entry per question in order: `{ labels?: string[], other?: string }`.
261
+ * Every question must be answered. Labels must match one of that question's options
262
+ * EXACTLY (the client shows a sanitized copy and sends the original back). `other` is the
263
+ * free text of the card's "Other" choice, sent as its own entry. Single-select takes
264
+ * exactly one entry. The result is keyed by each question's ORIGINAL text, labels in option
265
+ * order, "Other" last: the shape Claude Desktop sends.
266
+ * Free text goes through `quoteFreeTextAnswer`.
267
+ */
268
+ export function validateQuestionAnswers(questions: readonly BrokerQuestion[], raw: unknown): AnswerValidation {
269
+ if (!Array.isArray(raw) || raw.length !== questions.length) return { ok: false, reason: 'answers_shape' }
270
+ const answers: Record<string, string[]> = {}
271
+ for (let index = 0; index < questions.length; index++) {
272
+ const q = questions[index]!
273
+ const entry = raw[index]
274
+ if (!isRecord(entry)) return { ok: false, reason: 'answers_shape', index }
275
+ const labels = entry.labels === undefined ? [] : entry.labels
276
+ if (!Array.isArray(labels) || labels.some(l => typeof l !== 'string')) return { ok: false, reason: 'answers_shape', index }
277
+ if (entry.other !== undefined && typeof entry.other !== 'string') return { ok: false, reason: 'answers_shape', index }
278
+ const picked = new Set<string>()
279
+ for (const label of labels as string[]) {
280
+ if (!q.options.some(o => o.label === label)) return { ok: false, reason: 'unknown_label', index }
281
+ if (picked.has(label)) return { ok: false, reason: 'duplicate_label', index }
282
+ picked.add(label)
283
+ }
284
+ // Verbatim: whitespace-only is no answer, anything else is sent exactly as written.
285
+ const other = typeof entry.other === 'string' ? entry.other : ''
286
+ if (other.length > OTHER_TEXT_MAX) return { ok: false, reason: 'other_too_long', index }
287
+ const list: string[] = []
288
+ for (const option of q.options) {
289
+ if (picked.has(option.label) && !list.includes(option.label)) list.push(option.label)
290
+ }
291
+ if (other.trim().length > 0) list.push(quoteFreeTextAnswer(other))
292
+ if (list.length === 0) return { ok: false, reason: 'unanswered', index }
293
+ if (!q.multiSelect && list.length > 1) return { ok: false, reason: 'too_many', index }
294
+ answers[q.question] = list
295
+ }
296
+ return { ok: true, answers }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // What the hook prints
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /** AskUserQuestion answered: allow, with the ORIGINAL input plus `answers`. Nothing else. */
304
+ export function questionHookOutput(toolInput: Record<string, unknown>, answers: Record<string, string[]>): Record<string, unknown> {
305
+ return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedInput: { ...toolInput, answers } } } }
306
+ }
307
+
308
+ /** A tool approval: allow once, or deny with the one message. Never a rule. */
309
+ export function approvalHookOutput(decision: 'allow' | 'deny'): Record<string, unknown> {
310
+ const body = decision === 'allow' ? { behavior: 'allow' } : { behavior: 'deny', message: DENY_MESSAGE }
311
+ return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: body } }
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // The approval card
316
+ // ---------------------------------------------------------------------------
317
+
318
+ export interface ApprovalCard {
319
+ tool: string
320
+ /** What will run, as the lens shows it: the command verbatim, the full path, the URL. */
321
+ summary: string
322
+ /** The same, longer (up to APPROVAL_DETAIL_MAX). The lens puts it one page back. */
323
+ detail: string
324
+ }
325
+
326
+ const SHELL_TOOLS: ReadonlySet<string> = new Set(['bash', 'shell', 'exec', 'exec_command'])
327
+ const FILE_TOOLS: ReadonlySet<string> = new Set(['write', 'edit', 'multiedit', 'notebookedit'])
328
+
329
+ /**
330
+ * Approval text as the lens and phone draw it (6.52.0 QA round 2). Nothing is dropped and
331
+ * nothing is merged: a line break is shown as a literal `\n` between spaces, so a second
332
+ * command can never read as arguments of the first; any other control character is shown as
333
+ * `\xNN`. EVERY other character outside printable ASCII (0x20 to 0x7e) is shown as
334
+ * `\uNNNN`, one per UTF-16 unit (6.52.0 app QA): a bidirectional override or a zero-width
335
+ * character cannot reorder or hide part of a command, and a curly quote, a dash, an ellipsis
336
+ * or an emoji cannot be drawn as the ASCII character a lens font substitutes for it. Curly
337
+ * quotes are not shell quotes, so `echo <curly>; curl ... | bash;<curly>` runs the curl; drawn as
338
+ * straight quotes it would read as a harmless echo. Remaining whitespace runs (spaces and
339
+ * tabs) become one space.
340
+ */
341
+ export function visibleApprovalText(text: string): string {
342
+ return text
343
+ .replace(/\r\n|\r|\n/g, ' \\n ')
344
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, c => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
345
+ .replace(/[^\x00-\x7e]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
346
+ .replace(/[\t\u00a0\s]+/g, ' ')
347
+ .trim()
348
+ }
349
+
350
+ type ApprovalRedaction = 'command' | 'path' | 'url'
351
+
352
+ const PROVIDER_TOKEN_RE = /\b(?:sk-(?:proj-|live-|test-|ant-)?|sk_(?:live|test)_|sess-|pat-|gh[pousr]_|github_pat_|glpat-|npm_|pypi-|shpat_|xox[baprs]-)[A-Za-z0-9._-]{8,}\b/gi
353
+ const AWS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g
354
+ const GOOGLE_KEY_RE = /\bAIza[A-Za-z0-9_-]{30,}\b/g
355
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g
356
+ const PEM_MARKER_RE = /-----(?:BEGIN|END) [A-Z0-9 ]{0,40}(?:PRIVATE KEY|CREDENTIALS?)-----/gi
357
+ const SECRET_KEY = '(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth(?:orization)?|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie|phpsessid|jsessionid|sid)'
358
+ // Inside ONE piece only. The value stops at a quote, a slash or a comma, so a match can
359
+ // never reach past the piece it sits in (6.52.0 QA round 2: the shared patterns' quoted
360
+ // values ran across `&&`, `;` and `|` and swallowed whole commands).
361
+ const PIECE_ASSIGN_RE = new RegExp(`\\b((?:[a-z0-9]+[_-])*${SECRET_KEY}(?:[_-][a-z0-9]+)*["']?[:=])(["']?)([^"'/,]+)`, 'gi')
362
+ const URL_USERINFO_RE = /([a-z][a-z0-9+.-]{0,31}:\/\/)[^\s/@:]*:[^\s/@]+@/gi
363
+ const URL_QUERY_SECRET_RE = /([?&](?:api[_-]?key|access[_-]?token|token|auth|password|secret)=)[^&#\s]+/gi
364
+ // A flag or scheme word whose NEXT piece is the credential.
365
+ const SECRET_NEXT_PIECE_RE = /^(?:--?(?:api-key|token|access-token|refresh-token|password|passwd|secret|client-secret)|-u|--user|["']?(?:bearer|basic|digest)|["']?(?:set-)?cookie:)$/i
366
+ const BASE64ISH_RE = /^["']?[A-Za-z0-9+/=]{24,}["']?$/
367
+
368
+ function redactProviderTokens(text: string): string {
369
+ return text
370
+ .replace(PROVIDER_TOKEN_RE, '[redacted-token]')
371
+ .replace(AWS_KEY_RE, '[redacted-aws-key]')
372
+ .replace(GOOGLE_KEY_RE, '[redacted-google-key]')
373
+ .replace(JWT_RE, '[redacted-jwt]')
374
+ }
375
+
376
+ /** Keep a piece's own quotes and hide what they hold. */
377
+ function redactWholePiece(piece: string): string {
378
+ const m = /^(["']?)(.*?)(["']?)$/.exec(piece)
379
+ return m && m[2] ? `${m[1]}[redacted]${m[3]}` : piece
380
+ }
381
+
382
+ /**
383
+ * Secrets out of approval text, and never anything else (6.52.0 QA round 2). A command is
384
+ * split into pieces at whitespace and at `;`, `&` and `|`, which are kept as written; every
385
+ * redaction happens INSIDE one piece, so it can hide a credential but never a command,
386
+ * a pipe or an argument after it. A path keeps everything but provider tokens. A URL also
387
+ * loses its userinfo and secret query values.
388
+ */
389
+ export function redactApprovalText(text: string, mode: ApprovalRedaction): string {
390
+ if (mode === 'path') return redactProviderTokens(text)
391
+ if (mode === 'url') return redactProviderTokens(text.replace(URL_USERINFO_RE, '$1[redacted]@').replace(URL_QUERY_SECRET_RE, '$1[redacted]'))
392
+ const sawPem = PEM_MARKER_RE.test(text)
393
+ PEM_MARKER_RE.lastIndex = 0
394
+ const marked = text.replace(PEM_MARKER_RE, '[redacted-private-material]')
395
+ const parts = marked.split(/(\s+|&&|\|\||[;&|])/)
396
+ let previous = ''
397
+ for (let i = 0; i < parts.length; i++) {
398
+ const part = parts[i]!
399
+ if (i % 2 === 1 || part === '') continue
400
+ let piece = part
401
+ if (SECRET_NEXT_PIECE_RE.test(previous)) piece = redactWholePiece(piece)
402
+ else if (sawPem && BASE64ISH_RE.test(piece)) piece = redactWholePiece(piece)
403
+ else {
404
+ piece = piece.replace(URL_USERINFO_RE, '$1[redacted]@')
405
+ piece = piece.replace(PIECE_ASSIGN_RE, (_m, key: string, quote: string) => `${key}${quote}[redacted]`)
406
+ piece = redactProviderTokens(piece)
407
+ }
408
+ parts[i] = piece
409
+ previous = part
410
+ }
411
+ return parts.join('')
412
+ }
413
+
414
+ /** The marker a cut line ends with, naming how much is not shown. */
415
+ export function approvalCutMarker(omitted: number): string {
416
+ return ` ...(+${omitted} chars)`
417
+ }
418
+
419
+ /**
420
+ * What an approval line says when the call gives it nothing to show (6.52.0 app QA): never
421
+ * an empty line, so a client never has to drop an item it cannot draw.
422
+ */
423
+ export const APPROVAL_EMPTY_COMMAND = '(empty command)'
424
+ export const APPROVAL_EMPTY_PATH = '(empty path)'
425
+ export const APPROVAL_NO_INPUT = '(no input)'
426
+
427
+ /**
428
+ * One approval line from raw text: made visible (line breaks and hidden characters shown),
429
+ * then redacted with the approval redactor, then cut at `max` with the marker. Only
430
+ * REDACTION_SCAN_MAX_CHARS reach a regex, ended at a token boundary, so no input can make
431
+ * it slow and no secret straddling that bound is shown half. Nothing to show (an empty or
432
+ * whitespace-only value) is `empty`, never ''.
433
+ */
434
+ function approvalLine(raw: string, max: number, mode: ApprovalRedaction = 'command', empty: string = APPROVAL_NO_INPUT): string {
435
+ const { head, omitted } = boundForRedaction(raw)
436
+ const text = redactApprovalText(visibleApprovalText(head), mode)
437
+ if (text.length === 0 && omitted === 0) return empty
438
+ if (text.length <= max && omitted === 0) return text
439
+ const shown = text.slice(0, max)
440
+ return shown + approvalCutMarker(text.length - shown.length + omitted)
441
+ }
442
+
443
+ function lineCount(value: unknown): number {
444
+ if (typeof value !== 'string' || value.length === 0) return 0
445
+ return value.replace(/\n$/, '').split('\n').length
446
+ }
447
+
448
+ function plural(n: number, word: string): string {
449
+ return `${n} ${word}${n === 1 ? '' : 's'}`
450
+ }
451
+
452
+ /** How big a file change is, from the call itself. */
453
+ function fileChangeSize(lower: string, input: Record<string, unknown>): string {
454
+ if (lower === 'write') {
455
+ const content = typeof input.content === 'string' ? input.content : ''
456
+ return `${plural(lineCount(content), 'line')}, ${plural(content.length, 'char')}`
457
+ }
458
+ if (lower === 'edit') {
459
+ const all = input.replace_all === true ? ', every match' : ''
460
+ return `+${lineCount(input.new_string)} -${lineCount(input.old_string)} lines${all}`
461
+ }
462
+ if (lower === 'multiedit') {
463
+ const edits = Array.isArray(input.edits) ? input.edits.length : 0
464
+ return plural(edits, 'edit')
465
+ }
466
+ // NotebookEdit. The mode is the call's own text: shown like the rest of the card (every
467
+ // character outside printable ASCII escaped) and cut short, never drawn raw.
468
+ const mode = typeof input.edit_mode === 'string' && input.edit_mode.length > 0 ? approvalLine(input.edit_mode, 40, 'path') : 'replace'
469
+ return `${mode}, ${plural(lineCount(input.new_source), 'line')}`
470
+ }
471
+
472
+ /** A value as the card shows it: a string as written, anything else as JSON. */
473
+ function valueText(value: unknown): string {
474
+ if (typeof value === 'string') return value
475
+ try {
476
+ return JSON.stringify(value) ?? String(value)
477
+ } catch {
478
+ return String(value)
479
+ }
480
+ }
481
+
482
+ /**
483
+ * What the lens and phone show for an approval (6.52.0 QA round 1).
484
+ *
485
+ * THE RULE: the card never says less than what will run. Round 1 reused the Sessions
486
+ * display shorteners and they misstated it: `git status | tail -3 && git push --force
487
+ * origin main` read "git status", `ls | head -1; curl ... | sh` read "ls| sh", any path of
488
+ * 40+ characters read "[opaque output hidden]", and WebFetch showed its prompt. So:
489
+ * - a shell command: verbatim, whitespace-normalized, every segment, pipe and `&&` part;
490
+ * - Write, Edit, MultiEdit, NotebookEdit: the full path, then the size or line count;
491
+ * - WebFetch: the URL. WebSearch: the query;
492
+ * - anything else: its input keys and values in order (the card's `tool` names it).
493
+ * A call with nothing to show still has a line (6.52.0 app QA): `(empty command)` for an
494
+ * empty or blank command, `(empty path)` for an empty path, `(no input)` otherwise (`{}`).
495
+ * A line longer than its cap is cut there and ends with ` ...(+N chars)`. The only thing
496
+ * taken out is a secret, by `redactApprovalText`, which works inside one piece of the
497
+ * command at a time (round 2: the shared patterns swallowed `&& git push --force`), never
498
+ * the opaque-output heuristic, which hid ordinary paths.
499
+ */
500
+ export function approvalCard(toolName: string, toolInput: Record<string, unknown>): ApprovalCard {
501
+ const lower = toolName.trim().toLowerCase()
502
+ const str = (key: string): string | null => typeof toolInput[key] === 'string' ? toolInput[key] as string : null
503
+ const both = (text: string, mode: ApprovalRedaction = 'command', empty: string = APPROVAL_NO_INPUT): ApprovalCard => ({
504
+ tool: toolName,
505
+ summary: approvalLine(text, APPROVAL_SUMMARY_MAX, mode, empty),
506
+ detail: approvalLine(text, APPROVAL_DETAIL_MAX, mode, empty),
507
+ })
508
+ if (SHELL_TOOLS.has(lower)) {
509
+ const command = str('command') ?? str('cmd')
510
+ if (command !== null) return both(command, 'command', APPROVAL_EMPTY_COMMAND)
511
+ }
512
+ if (FILE_TOOLS.has(lower)) {
513
+ const path = str('file_path') ?? str('notebook_path') ?? str('path')
514
+ if (path !== null) {
515
+ const size = ` (${fileChangeSize(lower, toolInput)})`
516
+ return {
517
+ tool: toolName,
518
+ summary: approvalLine(path, APPROVAL_SUMMARY_MAX, 'path', APPROVAL_EMPTY_PATH) + size,
519
+ detail: approvalLine(path, APPROVAL_DETAIL_MAX, 'path', APPROVAL_EMPTY_PATH) + size,
520
+ }
521
+ }
522
+ }
523
+ if (lower === 'webfetch' && str('url') !== null) return both(str('url')!, 'url')
524
+ if (lower === 'websearch' && str('query') !== null) return both(str('query')!)
525
+ // `approvalLine` bounds what reaches a regex, so a huge value costs a join, not a scan.
526
+ return both(Object.entries(toolInput).map(([key, value]) => `${key}=${valueText(value)}`).join(' '))
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // The broker
531
+ // ---------------------------------------------------------------------------
532
+
533
+ export type BrokerItemKind = 'question' | 'approval'
534
+ export type BrokerResolution = 'answered' | 'handed_to_desk' | 'expired' | 'hook_gone' | 'retracted' | 'drained' | 'no_client'
535
+ export type BrokerFastPath =
536
+ | 'broker_off' | 'draining' | 'malformed' | 'cursor' | 'approvals_off' | 'unsupported_tool'
537
+ | 'unsupported_input' | 'no_client' | 'stale_hook' | 'capacity' | 'desk_unknown' | 'desk_active'
538
+ | 'hook_gone_early'
539
+
540
+ /** The held response. */
541
+ export interface HookReplyChannel {
542
+ /** The hook is still listening: its socket can take a reply. */
543
+ writable(): boolean
544
+ /** Write the reply; false when it could not be written. */
545
+ reply(body: Record<string, unknown>): boolean
546
+ }
547
+
548
+ /** Where the rows learn about a parked item (the session signal store, in production). */
549
+ export interface BrokerSignalSink {
550
+ attach(sessionId: string, requestId: string, fingerprint: string): void
551
+ detach(sessionId: string, requestId: string): void
552
+ decided(sessionId: string, requestId: string, fingerprint: string): void
553
+ }
554
+
555
+ export interface PermissionBrokerDeps {
556
+ now(): number
557
+ mode(): BrokerMode
558
+ admissionsOpen(): boolean
559
+ /** The newest counting `GET /api/session-questions`, or null (lib/client-liveness). */
560
+ lastQuestionsPollAt(): number | null
561
+ readDeskIdleSeconds(): Promise<number | null>
562
+ deskIdleSeconds(): number
563
+ timeoutMs(): number
564
+ signals?: BrokerSignalSink
565
+ pollMs?: number
566
+ newId?: () => string
567
+ log?: (line: string) => void
568
+ }
569
+
570
+ export interface ParkRequest {
571
+ facts: PermissionRequestFacts
572
+ kind: BrokerItemKind
573
+ questions: BrokerQuestion[] | null
574
+ approval: ApprovalCard | null
575
+ deadlineAt: number
576
+ }
577
+
578
+ export type BrokerAdmission = { ok: true; request: ParkRequest } | { ok: false; reason: BrokerFastPath }
579
+
580
+ type Chosen = { answers: Record<string, string[]> } | { decision: 'allow' | 'deny' }
581
+
582
+ interface BrokerItem {
583
+ id: string
584
+ sessionId: string
585
+ kind: BrokerItemKind
586
+ toolName: string
587
+ toolInput: Record<string, unknown>
588
+ fingerprint: string
589
+ /** When the hook started (its envelope stamp) or, unstamped, when it was parked. */
590
+ requestedAt: number
591
+ questions: BrokerQuestion[] | null
592
+ approval: ApprovalCard | null
593
+ createdAt: number
594
+ deadlineAt: number
595
+ state: 'pending' | BrokerResolution
596
+ settledAt: number | null
597
+ answer: { clientAnswerId: string; chosen: Chosen } | null
598
+ /** The clientAnswerId of the "Leave for the Mac" that settled it, so its retry replays. */
599
+ handedBackBy: string | null
600
+ channel: HookReplyChannel | null
601
+ timer: ReturnType<typeof setTimeout> | null
602
+ }
603
+
604
+ export interface SessionQuestionView {
605
+ id: string
606
+ sessionId: string
607
+ provider: 'claude'
608
+ kind: BrokerItemKind
609
+ tool: string
610
+ createdAt: string
611
+ deadlineAt: string
612
+ questions?: BrokerQuestion[]
613
+ approval?: ApprovalCard
614
+ }
615
+
616
+ /**
617
+ * `/api/health`'s `permissionBroker`. Counts, the mode and two timestamps: never an id,
618
+ * a question, a command, or how many are held right now (that is on the authenticated
619
+ * questions route). Every key is camelCase; the reason CODES keep their wire spelling.
620
+ */
621
+ export interface PermissionBrokerHealth {
622
+ enabled: boolean
623
+ mode: BrokerMode
624
+ counters: {
625
+ parked: number
626
+ answered: number
627
+ handedToDesk: number
628
+ /** Of `handedToDesk`, those a client handed back ("Leave for the Mac"). */
629
+ handedBack: number
630
+ expired: number
631
+ hookGone: number
632
+ retracted: number
633
+ drained: number
634
+ noClient: number
635
+ /** An answer refused 400 `invalid_answer`. */
636
+ invalidAnswer: number
637
+ /** A desk read that failed while something was held. */
638
+ deskUnreadable: number
639
+ /** Requests answered `{}` without being parked, keyed by the camelCase of the reason. */
640
+ fastPath: Record<string, number>
641
+ }
642
+ lastFastPath: BrokerFastPath | null
643
+ lastFastPathAt: string | null
644
+ lastQuestionsPollAt: string | null
645
+ }
646
+
647
+ type ResolutionCounter = 'handedToDesk' | 'expired' | 'hookGone' | 'retracted' | 'drained' | 'noClient'
648
+ const RESOLUTION_COUNTER: Record<Exclude<BrokerResolution, 'answered'>, ResolutionCounter> = {
649
+ handed_to_desk: 'handedToDesk',
650
+ expired: 'expired',
651
+ hook_gone: 'hookGone',
652
+ retracted: 'retracted',
653
+ drained: 'drained',
654
+ no_client: 'noClient',
655
+ }
656
+
657
+ function camelKey(code: string): string {
658
+ return code.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase())
659
+ }
660
+
661
+ function isoOrNull(ms: number | null): string | null {
662
+ return ms !== null && Number.isFinite(ms) ? new Date(ms).toISOString() : null
663
+ }
664
+
665
+ export interface AnswerResult { status: number; body: Record<string, unknown> }
666
+
667
+ export const BROKER_ID_RE = /^pq_[0-9a-f]{24}$/
668
+ export const CLIENT_ANSWER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
669
+
670
+ /** Events that end the turn a request was part of: every held request of the session is moot. */
671
+ const TURN_BOUNDARIES: ReadonlySet<string> = new Set(['Stop', 'StopFailure', 'SessionEnd', 'UserPromptSubmit'])
672
+
673
+ export class PermissionBroker {
674
+ private readonly deps: PermissionBrokerDeps
675
+ private readonly items = new Map<string, BrokerItem>()
676
+ private poll: ReturnType<typeof setInterval> | null = null
677
+ private pollInFlight = false
678
+ /** Failed desk reads in a row while something is held. Two hand everything back. */
679
+ private deskUnreadableStreak = 0
680
+ private readonly counters = { parked: 0, answered: 0, handedToDesk: 0, handedBack: 0, expired: 0, hookGone: 0, retracted: 0, drained: 0, noClient: 0, invalidAnswer: 0, deskUnreadable: 0 }
681
+ private readonly fastPaths: Record<string, number> = {}
682
+ private lastFastPath: BrokerFastPath | null = null
683
+ private lastFastPathAt: number | null = null
684
+
685
+ constructor(deps: PermissionBrokerDeps) {
686
+ this.deps = deps
687
+ }
688
+
689
+ private log(line: string): void {
690
+ try { (this.deps.log ?? console.log)(`[permission-broker] ${line}`) } catch { /* logging never breaks a hook */ }
691
+ }
692
+
693
+ /** A request answered `{}` without being parked, counted by why. */
694
+ noteFastPath(reason: BrokerFastPath): { ok: false; reason: BrokerFastPath } {
695
+ const key = camelKey(reason)
696
+ this.fastPaths[key] = (this.fastPaths[key] ?? 0) + 1
697
+ this.lastFastPath = reason
698
+ this.lastFastPathAt = this.deps.now()
699
+ return { ok: false, reason }
700
+ }
701
+
702
+ /**
703
+ * A client that can answer has polled within the window. A stamp more than
704
+ * POLL_FUTURE_SKEW_MS ahead of the clock (the clock stepped back) is stale, not live
705
+ * forever.
706
+ */
707
+ private clientLive(now: number): boolean {
708
+ const polledAt = this.deps.lastQuestionsPollAt()
709
+ if (polledAt === null || !Number.isFinite(polledAt)) return false
710
+ if (polledAt - now > POLL_FUTURE_SKEW_MS) return false
711
+ return now - polledAt <= CLIENT_LIVE_WINDOW_MS
712
+ }
713
+
714
+ /** The desk-idle threshold, never below DESK_IDLE_MIN_S. */
715
+ private deskIdleThreshold(): number {
716
+ const configured = Number(this.deps.deskIdleSeconds())
717
+ return Math.max(DESK_IDLE_MIN_S, Number.isFinite(configured) ? configured : DESK_IDLE_MIN_S)
718
+ }
719
+
720
+ pendingCount(): number {
721
+ let n = 0
722
+ for (const item of this.items.values()) if (item.state === 'pending') n++
723
+ return n
724
+ }
725
+
726
+ /**
727
+ * Every gate, cheapest first, so a request outside them is answered without spawning
728
+ * anything or reading its input: the switch, the drain, the envelope's structure (a
729
+ * Cursor payload), the tool policy, a live client, the hook's own deadline, capacity, and
730
+ * only then the desk (one `ioreg`). The switch, the drain, the approvals policy and
731
+ * capacity are read again after it, since the world may have moved during the read. The
732
+ * question card, the approval card and the fingerprint are built only for a request that
733
+ * will be parked.
734
+ */
735
+ async admit(body: unknown): Promise<BrokerAdmission> {
736
+ if (this.deps.mode() === 'off') return this.noteFastPath('broker_off')
737
+ if (!this.deps.admissionsOpen()) return this.noteFastPath('draining')
738
+ const read = readPermissionRequestEnvelope(body)
739
+ if (!read.ok) return this.noteFastPath(read.reason)
740
+ const { facts } = read
741
+ const isQuestion = facts.toolName === ASK_USER_QUESTION_TOOL
742
+ if (!isQuestion) {
743
+ if (this.deps.mode() !== 'all') return this.noteFastPath('approvals_off')
744
+ if (UNBROKERED_TOOLS.has(facts.toolName)) return this.noteFastPath('unsupported_tool')
745
+ }
746
+ const now = this.deps.now()
747
+ if (!this.clientLive(now)) return this.noteFastPath('no_client')
748
+ // The deadline counts from when the HOOK started, when its stamp is sane: a request the
749
+ // server read late must still be answered before the hook's curl gives up.
750
+ const requestedAt = facts.hookStartedAtMs !== null && facts.hookStartedAtMs <= now ? facts.hookStartedAtMs : now
751
+ const deadlineAt = requestedAt + this.deps.timeoutMs()
752
+ if (deadlineAt <= now) return this.noteFastPath('stale_hook')
753
+ if (this.pendingCount() >= MAX_PENDING) return this.noteFastPath('capacity')
754
+ let idle: number | null = null
755
+ try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null }
756
+ if (idle === null) return this.noteFastPath('desk_unknown')
757
+ if (idle < this.deskIdleThreshold()) return this.noteFastPath('desk_active')
758
+ const mode = this.deps.mode()
759
+ if (mode === 'off') return this.noteFastPath('broker_off')
760
+ if (!this.deps.admissionsOpen()) return this.noteFastPath('draining')
761
+ if (!isQuestion && mode !== 'all') return this.noteFastPath('approvals_off')
762
+ // Others were admitted during the read: the cap holds at the moment of parking.
763
+ if (this.pendingCount() >= MAX_PENDING) return this.noteFastPath('capacity')
764
+ let kind: BrokerItemKind
765
+ let questions: BrokerQuestion[] | null = null
766
+ let approval: ApprovalCard | null = null
767
+ if (isQuestion) {
768
+ questions = parseQuestions(facts.toolInput)
769
+ if (!questions) return this.noteFastPath('unsupported_input')
770
+ kind = 'question'
771
+ } else {
772
+ kind = 'approval'
773
+ approval = approvalCard(facts.toolName, facts.toolInput)
774
+ }
775
+ const fingerprint = toolFingerprint(facts.toolName, facts.toolInput)
776
+ return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt } }
777
+ }
778
+
779
+ /** Hold the hook's response until exactly one resolution. Returns the item id. */
780
+ park(request: ParkRequest, channel: HookReplyChannel): string {
781
+ const now = this.deps.now()
782
+ const id = this.deps.newId ? this.deps.newId() : `pq_${randomBytes(12).toString('hex')}`
783
+ const { facts } = request
784
+ const item: BrokerItem = {
785
+ id,
786
+ sessionId: facts.sessionId,
787
+ kind: request.kind,
788
+ toolName: facts.toolName,
789
+ // Only a question needs its input again (the answer is built on it). An approval's
790
+ // input can be a whole file (Write): never kept past the card and the fingerprint.
791
+ toolInput: request.kind === 'question' ? facts.toolInput : {},
792
+ fingerprint: facts.fingerprint,
793
+ requestedAt: facts.hookStartedAtMs !== null && facts.hookStartedAtMs <= now ? facts.hookStartedAtMs : now,
794
+ questions: request.questions,
795
+ approval: request.approval,
796
+ createdAt: now,
797
+ deadlineAt: request.deadlineAt,
798
+ state: 'pending',
799
+ settledAt: null,
800
+ answer: null,
801
+ handedBackBy: null,
802
+ channel,
803
+ timer: null,
804
+ }
805
+ this.items.set(id, item)
806
+ this.counters.parked++
807
+ item.timer = setTimeout(() => { this.settle(item, 'expired') }, Math.max(0, request.deadlineAt - now))
808
+ item.timer.unref?.()
809
+ try { this.deps.signals?.attach(item.sessionId, id, item.fingerprint) } catch { /* rows are advisory */ }
810
+ this.ensurePolling()
811
+ this.log(`parked ${id} kind=${item.kind} session=${item.sessionId.slice(0, 8)} deadline_s=${Math.round((request.deadlineAt - now) / 1000)} pending=${this.pendingCount()}`)
812
+ return id
813
+ }
814
+
815
+ /** The hook's connection closed. A no-op once the item is settled. */
816
+ hookClosed(id: string): void {
817
+ const item = this.items.get(id)
818
+ if (item) this.settle(item, 'hook_gone')
819
+ }
820
+
821
+ /** Every way an item ends except an answer. The hook gets `{}` unless it is gone. */
822
+ private settle(item: BrokerItem, resolution: Exclude<BrokerResolution, 'answered'>): boolean {
823
+ if (item.state !== 'pending') return false
824
+ item.state = resolution
825
+ item.settledAt = this.deps.now()
826
+ if (item.timer) clearTimeout(item.timer)
827
+ item.timer = null
828
+ if (resolution !== 'hook_gone') {
829
+ try { item.channel?.reply({}) } catch { /* the hook is gone anyway */ }
830
+ }
831
+ item.channel = null
832
+ // A settled item is kept for its codes, not its content.
833
+ item.toolInput = {}
834
+ item.questions = null
835
+ item.approval = null
836
+ this.counters[RESOLUTION_COUNTER[resolution]]++
837
+ try { this.deps.signals?.detach(item.sessionId, item.id) } catch { /* rows are advisory */ }
838
+ this.log(`${resolution} ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
839
+ this.prune()
840
+ return true
841
+ }
842
+
843
+ /** The pending items, oldest first, for `GET /api/session-questions`. */
844
+ list(): SessionQuestionView[] {
845
+ const now = this.deps.now()
846
+ const views: SessionQuestionView[] = []
847
+ for (const item of [...this.items.values()]) {
848
+ if (item.state !== 'pending') continue
849
+ // A deadline the timer has not caught yet is still a dead question.
850
+ if (now >= item.deadlineAt) { this.settle(item, 'expired'); continue }
851
+ views.push({
852
+ id: item.id,
853
+ sessionId: item.sessionId,
854
+ provider: 'claude',
855
+ kind: item.kind,
856
+ tool: item.toolName,
857
+ createdAt: new Date(item.createdAt).toISOString(),
858
+ deadlineAt: new Date(item.deadlineAt).toISOString(),
859
+ ...(item.questions ? { questions: item.questions } : {}),
860
+ ...(item.approval ? { approval: item.approval } : {}),
861
+ })
862
+ }
863
+ return views.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
864
+ }
865
+
866
+ /**
867
+ * `POST /api/session-questions/:id/answer`. Synchronous from the state check to the
868
+ * reply, so two racing answers cannot both win: the first one settles the item and the
869
+ * second reads it settled.
870
+ *
871
+ * `{clientAnswerId, handBack: true}` is "Leave for the Mac" (6.52.0 app QA): the hook gets
872
+ * `{}` at once, so the Mac's own dialog shows now instead of at the deadline. It resolves
873
+ * the item as `handed_to_desk`, and it competes with an answer like any answer: the first
874
+ * action wins. A later answer reads 409 `handed_to_desk`; a hand-back after an answer reads
875
+ * 409 `already_answered` with the recorded answer; the same hand-back retried (same
876
+ * clientAnswerId) replays its 200. `handBack` wins over any answer fields in the same body:
877
+ * it is the safe direction, since it only shows the dialog that would have shown anyway.
878
+ */
879
+ answer(id: string, body: unknown): AnswerResult {
880
+ const item = BROKER_ID_RE.test(id) ? this.items.get(id) : undefined
881
+ if (!item) return { status: 404, body: { error: 'not_found' } }
882
+ const input = isRecord(body) ? body : {}
883
+ const clientAnswerId = input.clientAnswerId
884
+ if (typeof clientAnswerId !== 'string' || !CLIENT_ANSWER_ID_RE.test(clientAnswerId)) {
885
+ this.counters.invalidAnswer++
886
+ return { status: 400, body: { error: 'invalid_answer', reason: 'client_answer_id' } }
887
+ }
888
+ const handBack = input.handBack === true
889
+ if (item.state !== 'pending') return this.settledReply(item, clientAnswerId, handBack)
890
+ if (this.deps.now() >= item.deadlineAt) {
891
+ this.settle(item, 'expired')
892
+ return this.settledReply(item, clientAnswerId, handBack)
893
+ }
894
+ if (handBack) return this.handBack(item, clientAnswerId)
895
+ let chosen: Chosen
896
+ let output: Record<string, unknown>
897
+ if (item.kind === 'question') {
898
+ const validation = validateQuestionAnswers(item.questions ?? [], input.answers)
899
+ if (!validation.ok) {
900
+ this.counters.invalidAnswer++
901
+ return { status: 400, body: { error: 'invalid_answer', reason: validation.reason, ...(validation.index !== undefined ? { index: validation.index } : {}) } }
902
+ }
903
+ chosen = { answers: validation.answers }
904
+ output = questionHookOutput(item.toolInput, validation.answers)
905
+ } else {
906
+ if (input.decision !== 'allow' && input.decision !== 'deny') {
907
+ this.counters.invalidAnswer++
908
+ return { status: 400, body: { error: 'invalid_answer', reason: 'decision' } }
909
+ }
910
+ chosen = { decision: input.decision }
911
+ output = approvalHookOutput(input.decision)
912
+ }
913
+ // The hook must still be listening BEFORE the answer is accepted. An answer written
914
+ // into a closed socket is one the session never received: it would read as answered
915
+ // here while Claude shows the native dialog.
916
+ if (!item.channel || !item.channel.writable() || !item.channel.reply(output)) {
917
+ this.settle(item, 'hook_gone')
918
+ return this.settledReply(item, clientAnswerId)
919
+ }
920
+ item.state = 'answered'
921
+ item.settledAt = this.deps.now()
922
+ item.answer = { clientAnswerId, chosen }
923
+ item.channel = null
924
+ item.toolInput = {}
925
+ item.questions = null
926
+ item.approval = null
927
+ if (item.timer) clearTimeout(item.timer)
928
+ item.timer = null
929
+ this.counters.answered++
930
+ try { this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint) } catch { /* rows are advisory */ }
931
+ this.log(`answered ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
932
+ this.prune()
933
+ return { status: 200, body: { ok: true, id: item.id, kind: item.kind, ...chosen } }
934
+ }
935
+
936
+ /**
937
+ * "Leave for the Mac" on a pending item. As for an answer, the hook must still be listening:
938
+ * one that already left is `hook_gone` (the Mac shows its dialog either way).
939
+ */
940
+ private handBack(item: BrokerItem, clientAnswerId: string): AnswerResult {
941
+ if (!item.channel || !item.channel.writable()) {
942
+ this.settle(item, 'hook_gone')
943
+ return this.settledReply(item, clientAnswerId, true)
944
+ }
945
+ item.handedBackBy = clientAnswerId
946
+ this.counters.handedBack++
947
+ this.settle(item, 'handed_to_desk')
948
+ return { status: 200, body: { ok: true, id: item.id, kind: item.kind, handedBack: true } }
949
+ }
950
+
951
+ private settledReply(item: BrokerItem, clientAnswerId: string, handBack = false): AnswerResult {
952
+ switch (item.state) {
953
+ case 'answered': {
954
+ const chosen = item.answer?.chosen ?? {}
955
+ // A hand-back never replays an answer, even under the answer's own id.
956
+ if (handBack) return { status: 409, body: { error: 'already_answered', ...chosen } }
957
+ if (item.answer?.clientAnswerId === clientAnswerId) return { status: 200, body: { ok: true, id: item.id, kind: item.kind, replay: true, ...chosen } }
958
+ return { status: 409, body: { error: 'already_answered', ...chosen } }
959
+ }
960
+ case 'expired':
961
+ return { status: 409, body: { error: 'expired' } }
962
+ case 'hook_gone':
963
+ return { status: 410, body: { error: 'hook_gone' } }
964
+ default:
965
+ // handed_to_desk, retracted, drained, no_client: the Mac has it now. Only the
966
+ // hand-back that did it, retried under its own id, replays; an answer never does.
967
+ if (handBack && item.handedBackBy === clientAnswerId) return { status: 200, body: { ok: true, id: item.id, kind: item.kind, handedBack: true, replay: true } }
968
+ return { status: 409, body: { error: 'handed_to_desk', reason: item.state } }
969
+ }
970
+ }
971
+
972
+ /**
973
+ * One hook event from the spool, after the signal store applied it (the store's
974
+ * subscriber). Keeps the row's id attached across re-announcements, and retracts a held
975
+ * item the session has moved past without us: its tool ran or was denied (the native
976
+ * dialog was answered), or its turn ended. An event older than the request is history.
977
+ */
978
+ observeHookEvent(env: HookEnvelope, child: boolean): void {
979
+ if (child) return
980
+ const p = env.payload
981
+ const toolName = typeof p.tool_name === 'string' ? p.tool_name : ''
982
+ const fingerprint = typeof p.tool_fingerprint === 'string' ? p.tool_fingerprint : toolFingerprint(toolName, p.tool_input ?? null)
983
+ for (const item of [...this.items.values()]) {
984
+ if (item.sessionId !== env.sessionId) continue
985
+ if (env.event === 'PermissionRequest' || env.event === 'PreToolUse') {
986
+ if (item.fingerprint !== fingerprint) continue
987
+ try {
988
+ if (item.state === 'pending') this.deps.signals?.attach(item.sessionId, item.id, item.fingerprint)
989
+ // A late copy of a request we already answered (stamped before the answer) must
990
+ // not leave the row waiting; the same question asked AGAIN later is a new wait.
991
+ else if (item.state === 'answered' && env.ts <= (item.settledAt ?? 0)) this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint)
992
+ } catch { /* rows are advisory */ }
993
+ continue
994
+ }
995
+ if (item.state !== 'pending' || env.ts < item.requestedAt) continue
996
+ if (TURN_BOUNDARIES.has(env.event) || (env.event === 'SessionStart' && p.source !== 'compact')) {
997
+ this.settle(item, 'retracted')
998
+ continue
999
+ }
1000
+ if (env.event === 'PostToolUse' || env.event === 'PostToolUseFailure' || env.event === 'PermissionDenied') {
1001
+ const same = item.kind === 'question'
1002
+ ? toolName === ASK_USER_QUESTION_TOOL
1003
+ : fingerprint === item.fingerprint || (env.event === 'PermissionDenied' && toolName === item.toolName)
1004
+ if (same) this.settle(item, 'retracted')
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ private ensurePolling(): void {
1010
+ if (this.poll || this.pendingCount() === 0) return
1011
+ this.poll = setInterval(() => { void this.tick() }, this.deps.pollMs ?? DESK_POLL_MS)
1012
+ this.poll.unref?.()
1013
+ }
1014
+
1015
+ private stopPolling(): void {
1016
+ if (this.poll) clearInterval(this.poll)
1017
+ this.poll = null
1018
+ this.deskUnreadableStreak = 0
1019
+ }
1020
+
1021
+ /**
1022
+ * One check while anything is held. A drain, the answering client going quiet, or
1023
+ * returning to the Mac hands everything back. An unreadable desk counts as the desk only
1024
+ * on the SECOND failed read in a row: one `ioreg` that times out under load must not
1025
+ * throw away a question someone is reading (QA round 1).
1026
+ */
1027
+ async tick(): Promise<void> {
1028
+ if (this.pendingCount() === 0) { this.stopPolling(); return }
1029
+ if (!this.deps.admissionsOpen()) { this.settleAll('drained'); return }
1030
+ if (!this.clientLive(this.deps.now())) { this.settleAll('no_client'); return }
1031
+ if (this.pollInFlight) return
1032
+ this.pollInFlight = true
1033
+ let idle: number | null = null
1034
+ try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null } finally { this.pollInFlight = false }
1035
+ if (idle === null) {
1036
+ this.counters.deskUnreadable++
1037
+ this.deskUnreadableStreak++
1038
+ if (this.deskUnreadableStreak >= 2) this.settleAll('handed_to_desk')
1039
+ return
1040
+ }
1041
+ this.deskUnreadableStreak = 0
1042
+ if (idle >= this.deskIdleThreshold()) return
1043
+ this.settleAll('handed_to_desk')
1044
+ }
1045
+
1046
+ private settleAll(resolution: 'handed_to_desk' | 'drained' | 'no_client'): void {
1047
+ for (const item of [...this.items.values()]) if (item.state === 'pending') this.settle(item, resolution)
1048
+ if (this.pendingCount() === 0) this.stopPolling()
1049
+ }
1050
+
1051
+ /** Settled items are kept for SETTLED_RETENTION_MS (late answers, replays), at most MAX_SETTLED. */
1052
+ private prune(): void {
1053
+ const now = this.deps.now()
1054
+ const settled: BrokerItem[] = []
1055
+ for (const item of this.items.values()) {
1056
+ if (item.state === 'pending') continue
1057
+ if (item.settledAt !== null && now - item.settledAt > SETTLED_RETENTION_MS) this.items.delete(item.id)
1058
+ else settled.push(item)
1059
+ }
1060
+ const excess = settled.length - MAX_SETTLED
1061
+ for (let i = 0; i < excess; i++) this.items.delete(settled[i]!.id)
1062
+ if (this.pendingCount() === 0) this.stopPolling()
1063
+ }
1064
+
1065
+ health(): PermissionBrokerHealth {
1066
+ const mode = this.deps.mode()
1067
+ let polledAt: number | null = null
1068
+ try { polledAt = this.deps.lastQuestionsPollAt() } catch { polledAt = null }
1069
+ return {
1070
+ enabled: mode !== 'off',
1071
+ mode,
1072
+ counters: { ...this.counters, fastPath: { ...this.fastPaths } },
1073
+ lastFastPath: this.lastFastPath,
1074
+ lastFastPathAt: isoOrNull(this.lastFastPathAt),
1075
+ lastQuestionsPollAt: isoOrNull(polledAt),
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * Counts, for the AUTHENTICATED questions route only (6.52.0 QA round 2): pending was
1081
+ * derivable from the public counters (parked minus every resolution), so none of them are
1082
+ * on the public `/api/health`.
1083
+ */
1084
+ stats(): { counters: Record<string, number>; fastPath: Record<string, number> } {
1085
+ return { counters: { ...this.counters }, fastPath: { ...this.fastPaths } }
1086
+ }
1087
+
1088
+ /** Shutdown: every held hook is answered `{}` (the native dialog) before the process goes. */
1089
+ stop(): void {
1090
+ this.settleAll('drained')
1091
+ this.stopPolling()
1092
+ }
1093
+ }
1094
+
1095
+ // ---------------------------------------------------------------------------
1096
+ // Wiring to the session signal store, and health
1097
+ // ---------------------------------------------------------------------------
1098
+
1099
+ type SignalStoreSeams = Pick<SessionSignalStore, 'attachPermissionRequestId' | 'detachPermissionRequestId' | 'resolveWaiting' | 'subscribe'>
1100
+
1101
+ /** The rows carry a held item's id; a decision is resolution evidence; anything else drops the id. */
1102
+ export function brokerSignalSink(store: SignalStoreSeams): BrokerSignalSink {
1103
+ return {
1104
+ attach: (sessionId, requestId, fingerprint) => { store.attachPermissionRequestId(sessionId, requestId, fingerprint) },
1105
+ detach: (sessionId, requestId) => { store.detachPermissionRequestId(sessionId, requestId) },
1106
+ decided: (sessionId, requestId, fingerprint) => { store.resolveWaiting(sessionId, { requestId, fingerprint }) },
1107
+ }
1108
+ }
1109
+
1110
+ /** Feed every applied hook event to the broker. Returns the unsubscribe. */
1111
+ export function wirePermissionBrokerToSignals(broker: PermissionBroker, store: SignalStoreSeams): () => void {
1112
+ return store.subscribe((_signal, env, child) => { broker.observeHookEvent(env, child) })
1113
+ }
1114
+
1115
+ let registered: PermissionBroker | null = null
1116
+
1117
+ export function registerPermissionBroker(broker: PermissionBroker | null): void {
1118
+ registered = broker
1119
+ }
1120
+
1121
+ /** `/api/health`: counts, the mode and timestamps only; never an id, a question or a command. */
1122
+ /** What the PUBLIC `/api/health` may show about the broker: the mode and two timestamps.
1123
+ * No counters (6.52.0 QA round 2): how many are held was derivable from them (parked minus
1124
+ * every resolution). The counts are on the authenticated `/api/session-questions` route. */
1125
+ export type PublicPermissionBrokerHealth = Omit<PermissionBrokerHealth, 'counters'>
1126
+
1127
+ export function permissionBrokerHealthFields(): { permissionBroker: PublicPermissionBrokerHealth | null } {
1128
+ if (!registered) return { permissionBroker: null }
1129
+ const { counters: _counters, ...visible } = registered.health()
1130
+ return { permissionBroker: visible }
1131
+ }
1132
+
1133
+ export interface SessionQuestionsCapability {
1134
+ enabled: boolean
1135
+ mode: BrokerMode
1136
+ protocolVersion: number
1137
+ pollIntervalMs: number
1138
+ liveWindowMs: number
1139
+ }
1140
+
1141
+ /**
1142
+ * `/api/models` `capabilities.sessionQuestions`: whether the questions API is there and
1143
+ * holding, and the client contract (poll `GET /api/session-questions?client=glasses|phone`
1144
+ * every `pollIntervalMs`; a client quiet for `liveWindowMs` hands everything back).
1145
+ */
1146
+ export function sessionQuestionsCapability(): SessionQuestionsCapability {
1147
+ let mode: BrokerMode = 'off'
1148
+ try { mode = registered ? registered.health().mode : 'off' } catch { mode = 'off' }
1149
+ return {
1150
+ enabled: mode !== 'off',
1151
+ mode,
1152
+ protocolVersion: SESSION_QUESTIONS_PROTOCOL_VERSION,
1153
+ pollIntervalMs: QUESTIONS_POLL_INTERVAL_MS,
1154
+ liveWindowMs: CLIENT_LIVE_WINDOW_MS,
1155
+ }
1156
+ }