@gotcos/glasses-server 7.0.7 → 7.0.18

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 (87) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +170 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +126 -23
  8. package/server/lib/agent-session-recent.ts +321 -0
  9. package/server/lib/agent-session-store.ts +3 -3
  10. package/server/lib/api-auth.ts +37 -1
  11. package/server/lib/calendar-meetings.ts +70 -0
  12. package/server/lib/calendar-source.ts +54 -9
  13. package/server/lib/claude-hooks-installer.ts +111 -12
  14. package/server/lib/conversation.ts +56 -28
  15. package/server/lib/cors-reject-log.ts +51 -0
  16. package/server/lib/disk-io-meter.ts +202 -0
  17. package/server/lib/drop-service.ts +47 -12
  18. package/server/lib/drop-store.ts +21 -1
  19. package/server/lib/event-loop-stall.ts +62 -0
  20. package/server/lib/full-hold-engine.ts +114 -0
  21. package/server/lib/full-hold.ts +124 -0
  22. package/server/lib/g2-turn-provenance.ts +128 -9
  23. package/server/lib/listener-startup.ts +18 -0
  24. package/server/lib/meeting-batch-transcribe.ts +32 -5
  25. package/server/lib/permission-broker.ts +443 -11
  26. package/server/lib/secret-path-guard.ts +4 -1
  27. package/server/lib/session-cache-writer.ts +8 -1
  28. package/server/lib/session-hook-events.ts +23 -1
  29. package/server/lib/session-hooks-runtime.ts +34 -0
  30. package/server/lib/session-log.ts +313 -4
  31. package/server/lib/session-state-derive.ts +36 -0
  32. package/server/lib/session-stream-events.ts +25 -1
  33. package/server/lib/today-brief.ts +522 -58
  34. package/server/lib/turn-end-ask.ts +320 -0
  35. package/server/lib/turn-footer.ts +3 -3
  36. package/server/lib/web-control-integrity.ts +160 -0
  37. package/server/lib/web-control-offnet-page.ts +58 -0
  38. package/server/lib/web-control-static.ts +53 -9
  39. package/server/lib/web-key-authority.ts +372 -0
  40. package/server/lib/web-key-envelope.ts +98 -0
  41. package/server/lib/web-key-handoff.ts +135 -0
  42. package/server/lib/web-key-origin.ts +21 -0
  43. package/server/lib/web-key-passkey.ts +155 -0
  44. package/server/lib/web-key-registry.ts +825 -0
  45. package/server/lib/web-key-signer-app.ts +152 -0
  46. package/server/lib/web-session-auth.ts +133 -15
  47. package/server/lib/whisper-local.ts +93 -7
  48. package/server/lib/whisper-preview.ts +5 -3
  49. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  50. package/server/lib/work-review-runtime.ts +17 -4
  51. package/server/routes/agent-session-bindings.ts +16 -1
  52. package/server/routes/agent-session-stream.ts +7 -0
  53. package/server/routes/drops.ts +7 -2
  54. package/server/routes/g2-turns.ts +35 -0
  55. package/server/routes/health.ts +11 -1
  56. package/server/routes/live-events.ts +6 -1
  57. package/server/routes/permission-broker.ts +26 -0
  58. package/server/routes/query-jobs.ts +3 -0
  59. package/server/routes/sessions.ts +15 -24
  60. package/server/routes/thread-turn-queue.ts +16 -9
  61. package/server/routes/today-brief.ts +91 -36
  62. package/server/routes/web-control.ts +37 -4
  63. package/server/routes/web-key.ts +284 -0
  64. package/server/routes/welcome-context.ts +6 -2
  65. package/server/routes/work-orchestrator.ts +12 -7
  66. package/server/scripts/write-web-control-manifest.mjs +43 -0
  67. package/server/web-control/.manifest.json +44 -0
  68. package/server/web-control/app.css +36 -0
  69. package/server/web-control/app.js +953 -57
  70. package/server/web-control/apple-touch-icon.png +0 -0
  71. package/server/web-control/index.html +14 -1
  72. package/server/web-control/panes/cos-chat.css +52 -6
  73. package/server/web-control/panes/cos-chat.js +364 -32
  74. package/server/web-control/panes/questions.js +135 -14
  75. package/server/web-control/panes/settings.js +1 -1
  76. package/server/web-control/panes/today-actions.css +35 -0
  77. package/server/web-control/panes/today-actions.js +75 -6
  78. package/server/web-control/panes/work.css +3 -0
  79. package/server/web-control/panes/work.js +178 -35
  80. package/server/web-signer/.manifest.json +11 -0
  81. package/server/web-signer/index.html +74 -0
  82. package/server/web-signer/signer-proof.js +348 -0
  83. package/server/web-signer/signer-store.js +39 -0
  84. package/server/web-signer/signer.css +23 -0
  85. package/server/web-signer/signer.js +341 -0
  86. package/shared/web-approval-packets.golden.json +124 -0
  87. package/shared/web-turn-envelope.ts +271 -0
@@ -97,7 +97,21 @@ const filesLine = (n: number): string => n === 1 ? 'The file stays here in the C
97
97
  const nextAsk = (drop: DropRecord): DropNextAsk =>
98
98
  ({ kind: 'ask', prompt: drop.text, ...(drop.attachments?.length ? { attachmentIds: drop.attachments.map(a => a.id) } : {}) })
99
99
  /** One item that waits on an answer: a live question, or a live item marked needsReply (6.99.10). */
100
- export const isWaiting = (i: FeedItem): boolean => i.state === 'live' && (i.kind === 'question' || i.needsReply === true)
100
+ /**
101
+ * 7.0.9 (Miles 11/08, "Expire after 7 days"): an item that asks for a reply stops waiting on you this long after it
102
+ * was asked (its own createdAt, never when it was read). It is not answered or closed: it moves to the chat's older
103
+ * questions (GET /api/inbox/archive), where it can still be read and answered, and answering it there clears it.
104
+ * The one rule for every count: `asked`, the chat part of `waiting`, `archived`, and the web's split.
105
+ */
106
+ export const WAITING_EXPIRY_DAYS = 7
107
+ export const WAITING_EXPIRY_MS = WAITING_EXPIRY_DAYS * 86_400_000
108
+ const asksForReply = (i: FeedItem): boolean => i.state === 'live' && (i.kind === 'question' || i.needsReply === true)
109
+ /** How long ago it was asked, in ms; an unreadable time reads as just asked (it keeps waiting rather than vanish). */
110
+ const askedAgo = (i: FeedItem, nowMs: number): number => { const t = Date.parse(i.createdAt); return Number.isFinite(t) ? nowMs - t : 0 }
111
+ export const isWaiting = (i: FeedItem, nowMs: number = Date.now()): boolean => asksForReply(i) && askedAgo(i, nowMs) < WAITING_EXPIRY_MS
112
+ /** Asks for a reply, but was asked WAITING_EXPIRY_DAYS or more ago: shown under older questions, never counted. */
113
+ export const isArchivedQuestion = (i: FeedItem, nowMs: number = Date.now()): boolean => asksForReply(i) && askedAgo(i, nowMs) >= WAITING_EXPIRY_MS
114
+ export const ARCHIVE_LIST_MAX = 200
101
115
  const A = {
102
116
  undo: { id: 'undo', label: 'Undo' }, move: { id: 'move', label: 'Move' },
103
117
  openCard: { id: 'open_card', label: 'Open card' }, openThread: { id: 'open_thread', label: 'Open thread' },
@@ -559,14 +573,24 @@ export class DropService {
559
573
  * waits on an answer from you, and clears only when it is answered, done or dismissed: every live question COS asked
560
574
  * (a router ask), every live item marked `needsReply` (a follow-up that needs a decision, a ping that asks), plus
561
575
  * `yourMove`, the Work items in Your move, when the caller could count them. Always a whole number, never negative.
576
+ *
577
+ * 7.0.9 (Miles 11/08: the web rail said 99+ and the pinned row "286 waiting" for things he had already seen): two
578
+ * more fields, so a client can say what each number is. `asked` is the chat's own part of `waiting`, the live
579
+ * questions and needsReply items, which truly wait on a reply in this chat. `yourMove` is the Work part (null when
580
+ * it could not be counted). `waiting` keeps its 6.99.10 meaning for COS Control, which reads it by that name.
562
581
  */
563
- summary(extra: { yourMove?: number | null } = {}): { unread: number; waiting: number; lastLine: string; lastAt: string | null; version: number } {
582
+ summary(extra: { yourMove?: number | null } = {}): { unread: number; waiting: number; asked: number; archived: number; waitingExpiryDays: number; yourMove: number | null; lastLine: string; lastAt: string | null; version: number } {
564
583
  const items = this.deps.feed.list()
584
+ const nowMs = this.now().getTime()
565
585
  const unread = items.filter(i => i.readAt === null && (i.kind === 'ping' || i.kind === 'followup' || i.kind === 'question')).length
566
- const asked = items.filter(i => isWaiting(i)).length
567
- const work = typeof extra.yourMove === 'number' && Number.isSafeInteger(extra.yourMove) && extra.yourMove > 0 ? extra.yourMove : 0
586
+ // 7.0.9: a question asked WAITING_EXPIRY_DAYS ago or more is in older questions, out of `asked` and so out of the
587
+ // chat part of `waiting`. Work's part of `waiting` (yourMove) is untouched.
588
+ const asked = items.filter(i => isWaiting(i, nowMs)).length
589
+ const archived = items.filter(i => isArchivedQuestion(i, nowMs)).length
590
+ const counted = typeof extra.yourMove === 'number' && Number.isSafeInteger(extra.yourMove) && extra.yourMove >= 0
591
+ const work = counted && (extra.yourMove as number) > 0 ? extra.yourMove as number : 0
568
592
  const last = items[items.length - 1]
569
- return { unread, waiting: asked + work, lastLine: last ? short(pinLine(last), 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
593
+ return { unread, waiting: asked + work, asked, archived, waitingExpiryDays: WAITING_EXPIRY_DAYS, yourMove: counted ? extra.yourMove as number : null, lastLine: last ? short(pinLine(last), 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
570
594
  }
571
595
 
572
596
  /**
@@ -574,7 +598,7 @@ export class DropService {
574
598
  * its buttons. Only an item marked `needsReply` can be dismissed; a question is answered by one of its choices
575
599
  * (Just a note among them). Idempotent: a second dismiss answers the same.
576
600
  */
577
- dismiss(itemId: string): { unread: number; waiting: number; version: number } {
601
+ dismiss(itemId: string): { unread: number; waiting: number; asked: number; version: number } {
578
602
  const item = this.deps.feed.get(itemId)
579
603
  if (!item) throw new DropError('feed_item_not_found', 404)
580
604
  if (!item.needsReply) throw new DropError('not_dismissable', 409, 'Only something that waits on you can be dismissed.')
@@ -583,11 +607,21 @@ export class DropService {
583
607
  this.deps.feed.update(new Set([item.id]), i => ({ ...i, state: 'done', actions: [], readAt: i.readAt ?? at }))
584
608
  }
585
609
  const s = this.summary()
586
- return { unread: s.unread, waiting: s.waiting, version: s.version }
610
+ return { unread: s.unread, waiting: s.waiting, asked: s.asked, version: s.version }
611
+ }
612
+
613
+ /**
614
+ * 7.0.9: the older questions, newest first: what still asks for a reply but was asked WAITING_EXPIRY_DAYS or more
615
+ * ago. Each answers exactly as in the chat (POST /api/inbox/action), and answering it takes it off this list.
616
+ */
617
+ archive(): { items: Array<FeedItem & { archived: true }>; total: number; waitingExpiryDays: number; version: number } {
618
+ const nowMs = this.now().getTime()
619
+ const all = this.deps.feed.list().filter(i => isArchivedQuestion(i, nowMs)).reverse()
620
+ return { items: all.slice(0, ARCHIVE_LIST_MAX).map(i => ({ ...i, archived: true as const })), total: all.length, waitingExpiryDays: WAITING_EXPIRY_DAYS, version: this.deps.feed.version() }
587
621
  }
588
622
 
589
- /** The chat, oldest first. `after` is an item id: only items after it. */
590
- feed(options: { after?: string; limit?: number } = {}): { items: FeedItem[]; version: number; more: boolean } {
623
+ /** The chat, oldest first. `after` is an item id: only items after it. 7.0.9: an older question carries `archived: true`. */
624
+ feed(options: { after?: string; limit?: number } = {}): { items: Array<FeedItem & { archived?: true }>; version: number; more: boolean } {
591
625
  const items = this.deps.feed.list()
592
626
  let start = 0
593
627
  if (options.after) {
@@ -599,7 +633,8 @@ export class DropService {
599
633
  const rest = items.slice(start)
600
634
  // Without `after` the newest page; with it, the next page forward.
601
635
  const page = options.after ? rest.slice(0, limit) : rest.slice(-limit)
602
- return { items: page, version: this.deps.feed.version(), more: options.after ? rest.length > limit : rest.length > limit }
636
+ const nowMs = this.now().getTime()
637
+ return { items: page.map(i => isArchivedQuestion(i, nowMs) ? { ...i, archived: true as const } : i), version: this.deps.feed.version(), more: options.after ? rest.length > limit : rest.length > limit }
603
638
  }
604
639
 
605
640
  /** The drop a chat item belongs to. */
@@ -609,7 +644,7 @@ export class DropService {
609
644
  return item.threadId
610
645
  }
611
646
 
612
- markRead(through: string): { unread: number; waiting: number; version: number } {
647
+ markRead(through: string): { unread: number; waiting: number; asked: number; version: number } {
613
648
  const items = this.deps.feed.list()
614
649
  const index = items.findIndex(i => i.id === through)
615
650
  if (index < 0) throw new DropError('feed_item_not_found', 404)
@@ -618,7 +653,7 @@ export class DropService {
618
653
  if (ids.size) this.deps.feed.update(ids, item => ({ ...item, readAt: at }))
619
654
  const s = this.summary()
620
655
  // Opening the chat clears unread only: what waits on you stays until it is answered, done or dismissed.
621
- return { unread: s.unread, waiting: s.waiting, version: s.version }
656
+ return { unread: s.unread, waiting: s.waiting, asked: s.asked, version: s.version }
622
657
  }
623
658
 
624
659
  /** History (plan section 6), P1: words over drop text and link labels, with kind and status filters, newest first. */
@@ -387,6 +387,13 @@ export class InboxFeedStore {
387
387
  private items: FeedItem[] = []
388
388
  private quarantined: unknown[] = []
389
389
  private versionNo = 0
390
+ /**
391
+ * 7.0.9 (Miles 11/08, "99 plus messages, despite the fact that I've already seen all of those"): the moment this
392
+ * feed started counting unread as net new. Every item still unread when the first build with it opened the file is
393
+ * marked read once, at that open, so the rail never counts what was already on screen. A file this build wrote
394
+ * carries it; a file without it (an older build wrote it last) is migrated on open.
395
+ */
396
+ private readFloorAt: string
390
397
  private readonly path: string
391
398
  private readonly lock: ServerInstanceLock
392
399
  private closed = false
@@ -400,6 +407,19 @@ export class InboxFeedStore {
400
407
  this.versionNo = Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : this.items.length
401
408
  } catch (e) { this.close(); throw e }
402
409
  if (this.quarantined.length) console.warn(`[inbox] ${this.quarantined.length} unreadable feed item(s) set aside`)
410
+ const floor = opened.journal?.readFloorAt
411
+ this.readFloorAt = this.now().toISOString()
412
+ if (typeof floor === 'string' && Number.isFinite(Date.parse(floor))) this.readFloorAt = floor
413
+ else if (opened.journal) this.migrateReadFloor()
414
+ }
415
+ /** When unread started meaning net new on this feed (see readFloorAt). */
416
+ readFloor(): string { return this.readFloorAt }
417
+ /** Once per file: what was unread before this build opened it had already been shown, so it reads as read now. */
418
+ private migrateReadFloor(): void {
419
+ const at = this.readFloorAt
420
+ const next = this.items.map(i => i.readAt === null ? { ...i, readAt: at } : i)
421
+ // A file that cannot be written now still opens; the next open tries again. Unread never blocks the chat.
422
+ try { this.write(next, 1) } catch (e) { console.warn(`[inbox] read floor not written: ${(e as Error).message}`) }
403
423
  }
404
424
  /** Oldest first, as a chat reads. */
405
425
  list(): FeedItem[] { return structuredClone(this.items) }
@@ -455,7 +475,7 @@ export class InboxFeedStore {
455
475
  if (kept.length > DROP_LIMITS.feed) throw new DropStoreError('inbox_store_full', 503)
456
476
  const version = this.versionNo + bump
457
477
  // Never write a file the reader would refuse: the oldest COS lines (receipts, answers, pings) go first.
458
- const serialize = () => JSON.stringify({ version: 1, feedVersion: version, items: kept, quarantined: this.quarantined }) + '\n'
478
+ const serialize = () => JSON.stringify({ version: 1, feedVersion: version, readFloorAt: this.readFloorAt, items: kept, quarantined: this.quarantined }) + '\n'
459
479
  let body = serialize()
460
480
  while (Buffer.byteLength(body) > this.maxBytes) {
461
481
  const oldest = kept.findIndex(i => i.kind !== 'drop')
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Event-loop stall detector (S1, 2026-11-09). Logs only when the server's event loop was blocked.
3
+ *
4
+ * Why: COS Control's dictation asks GET /api/transcribe/capabilities with a 5 s timeout. Six
5
+ * dictations (11/03 13:01 CST, 11/04 15:32, and four between 18:12 and 18:32 on 11/04) failed at
6
+ * 5,052 to 5,072 ms with no answer, logged by Control as "refused:unknown". That route is a
7
+ * constant JSON object, so a 5 s silence means the process did not get to it: a blocked event
8
+ * loop, a process mid-restart, or a Mac under memory pressure. Nothing in the server logs could
9
+ * tell those apart. This line can:
10
+ *
11
+ * [event-loop] stalled 6230 ms (rss 1840 MB, heap 910 MB; 1 stall(s) over 2000 ms since boot)
12
+ *
13
+ * A 500 ms unref'd timer measures its own lateness. A late tick above the threshold is one stall.
14
+ * At most one line per 10 s; stalls in between are counted into the next line.
15
+ */
16
+ export const STALL_SAMPLE_MS = 500
17
+ export const STALL_THRESHOLD_MS = 2_000
18
+ const LOG_EVERY_MS = 10_000
19
+
20
+ export interface StallMonitor {
21
+ stop(): void
22
+ stats(): { stalls: number; worstMs: number }
23
+ }
24
+
25
+ export function startEventLoopStallMonitor(options: {
26
+ sampleMs?: number
27
+ thresholdMs?: number
28
+ now?: () => number
29
+ log?: (line: string) => void
30
+ memory?: () => { rss: number; heapUsed: number }
31
+ } = {}): StallMonitor {
32
+ const sampleMs = options.sampleMs ?? STALL_SAMPLE_MS
33
+ const thresholdMs = options.thresholdMs ?? STALL_THRESHOLD_MS
34
+ const now = options.now ?? (() => performance.now())
35
+ const log = options.log ?? (line => console.warn(line))
36
+ const memory = options.memory ?? (() => process.memoryUsage())
37
+ let last = now()
38
+ let stalls = 0
39
+ let worstMs = 0
40
+ let lastLogAt = -Infinity
41
+ let unlogged = 0
42
+ const timer = setInterval(() => {
43
+ const at = now()
44
+ const lateMs = at - last - sampleMs
45
+ last = at
46
+ if (lateMs < thresholdMs) return
47
+ stalls++
48
+ worstMs = Math.max(worstMs, lateMs)
49
+ if (at - lastLogAt < LOG_EVERY_MS) { unlogged++; return }
50
+ lastLogAt = at
51
+ const m = memory()
52
+ const extra = unlogged ? `; ${unlogged} more since the last line` : ''
53
+ unlogged = 0
54
+ log(`[event-loop] stalled ${Math.round(lateMs)} ms (rss ${Math.round(m.rss / 1e6)} MB, heap ${Math.round(m.heapUsed / 1e6)} MB; `
55
+ + `${stalls} stall(s) over ${thresholdMs} ms since boot${extra})`)
56
+ }, sampleMs)
57
+ timer.unref?.()
58
+ return {
59
+ stop: () => clearInterval(timer),
60
+ stats: () => ({ stalls, worstMs: Math.round(worstMs) }),
61
+ }
62
+ }
@@ -0,0 +1,114 @@
1
+ // X1 (Q1 review O2, 2026-11-09): WHICH engine asked, read from the process that ran the hook, never
2
+ // from the session id alone.
3
+ //
4
+ // The hook script posts its own `$PPID`, which is the Claude Code process that ran it (measured: a
5
+ // CLI 2.1.322 run and the Desktop engine 2.1.317 both, `~/.claude/sessions/<ppid>.json` names the
6
+ // same session). Reading the record for the session id instead lets a second process that shares the
7
+ // id (an older CLI, a `claude -p --resume <id>`) inherit a tab's version (Q1 O2).
8
+ //
9
+ // The entrypoint is not enough either. `claude -p` takes `CLAUDE_CODE_ENTRYPOINT` from whatever
10
+ // started it: a print run launched from a Desktop tab's shell registers as `claude-desktop`, and its
11
+ // `kind` reads `interactive` (measured 22:05). A print run has no dialog, and it does reach the
12
+ // PermissionRequest hook for an approval and waits on it (measured: a 20 s hold blocked it 20 s), so a
13
+ // full hold there would stall a headless job for up to an hour. What does tell them apart is where
14
+ // the process runs (census of 25 live sessions, 22:06):
15
+ // - a Desktop tab's engine is a child of Claude.app's own `Contents/Helpers/disclaimer`;
16
+ // - an interactive CLI has a controlling terminal (`ttys000`) and none of the print or SDK flags;
17
+ // - a print run started from a shell or a tool has neither.
18
+ // Q1 C3 (Codex second opinion), measured 22:42: a terminal alone is not enough. A `claude -p` run under
19
+ // a terminal (typed there, or started by a CLI session's own Bash tool, whose children inherit its
20
+ // terminal) has a tty too, and `-p` in its arguments. So a terminal engine must also carry none of
21
+ // `-p`, `--print`, `--input-format`, `--output-format` (an SDK host passes the last two). Miles's two
22
+ // interactive CLI sessions carried none of them.
23
+ // Anything that cannot be read is `other`, and `other` is never proven.
24
+
25
+ import { execFile } from 'node:child_process'
26
+ import { readFileSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { join } from 'node:path'
29
+ import type { EngineFacts, EngineHost } from './full-hold.js'
30
+
31
+ export function claudeSessionsRegistryDir(): string {
32
+ const configDir = process.env.CLAUDE_CONFIG_DIR ? process.env.CLAUDE_CONFIG_DIR : join(homedir(), '.claude')
33
+ return join(configDir, 'sessions')
34
+ }
35
+
36
+ /** `~/.claude/sessions/<pid>.json` parsed, only when it names `sessionId` (and, if it says, that pid). Bounded; null on anything else. */
37
+ function readRecord(pid: number, sessionId: string, dir: string): Record<string, unknown> | null {
38
+ if (!Number.isInteger(pid) || pid <= 1) return null
39
+ try {
40
+ const text = readFileSync(join(dir, `${pid}.json`), 'utf-8')
41
+ if (text.length > 256 * 1024) return null
42
+ const raw = JSON.parse(text) as Record<string, unknown>
43
+ if (typeof raw.sessionId !== 'string' || raw.sessionId.toLowerCase() !== sessionId.toLowerCase()) return null
44
+ if (typeof raw.pid === 'number' && raw.pid !== pid) return null
45
+ return raw
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ /** The engine's entrypoint and version from its own record. */
52
+ export function registryRecordForPid(pid: number, sessionId: string, dir = claudeSessionsRegistryDir()): { entrypoint: string | null; version: string | null } | null {
53
+ const raw = readRecord(pid, sessionId, dir)
54
+ if (!raw) return null
55
+ return {
56
+ entrypoint: typeof raw.entrypoint === 'string' && raw.entrypoint ? raw.entrypoint : null,
57
+ version: typeof raw.version === 'string' && raw.version ? raw.version.slice(0, 32) : null,
58
+ }
59
+ }
60
+
61
+ type PsRun = (args: string[]) => Promise<string | null>
62
+
63
+ /** A flag only a print run or an SDK host passes (Q1 C3). */
64
+ const PRINT_OR_SDK_FLAG = /^(?:-p|--print|--input-format(?:=.*)?|--output-format(?:=.*)?)$/
65
+
66
+ const runPs: PsRun = args => new Promise(resolve => {
67
+ try {
68
+ execFile('/bin/ps', args, { timeout: 1_000, maxBuffer: 64 * 1024 }, (error, stdout) => resolve(error ? null : String(stdout)))
69
+ } catch {
70
+ resolve(null)
71
+ }
72
+ })
73
+
74
+ /** Where a Claude Code process runs: Claude.app's helper, a terminal, or neither. */
75
+ export async function processHost(pid: number, ps: PsRun = runPs): Promise<EngineHost> {
76
+ if (!Number.isInteger(pid) || pid <= 1) return 'other'
77
+ const own = await ps(['-o', 'ppid=,tty=', '-p', String(pid)])
78
+ if (!own) return 'other'
79
+ const [ppidRaw, tty] = own.trim().split(/\s+/)
80
+ const ppid = Number(ppidRaw)
81
+ if (tty && tty !== '??' && tty !== '-' && /^tty/.test(tty)) {
82
+ const args = await ps(['-o', 'args=', '-p', String(pid)])
83
+ if (args === null) return 'other'
84
+ return args.trim().split(/\s+/).some(token => PRINT_OR_SDK_FLAG.test(token)) ? 'other' : 'terminal'
85
+ }
86
+ if (!Number.isInteger(ppid) || ppid <= 1) return 'other'
87
+ const parent = await ps(['-o', 'comm=', '-p', String(ppid)])
88
+ const path = parent ? parent.trim() : ''
89
+ return /\/Claude\.app\/Contents\/Helpers\/disclaimer$/.test(path) ? 'desktop-app' : 'other'
90
+ }
91
+
92
+ /**
93
+ * X1 round 3: the engine's own status now (`waiting` while its dialog is up), from the same record, or
94
+ * null when the record is gone (the process ended) or names another session (the pid was reused).
95
+ * Measured 22:39 on both engines: `waiting` is written 0 to 5 ms before the PermissionRequest hook
96
+ * starts and leaves `waiting` within milliseconds of an answer at the desk (`busy`, or `idle` on Esc);
97
+ * the record is removed when the process exits.
98
+ */
99
+ export function registryStatusForPid(pid: number, sessionId: string, dir = claudeSessionsRegistryDir()): { status: string | null; statusUpdatedAt: number | null } | null {
100
+ const raw = readRecord(pid, sessionId, dir)
101
+ if (!raw) return null
102
+ return {
103
+ status: typeof raw.status === 'string' ? raw.status : null,
104
+ statusUpdatedAt: typeof raw.statusUpdatedAt === 'number' && Number.isFinite(raw.statusUpdatedAt) ? raw.statusUpdatedAt : null,
105
+ }
106
+ }
107
+
108
+ /** The engine facts the full hold reads, for the process that ran the hook. Null when its record does not name the session. */
109
+ export async function engineForHookProcess(sessionId: string, ppid: number | null, deps: { dir?: string; ps?: PsRun } = {}): Promise<EngineFacts | null> {
110
+ if (ppid === null) return null
111
+ const record = registryRecordForPid(ppid, sessionId, deps.dir)
112
+ if (!record) return null
113
+ return { ...record, host: await processHost(ppid, deps.ps) }
114
+ }
@@ -0,0 +1,124 @@
1
+ // X1 (2026-11-09): the full hold. A session question (AskUserQuestion) or a tool approval held for
2
+ // the WHOLE wait of the PermissionRequest hook, whether or not a COS device is live when it is asked.
3
+ //
4
+ // WHY THIS IS SAFE AT THE DESK (canaries, operations/personal/wk46_2026/production_hardening/
5
+ // x1_question_fetch/CANARIES.md): on the engines below, Claude Code shows its own question or
6
+ // approval dialog at once WHILE the PermissionRequest hook is still waiting (C1, C5). An answer at
7
+ // the Mac wins and a later hook answer is ignored (C3, C4); a hook answer withdraws the dialog (C2;
8
+ // the desktop engine sends `control_cancel_request` to the app, C6). A hold of 3,703 s worked (C7).
9
+ // So holding the hook never delays the Mac's own prompt (Miles's option B, 11/05) ON THESE ENGINES.
10
+ // An engine that is not listed, or reads older, keeps today's rule: it may not show its dialog
11
+ // until the hook returns, and a long hold there would keep the desk waiting.
12
+ //
13
+ // Both switches default OFF. With both off nothing in this file is read on the permission path.
14
+ //
15
+ // Pure, no imports beyond the one switch reader.
16
+
17
+ import { readSwitch } from './env-switch.js'
18
+
19
+ /** `COS_QUESTION_FULL_HOLD`: hold a question for the whole hook wait. Default OFF. Read per request. */
20
+ export function questionFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
21
+ return readSwitch(env.COS_QUESTION_FULL_HOLD, false)
22
+ }
23
+
24
+ /** `COS_APPROVAL_FULL_HOLD`: hold a tool approval for the whole hook wait while the desk stays idle. Default OFF. */
25
+ export function approvalFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
26
+ return readSwitch(env.COS_APPROVAL_FULL_HOLD, false)
27
+ }
28
+
29
+ /**
30
+ * `COS_WAITING_QUESTION_WORDS`: a session waiting on a question carries the first 80 characters of its
31
+ * first question (`waiting_detail`, through `toolTarget`), so Today and the Sessions rows say what it
32
+ * asks. Default ON: Miles, 2026-11-09 22:10 CST, "Yes, show by default", told that the text is written
33
+ * to the on-disk session-hook ledger and sent to any signed-in browser, including for questions
34
+ * answered at the desk. `0` restores 7.0.13's empty detail exactly. Question text only: never an
35
+ * option, a description or an answer. The `waiting` list with full words and options is NOT this
36
+ * switch: it is on only while a full-hold switch is.
37
+ */
38
+ export function waitingQuestionWordsEnabled(env: NodeJS.ProcessEnv): boolean {
39
+ return readSwitch(env.COS_WAITING_QUESTION_WORDS, true)
40
+ }
41
+
42
+ /** Control and format characters (bidi overrides, zero-width) taken out of a question before it becomes row text. */
43
+ export function questionRowText(question: unknown): string {
44
+ if (typeof question !== 'string') return ''
45
+ return question.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
46
+ }
47
+
48
+ export interface FullHoldSwitches { questions: boolean; approvals: boolean }
49
+
50
+ export function fullHoldSwitches(env: NodeJS.ProcessEnv): FullHoldSwitches {
51
+ return { questions: questionFullHoldEnabled(env), approvals: approvalFullHoldEnabled(env) }
52
+ }
53
+
54
+ /**
55
+ * The oldest engine of each kind proven to show its own dialog while the hook waits, by the
56
+ * registry record's `entrypoint` and `version` (`~/.claude/sessions/<pid>.json`).
57
+ * - `cli` 2.1.321: canaries C1 to C4, C7, C9 (2026-11-09, tmux, real TTY).
58
+ * - `claude-desktop` 2.1.317: the engine Claude Desktop runs, driven over its stream-json
59
+ * protocol, C5, C6, C9 (and the 6.71.7 Desktop D1 canary of 2026-10-11 in the real app).
60
+ * Anything else (`sdk-cli`, an unknown entrypoint, no version) is not proven.
61
+ */
62
+ export const FULL_HOLD_ENGINE_FLOORS: Readonly<Record<string, readonly number[]>> = Object.freeze({
63
+ cli: Object.freeze([2, 1, 321]),
64
+ 'claude-desktop': Object.freeze([2, 1, 317]),
65
+ })
66
+
67
+ /** Where the engine runs (`lib/full-hold-engine.ts` `processHost`): Claude.app's helper, a terminal, or neither. */
68
+ export type EngineHost = 'desktop-app' | 'terminal' | 'other'
69
+
70
+ export interface EngineFacts { entrypoint: string | null; version: string | null; host?: EngineHost }
71
+
72
+ /**
73
+ * Q1 O2: the host each entrypoint must run in. A `claude -p` print run takes its entrypoint from
74
+ * whatever launched it, has no dialog, and runs under neither (`lib/full-hold-engine.ts`).
75
+ */
76
+ export const FULL_HOLD_ENGINE_HOSTS: Readonly<Record<string, EngineHost>> = Object.freeze({
77
+ cli: 'terminal',
78
+ 'claude-desktop': 'desktop-app',
79
+ })
80
+
81
+ /** `2.1.321` as numbers; null for anything that is not one to four dot-separated whole numbers. */
82
+ export function parseEngineVersion(version: unknown): number[] | null {
83
+ if (typeof version !== 'string') return null
84
+ const trimmed = version.trim()
85
+ if (!/^\d{1,6}(?:\.\d{1,6}){0,3}$/.test(trimmed)) return null
86
+ return trimmed.split('.').map(Number)
87
+ }
88
+
89
+ /** True when `version` is at or above `floor`, part by part (a missing part reads 0). */
90
+ export function versionAtLeast(version: readonly number[], floor: readonly number[]): boolean {
91
+ const length = Math.max(version.length, floor.length)
92
+ for (let i = 0; i < length; i++) {
93
+ const v = version[i] ?? 0
94
+ const f = floor[i] ?? 0
95
+ if (v !== f) return v > f
96
+ }
97
+ return true
98
+ }
99
+
100
+ /** Whether this engine is proven to keep its dialog on screen while the hook waits, and runs where that dialog is. */
101
+ export function engineProvenForFullHold(engine: EngineFacts | null | undefined): boolean {
102
+ if (!engine || typeof engine.entrypoint !== 'string') return false
103
+ if (!Object.prototype.hasOwnProperty.call(FULL_HOLD_ENGINE_FLOORS, engine.entrypoint)) return false
104
+ if (engine.host !== FULL_HOLD_ENGINE_HOSTS[engine.entrypoint]) return false
105
+ const floor = FULL_HOLD_ENGINE_FLOORS[engine.entrypoint]!
106
+ const version = parseEngineVersion(engine.version)
107
+ return version !== null && versionAtLeast(version, floor)
108
+ }
109
+
110
+ /** The PermissionRequest wait the installer writes for a kind under the full hold. */
111
+ export const FULL_HOLD_HOOK_WAIT_S = 3600
112
+
113
+ /**
114
+ * The matcher of the non-question PermissionRequest block once the hook is split (canary C9:
115
+ * honoured on both engines; a question runs only the `AskUserQuestion` block, everything else
116
+ * only this one).
117
+ */
118
+ export const NON_QUESTION_MATCHER = '^(?!AskUserQuestion$).*'
119
+ export const QUESTION_MATCHER = 'AskUserQuestion'
120
+
121
+ /** How a web (browser session) answer proves a person just touched the page, while a flag is on. */
122
+ export const WEB_ANSWER_TOUCH_MAX_MS = 60_000
123
+ /** How long a web approval's confirm step stays usable. */
124
+ export const WEB_CONFIRM_TTL_MS = 60_000