@gotcos/glasses-server 7.0.7 → 7.0.27

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 (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -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 +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Sessions a COS job started in the last 7 days (Miles, 2026-11-10 23:16, via the lead: "Yes, keep for 7 days").
3
+ *
4
+ * The 0b spike found that a session started from Work or Ask drops out of GET /api/agent-sessions within hours on a
5
+ * busy Mac (the per-agent cap of 20 showed only the newest 4.2 hours of Claude sessions). The list keeps these, like a
6
+ * COS pin, inside the same bounded keep ceiling (agent-session-store.ts KEPT_LIST_MAX).
7
+ *
8
+ * Read from the query-job journal (data/query-jobs/<YYYY-MM-DD>.jsonl): each job's `running` or `completed` record
9
+ * names its provider and native session (`cliSessionId` for Claude and Cursor, `codexThreadId` for Codex). Only ids,
10
+ * times and the request's kind are read, never prompts or answers.
11
+ *
12
+ * Only jobs a PERSON started count (Q1, 2026-11-10 23:3x). Every job records a session id (54 of 54 in the 8 days
13
+ * measured), so the job's `accepted` record decides. It is an ALLOWLIST (Q1's nit, lead 23:44), so a new kind of
14
+ * background job fails safe: a job counts only when its request has no `origin` (Ask from phone, lens or web, Work
15
+ * starts, Continue) or `origin.kind === 'task'` (a dispatched task), and no `maintenanceWorkflow`. That leaves out
16
+ * the scheduler's routines (the morning brief: 6 of 54), meeting consolidation (2 of 54) and any kind added later.
17
+ * A session id whose job's request record is not in the window is left out too (only a job that ran for more than
18
+ * a day; failing safe). Each day file is memoized on mtime and size, and a file that keeps
19
+ * changing (a job writing its deltas) is re-read at most every 30 seconds, so the 5-second list poll stays cheap.
20
+ */
21
+ import { readdir, readFile, stat } from 'node:fs/promises'
22
+ import { join } from 'node:path'
23
+ import { dataPath } from './data-dir.js'
24
+
25
+ export const COS_STARTED_WINDOW_MS = 7 * 24 * 3600 * 1000
26
+ export const COS_STARTED_REREAD_MS = 30_000
27
+ type Provider = 'claude' | 'codex' | 'cursor'
28
+ interface Seen { provider: Provider; id: string; at: number; jobId: string }
29
+ interface DayMemo { mtimeMs: number; size: number; readAt: number; seen: Seen[]; person: string[] }
30
+
31
+ const memo = new Map<string, DayMemo>()
32
+ export const cosStartedStats = { reads: 0 }
33
+ export function __resetCosStartedForTests(): void { memo.clear(); cosStartedStats.reads = 0 }
34
+
35
+ const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
36
+ const SAFE_ID = /^[A-Za-z0-9-]{8,64}$/
37
+
38
+ /** True only for a request a person started: no origin, or a dispatched task; never a maintenance workflow. */
39
+ export function isPersonJobRequest(request: unknown): boolean {
40
+ if (!request || typeof request !== 'object') return false
41
+ const r = request as { origin?: unknown; maintenanceWorkflow?: unknown }
42
+ if (r.maintenanceWorkflow != null) return false
43
+ if (r.origin == null) return true
44
+ return typeof r.origin === 'object' && (r.origin as { kind?: unknown }).kind === 'task'
45
+ }
46
+
47
+ function idsIn(text: string): { seen: Seen[]; person: string[] } {
48
+ const out: Seen[] = []
49
+ const person: string[] = []
50
+ for (const line of text.split('\n')) {
51
+ const hasRequest = line.includes('"request"')
52
+ if (!hasRequest && !line.includes('SessionId') && !line.includes('ThreadId')) continue
53
+ let record: any
54
+ try { record = JSON.parse(line) } catch { continue }
55
+ const jobId = typeof record?.jobId === 'string' ? record.jobId : ''
56
+ if (hasRequest && jobId && isPersonJobRequest(record?.request)) person.push(jobId)
57
+ const patch = record?.patch
58
+ if (!patch || typeof patch !== 'object') continue
59
+ const provider = patch.provider
60
+ const at = Date.parse(record.persistedAt ?? '')
61
+ const id = provider === 'codex' ? patch.codexThreadId : patch.cliSessionId
62
+ if ((provider === 'claude' || provider === 'codex' || provider === 'cursor') && typeof id === 'string' && SAFE_ID.test(id)) {
63
+ out.push({ provider, id: id.toLowerCase(), at: Number.isFinite(at) ? at : 0, jobId })
64
+ }
65
+ }
66
+ return { seen: out, person }
67
+ }
68
+
69
+ /** Native session ids per provider that a COS job started within `windowMs` of `now`. Never throws. */
70
+ export async function cosStartedSessionIds(now = Date.now(), dir = dataPath('query-jobs'), windowMs = COS_STARTED_WINDOW_MS): Promise<Record<Provider, Set<string>>> {
71
+ const result: Record<Provider, Set<string>> = { claude: new Set(), codex: new Set(), cursor: new Set() }
72
+ let names: string[]
73
+ try { names = await readdir(dir) } catch { return result }
74
+ // One day of slack: a job's day file is its own partition day, and the window is measured from its record time.
75
+ const oldestDay = new Date(now - windowMs - 24 * 3600 * 1000).toISOString().slice(0, 10)
76
+ const memos: DayMemo[] = []
77
+ for (const name of names) {
78
+ const day = DAY_FILE.exec(name)?.[1]
79
+ if (!day || day < oldestDay) continue
80
+ const file = join(dir, name)
81
+ try {
82
+ const st = await stat(file)
83
+ if (!st.isFile()) continue
84
+ let m = memo.get(file)
85
+ const changed = !m || m.mtimeMs !== st.mtimeMs || m.size !== st.size
86
+ if (changed && (!m || now - m.readAt >= COS_STARTED_REREAD_MS)) {
87
+ cosStartedStats.reads++
88
+ m = { mtimeMs: st.mtimeMs, size: st.size, readAt: now, ...idsIn(await readFile(file, 'utf8')) }
89
+ memo.set(file, m)
90
+ }
91
+ if (m) memos.push(m)
92
+ } catch { /* a day file that cannot be read adds nothing */ }
93
+ }
94
+ // A job's request and its session id can sit in different day files, so person-started jobs are gathered first.
95
+ const person = new Set(memos.flatMap(m => m.person))
96
+ for (const m of memos) {
97
+ for (const s of m.seen) if (now - s.at <= windowMs && person.has(s.jobId)) result[s.provider].add(s.id)
98
+ }
99
+ for (const key of memo.keys()) if (!names.includes(key.split('/').pop() || '')) memo.delete(key)
100
+ return result
101
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Opt-in per-path disk I/O meter (S1, 2026-11-09). COS_DISK_IO_METER=1 turns it on; default off.
3
+ *
4
+ * Why: macOS filed 12 "disk writes" resource reports against the server's node process
5
+ * (2.1 to 8.6 GB over 0.5 to 6 h) with JavaScript frames unsymbolicated, so no report could
6
+ * name a file. Measured from outside on 11/09 (proc_pid_rusage, idle, no meeting): 111.7 KB/s
7
+ * written and about 1.8 to 5 MB/s read. The largest writer (work-reviews/reviews.json) is fixed;
8
+ * the rest could not be attributed without root. With the meter on, the server logs once an
9
+ * hour which paths took the bytes:
10
+ *
11
+ * [disk-io] 60 min: wrote 152.3 MB in 1,204 writes, read 3.1 GB in 9,880 reads; top writes: ...
12
+ *
13
+ * How: wraps the synchronous and promise write/read entry points of node:fs and
14
+ * node:fs/promises, then calls syncBuiltinESMExports() so modules that already imported
15
+ * named bindings see the wrappers. Each wrapper calls the original and only counts bytes;
16
+ * errors and return values pass through unchanged. Streams (createWriteStream) are not
17
+ * counted. Paths are folded so ids and temp suffixes group: "~/.cos-glasses/data/active-sessions/*.json".
18
+ */
19
+ import fs from 'node:fs'
20
+ import fsPromises from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import { syncBuiltinESMExports } from 'node:module'
23
+
24
+ interface Tally { bytes: number; ops: number }
25
+ interface Window { startedAt: number; writes: Map<string, Tally>; reads: Map<string, Tally> }
26
+
27
+ let windowState: Window | null = null
28
+ let restore: (() => void) | null = null
29
+ let reportTimer: ReturnType<typeof setInterval> | null = null
30
+ const fdPaths = new Map<number, string>()
31
+ /** Node's own fs functions call each other (appendFileSync calls writeFileSync, which calls
32
+ * openSync and writeSync). Only the outermost wrapped call counts. */
33
+ let depth = 0
34
+ function nested<T>(run: () => T): T {
35
+ depth++
36
+ try { return run() } finally { depth-- }
37
+ }
38
+
39
+ const HOME = homedir()
40
+
41
+ /** Group a path: home as ~, temp suffixes dropped, id-like segments as *. */
42
+ export function foldPath(path: string): string {
43
+ let p = path.startsWith(HOME) ? `~${path.slice(HOME.length)}` : path
44
+ p = p.replace(/(\.\d+\.[0-9a-f]+)?\.tmp$/i, '') // atomic-write temp names
45
+ p = p.replace(/\.corrupt-\d+$/, '.corrupt-*')
46
+ p = p.replace(/(^|[/._-])[0-9a-f]{8,}(?=$|[/._-])/gi, '$1*') // hashes, uuids, hex ids
47
+ p = p.replace(/(^|[/._-])\d{6,}(?=$|[/._-])/g, '$1*') // epoch ms, pids in names
48
+ p = p.replace(/meeting_\*_[a-z0-9]+/gi, 'meeting_*')
49
+ return p
50
+ }
51
+
52
+ function byteLength(data: unknown, encoding?: unknown): number {
53
+ if (typeof data === 'string') return Buffer.byteLength(data, typeof encoding === 'string' ? encoding as BufferEncoding : 'utf8')
54
+ if (data && typeof (data as ArrayBufferView).byteLength === 'number') return (data as ArrayBufferView).byteLength
55
+ return 0
56
+ }
57
+
58
+ function keyFor(target: unknown): string {
59
+ if (typeof target === 'number') return fdPaths.get(target) ?? `fd:${target}`
60
+ if (typeof target === 'string') return foldPath(target)
61
+ if (target instanceof URL) return foldPath(target.pathname)
62
+ if (Buffer.isBuffer(target)) return foldPath(target.toString())
63
+ if (target && typeof (target as { fd?: unknown }).fd === 'number') return fdPaths.get((target as { fd: number }).fd) ?? 'filehandle'
64
+ return 'unknown'
65
+ }
66
+
67
+ function count(kind: 'writes' | 'reads', target: unknown, bytes: number, outer: boolean): void {
68
+ if (!windowState || bytes <= 0 || !outer) return
69
+ const map = windowState[kind]
70
+ const key = keyFor(target)
71
+ const tally = map.get(key)
72
+ if (tally) { tally.bytes += bytes; tally.ops++ } else map.set(key, { bytes, ops: 1 })
73
+ }
74
+
75
+ export interface DiskIoReport {
76
+ windowMs: number
77
+ wroteBytes: number; writeOps: number
78
+ readBytes: number; readOps: number
79
+ topWrites: Array<{ path: string; bytes: number; ops: number }>
80
+ topReads: Array<{ path: string; bytes: number; ops: number }>
81
+ }
82
+
83
+ /** The current window's totals (diagnostics, tests). Null when the meter is off. */
84
+ export function diskIoSnapshot(top = 10): DiskIoReport | null {
85
+ if (!windowState) return null
86
+ const sum = (m: Map<string, Tally>) => [...m.values()].reduce((a, t) => ({ bytes: a.bytes + t.bytes, ops: a.ops + t.ops }), { bytes: 0, ops: 0 })
87
+ const rank = (m: Map<string, Tally>) => [...m.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, top)
88
+ .map(([path, t]) => ({ path, bytes: t.bytes, ops: t.ops }))
89
+ const w = sum(windowState.writes), r = sum(windowState.reads)
90
+ return {
91
+ windowMs: Date.now() - windowState.startedAt,
92
+ wroteBytes: w.bytes, writeOps: w.ops, readBytes: r.bytes, readOps: r.ops,
93
+ topWrites: rank(windowState.writes), topReads: rank(windowState.reads),
94
+ }
95
+ }
96
+
97
+ function mb(bytes: number): string {
98
+ return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${(bytes / 1e6).toFixed(1)} MB`
99
+ }
100
+
101
+ /** One log line for the window, then start a new window. */
102
+ export function reportDiskIo(log: (line: string) => void = line => console.log(line)): DiskIoReport | null {
103
+ const report = diskIoSnapshot(8)
104
+ if (!report || !windowState) return null
105
+ const fmt = (rows: DiskIoReport['topWrites']) => rows.map(r => `${r.path} ${mb(r.bytes)} (${r.ops})`).join('; ') || 'none'
106
+ log(`[disk-io] ${Math.round(report.windowMs / 60_000)} min: wrote ${mb(report.wroteBytes)} in ${report.writeOps} writes, `
107
+ + `read ${mb(report.readBytes)} in ${report.readOps} reads; top writes: ${fmt(report.topWrites)}; top reads: ${fmt(report.topReads)}`)
108
+ windowState = { startedAt: Date.now(), writes: new Map(), reads: new Map() }
109
+ return report
110
+ }
111
+
112
+ export function diskIoMeterRequested(env: NodeJS.ProcessEnv = process.env): boolean {
113
+ return env.COS_DISK_IO_METER === '1'
114
+ }
115
+
116
+ /** Install the wrappers. Idempotent. Returns false when already installed. */
117
+ export function installDiskIoMeter(options: { reportEveryMs?: number } = {}): boolean {
118
+ if (restore) return false
119
+ windowState = { startedAt: Date.now(), writes: new Map(), reads: new Map() }
120
+ const f = fs as unknown as Record<string, any>
121
+ const p = fsPromises as unknown as Record<string, any>
122
+ const saved: Array<[Record<string, any>, string, unknown]> = []
123
+ const wrap = (obj: Record<string, any>, name: string, make: (orig: any) => any) => {
124
+ const orig = obj[name]
125
+ if (typeof orig !== 'function') return
126
+ saved.push([obj, name, orig])
127
+ obj[name] = make(orig)
128
+ }
129
+ wrap(f, 'openSync', orig => function (this: unknown, path: unknown, ...rest: unknown[]) {
130
+ const fd = nested(() => orig.call(this, path, ...rest))
131
+ if (typeof fd === 'number' && (typeof path === 'string' || path instanceof URL)) fdPaths.set(fd, keyFor(path))
132
+ return fd
133
+ })
134
+ wrap(f, 'closeSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
135
+ fdPaths.delete(fd)
136
+ return orig.call(this, fd, ...rest)
137
+ })
138
+ wrap(f, 'writeFileSync', orig => function (this: unknown, target: unknown, data: unknown, options?: unknown) {
139
+ const outer = depth === 0
140
+ const result = nested(() => orig.call(this, target, data, options))
141
+ count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), outer)
142
+ return result
143
+ })
144
+ wrap(f, 'appendFileSync', orig => function (this: unknown, target: unknown, data: unknown, options?: unknown) {
145
+ const outer = depth === 0
146
+ const result = nested(() => orig.call(this, target, data, options))
147
+ count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), outer)
148
+ return result
149
+ })
150
+ wrap(f, 'writeSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
151
+ const outer = depth === 0
152
+ const written = nested(() => orig.call(this, fd, ...rest))
153
+ count('writes', fd, typeof written === 'number' ? written : 0, outer)
154
+ return written
155
+ })
156
+ wrap(f, 'readFileSync', orig => function (this: unknown, target: unknown, ...rest: unknown[]) {
157
+ const outer = depth === 0
158
+ const result = nested(() => orig.call(this, target, ...rest))
159
+ count('reads', target, byteLength(result), outer)
160
+ return result
161
+ })
162
+ wrap(f, 'readSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
163
+ const outer = depth === 0
164
+ const read = nested(() => orig.call(this, fd, ...rest))
165
+ count('reads', fd, typeof read === 'number' ? read : 0, outer)
166
+ return read
167
+ })
168
+ for (const name of ['writeFile', 'appendFile'] as const) {
169
+ wrap(p, name, orig => async function (this: unknown, target: unknown, data: unknown, options?: unknown) {
170
+ const result = await orig.call(this, target, data, options)
171
+ count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), true)
172
+ return result
173
+ })
174
+ }
175
+ wrap(p, 'readFile', orig => async function (this: unknown, target: unknown, ...rest: unknown[]) {
176
+ const result = await orig.call(this, target, ...rest)
177
+ count('reads', target, byteLength(result), true)
178
+ return result
179
+ })
180
+ syncBuiltinESMExports()
181
+ restore = () => {
182
+ for (const [obj, name, orig] of saved) obj[name] = orig
183
+ syncBuiltinESMExports()
184
+ }
185
+ const every = options.reportEveryMs ?? 60 * 60_000
186
+ if (every > 0) {
187
+ reportTimer = setInterval(() => { reportDiskIo() }, every)
188
+ reportTimer.unref?.()
189
+ }
190
+ console.log(`[disk-io] meter on (COS_DISK_IO_METER=1): ${every > 0 ? `one line every ${Math.round(every / 60_000)} min` : 'reports on request only'}`)
191
+ return true
192
+ }
193
+
194
+ /** Remove the wrappers (tests, and a clean shutdown). */
195
+ export function uninstallDiskIoMeter(): void {
196
+ if (reportTimer) clearInterval(reportTimer)
197
+ reportTimer = null
198
+ restore?.()
199
+ restore = null
200
+ windowState = null
201
+ fdPaths.clear()
202
+ }
@@ -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
+ }