@gotcos/glasses-server 6.99.33 → 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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -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
+ }
@@ -38,7 +38,7 @@ export interface DropP2Deps {
38
38
  timezone: () => string
39
39
  now?: () => number
40
40
  /** The next calendar event; undefined when the calendar could not be read. */
41
- nextEvent?: () => Promise<{ title?: unknown; time?: unknown } | null | undefined>
41
+ nextEvent?: () => Promise<{ title?: unknown; time?: unknown; occurrence?: unknown } | null | undefined>
42
42
  /** Open Work intake items; undefined when the store is not available. */
43
43
  intakeOpen?: () => IntakeRow[] | undefined
44
44
  /** operations/scripts, for the thread and signal caches (read only; no script is run). */
@@ -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. */
@@ -15,6 +15,8 @@ import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
15
15
  import { join, resolve } from 'node:path'
16
16
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
17
17
  import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
18
+ import { parsePrepOccurrence } from './prep-store.js'
19
+ import type { PrepOccurrence } from '../../shared/prep.js'
18
20
 
19
21
  export class DropStoreError extends Error {
20
22
  constructor(readonly code: string, readonly status = 409) { super(code) }
@@ -81,9 +83,10 @@ export const FEED_KINDS = ['drop', 'receipt', 'question', 'followup', 'ping', 'a
81
83
  export type FeedKind = typeof FEED_KINDS[number]
82
84
  /**
83
85
  * 6.99.19: the prep card's "Prep me" may carry the event it is about, so a client can open a Prep draft for it
84
- * (ping-sources.ts prepCandidates). Display fields only; occurrence is null until the calendar sends event ids.
86
+ * (ping-sources.ts prepCandidates). Display fields plus, since the EventKit calendar source (PH plan P1), the event's
87
+ * occurrence identity when the calendar gave one. Null when it did not (the starter-kit bridge sends no event ids).
85
88
  */
86
- export interface FeedActionPrep { title: string; startsAt: string; occurrence: null }
89
+ export interface FeedActionPrep { title: string; startsAt: string; occurrence: PrepOccurrence | null }
87
90
  export interface FeedAction { id: string; label: string; prep?: FeedActionPrep }
88
91
  export interface FeedItem {
89
92
  id: string
@@ -245,8 +248,11 @@ export function parseDropRecord(raw: unknown): DropRecord {
245
248
  function feedActionPrep(raw: unknown): FeedActionPrep | undefined {
246
249
  if (!record(raw)) return undefined
247
250
  const title = str(raw.title, 300), startsAt = typeof raw.startsAt === 'string' && raw.startsAt.length <= 64 && Number.isFinite(Date.parse(raw.startsAt)) ? raw.startsAt : null
248
- if (!title || !startsAt || raw.occurrence !== null) return undefined
249
- return { title, startsAt, occurrence: null }
251
+ if (!title || !startsAt) return undefined
252
+ // Older rows carry occurrence: null. A present but invalid identity drops to null, never a guess.
253
+ let occurrence: PrepOccurrence | null = null
254
+ if (raw.occurrence != null) { try { occurrence = parsePrepOccurrence(raw.occurrence) } catch { occurrence = null } }
255
+ return { title, startsAt, occurrence }
250
256
  }
251
257
 
252
258
  export function parseFeedItem(raw: unknown): FeedItem {
@@ -381,6 +387,13 @@ export class InboxFeedStore {
381
387
  private items: FeedItem[] = []
382
388
  private quarantined: unknown[] = []
383
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
384
397
  private readonly path: string
385
398
  private readonly lock: ServerInstanceLock
386
399
  private closed = false
@@ -394,6 +407,19 @@ export class InboxFeedStore {
394
407
  this.versionNo = Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : this.items.length
395
408
  } catch (e) { this.close(); throw e }
396
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}`) }
397
423
  }
398
424
  /** Oldest first, as a chat reads. */
399
425
  list(): FeedItem[] { return structuredClone(this.items) }
@@ -449,7 +475,7 @@ export class InboxFeedStore {
449
475
  if (kept.length > DROP_LIMITS.feed) throw new DropStoreError('inbox_store_full', 503)
450
476
  const version = this.versionNo + bump
451
477
  // Never write a file the reader would refuse: the oldest COS lines (receipts, answers, pings) go first.
452
- 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'
453
479
  let body = serialize()
454
480
  while (Buffer.byteLength(body) > this.maxBytes) {
455
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
+ }