@gotcos/glasses-server 6.43.0 → 6.43.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,52 @@
1
+ ## 6.43.2
2
+
3
+ Managed-server updates pass their Claude readiness proof again.
4
+
5
+ - **The proof no longer loads your MCP catalog.** COS Control verifies a
6
+ candidate server by running one real `claude -p --model haiku` query with
7
+ no tools. On Claude Code 2.1.251, `--tools ''` still loads every MCP
8
+ server's tool definitions, and on a Mac with a large fleet that alone was
9
+ about 244,000 tokens against Haiku's 200,000-token window: "Prompt is too
10
+ long", exit 1, zero API time, and the update rolled back to the previous
11
+ server every time (six attempts on 6.43.1). The proof now passes
12
+ `--strict-mcp-config` with an empty MCP config, which keeps it under
13
+ 20,000 tokens and answers in about two seconds. Nothing else changed.
14
+
15
+ ## 6.43.1
16
+
17
+ Two things the first night of the morning brief taught us, plus the depth
18
+ behind each source.
19
+
20
+ - **A running brief can no longer collide with your next message.** A brief
21
+ holds its number for the minutes it runs, but `/api/message-counter` only
22
+ counted exchanges that had already been projected, so a phone whose counter
23
+ last synced at boot could mint the same number for its next prompt. Numbers
24
+ held by admitted, not-yet-terminal durable jobs (and by the brief's own
25
+ ledger row, which exists before the job does) now count toward the ceiling.
26
+ New `lib/message-reservations.ts` registry; the job store keeps the number
27
+ on its always-in-memory identity so the answer is synchronous and survives
28
+ a restart. (The double #74 reported on 6.9.443 was the companion showing one
29
+ exchange twice; companion 6.9.445 fixes that side.)
30
+ - **Sources show what is behind them.** `GET /api/morning-brief` now carries
31
+ `coverage`: one row per source with a state (`ready`, `empty`,
32
+ `unavailable`, `runtime`) and a summary line such as "2,312 meetings
33
+ stored, newest Sep 2026", "6,705 memories · 66 threads (12 active)", or
34
+ "/good-morning found under .claude/skills". Same wells COS Control's
35
+ Activity tiles read (meeting library, context status, the pipeline bridge
36
+ for calendar and tasks, the reflection log). Probes are cached for five
37
+ minutes and bounded to eight seconds each; a source the server cannot see
38
+ (Slack, health, dashboards) says so as `runtime` instead of showing a dash.
39
+ `GET /api/morning-brief/coverage?refresh=1` re-probes on demand.
40
+ - **Each run records what it was asked for and what came back.** The ledger
41
+ row stores the sections the brief was composed with, and once the job
42
+ completes the run view reports each as `present`, `unavailable` (the
43
+ prompt's "<Section>: unavailable" line), `skipped` (silent-skip sections
44
+ with nothing behind them), or `missing`. A skill section is present unless
45
+ the answer says the skill was not found.
46
+
47
+ No config migration: existing `config.json` and `runs.json` files load as
48
+ they are; older run rows simply have no section outcome.
49
+
1
50
  ## 6.43.0
2
51
 
3
52
  The brief is waiting before you ask for it.
package/README.md CHANGED
@@ -231,6 +231,13 @@ doubles it; a Mac asleep at the slot still fires inside a three-hour catch-up
231
231
  window; "Run now" is capped at five a day. Off entirely when Background jobs
232
232
  are off. The brief is read-only by contract.
233
233
 
234
+ Since 6.43.1 the same `GET /api/morning-brief` response carries `coverage`,
235
+ one row per source saying what the server can see behind it (meetings stored,
236
+ memories and threads, whether the named skill exists) and every run reports
237
+ which sections the answer actually opened. Numbers reserved by a running
238
+ brief count toward `/api/message-counter`, so the phone never mints the same
239
+ `#NNN` twice.
240
+
234
241
  ## Speaker diarization (opt-in)
235
242
 
236
243
  Without a voiceprint model this server does not classify speakers at all — it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.43.0",
3
+ "version": "6.43.2",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,62 @@
1
+ // Message-number reservations that are not exchanges yet.
2
+ //
3
+ // The phone mints its own #NNN from the `max` that /api/message-counter
4
+ // reports, and a durable job carries the number it was minted with until its
5
+ // terminal projection writes the exchange into the session. Between admission
6
+ // and projection the number existed nowhere the counter looked, so a second
7
+ // producer could mint it again: a running morning brief holds its number for
8
+ // minutes while the phone's counter, which only re-syncs at boot, still sits
9
+ // below it. (The double "#74" seen on 2026-09-01 turned out to be the phone
10
+ // showing ONE exchange twice, a display-bus placeholder plus the hydrated
11
+ // row, fixed in companion 6.9.445. The window this closes is real all the
12
+ // same: the same night's ledger shows the brief holding #74 for 6m35s.)
13
+ //
14
+ // Every holder of a not-yet-projected number registers a source here. The
15
+ // counter route and the brief's own reservation read the union. Sources
16
+ // register themselves rather than being imported, because the morning-brief
17
+ // runtime already imports the counter route (a direct import would cycle).
18
+
19
+ import { exchangeBelongsToEra } from './message-era.js'
20
+
21
+ export interface MessageReservation {
22
+ globalMsgNum: number
23
+ messageEra?: string
24
+ /** Diagnostic owner label, e.g. `job:<id>` or `brief:<day>`. Never a prompt. */
25
+ owner: string
26
+ }
27
+
28
+ export type MessageReservationSource = () => readonly MessageReservation[]
29
+
30
+ const sources = new Set<MessageReservationSource>()
31
+
32
+ export function registerMessageReservationSource(source: MessageReservationSource): () => void {
33
+ sources.add(source)
34
+ return () => { sources.delete(source) }
35
+ }
36
+
37
+ /** Every live reservation in `era`. A source that throws contributes nothing;
38
+ * the counter must never fail because one holder is mid-shutdown. */
39
+ export function reservedGlobalMsgNums(era: string): MessageReservation[] {
40
+ const out: MessageReservation[] = []
41
+ for (const source of sources) {
42
+ let items: readonly MessageReservation[]
43
+ try { items = source() } catch { continue }
44
+ for (const item of items) {
45
+ if (!Number.isSafeInteger(item.globalMsgNum) || item.globalMsgNum <= 0) continue
46
+ if (!exchangeBelongsToEra(item, era)) continue
47
+ out.push(item)
48
+ }
49
+ }
50
+ return out
51
+ }
52
+
53
+ export function maxReservedGlobalMsgNum(era: string): number {
54
+ let max = 0
55
+ for (const item of reservedGlobalMsgNums(era)) if (item.globalMsgNum > max) max = item.globalMsgNum
56
+ return max
57
+ }
58
+
59
+ /** Test-only. */
60
+ export function resetMessageReservationSourcesForTests(): void {
61
+ sources.clear()
62
+ }
@@ -506,6 +506,8 @@ export interface MorningBriefRun {
506
506
  submitError?: { code: string; message: string }
507
507
  /** Terminal state copied from the job when last observed. */
508
508
  lastKnownStatus?: string
509
+ /** The sections this run was asked for, in order, from the config at fire time. */
510
+ sections?: Array<{ id: MorningBriefSourceId; label: string }>
509
511
  }
510
512
 
511
513
  export interface MorningBriefLedger {
@@ -0,0 +1,376 @@
1
+ // Morning brief — what each source can actually reach, and what a run produced.
2
+ //
3
+ // The Sources list names an INSTRUCTION ("Meetings", "Knowledge graph"); the
4
+ // server can also say how deep the well is behind it: 2,312 meetings stored,
5
+ // 6,705 memories and 66 threads, the /good-morning skill found under
6
+ // .claude/skills. Miles (2026-09-01): "I'm assuming we should see all of these
7
+ // stats when we turn it on." Those are the same numbers COS Control's Activity
8
+ // tiles show, read from the same places, so the card agrees with the tiles.
9
+ //
10
+ // Two halves, both pure over injected probes so tests never touch a venv:
11
+ // - coverage: per-source state + one summary line, probes cached for a few
12
+ // minutes (they walk the meeting library and shell to the Python bridge);
13
+ // - section outcomes: after a run, which sections the answer actually opened,
14
+ // which came back "<Section>: unavailable", and which were silently skipped.
15
+ //
16
+ // A source the server cannot see at all (Slack, health, dashboards) is
17
+ // `runtime`: the COS brain resolves it when the brief runs. That is a fact,
18
+ // not a failure, and the summary says so instead of showing a dash.
19
+
20
+ import type { MorningBriefConfig, MorningBriefSource, MorningBriefSourceId } from './morning-brief-config.js'
21
+ import { MORNING_BRIEF_SOURCES } from './morning-brief-config.js'
22
+ import { sectionInstruction } from './morning-brief-prompt.js'
23
+
24
+ export type MorningBriefCoverageState = 'ready' | 'empty' | 'unavailable' | 'runtime'
25
+
26
+ export interface MorningBriefSourceCoverage {
27
+ id: MorningBriefSourceId
28
+ state: MorningBriefCoverageState
29
+ /** One line for a settings row. Never a path, never a prompt. */
30
+ summary: string
31
+ counts?: Record<string, number>
32
+ }
33
+
34
+ export interface MorningBriefCoverage {
35
+ checkedAt: string
36
+ ttlMs: number
37
+ sources: MorningBriefSourceCoverage[]
38
+ }
39
+
40
+ export interface MeetingsProbe { count: number; newestMonth: string | null; layout: string }
41
+ export interface ContextProbe {
42
+ memory: { available: boolean; total: number; state?: string }
43
+ threads: { available: boolean; total: number; active?: number; state?: string }
44
+ }
45
+ export interface CalendarProbe { todayCount: number; source?: string }
46
+ export interface TasksProbe { open: number; files: number }
47
+ export interface ReflectionProbe { entries: number; newestAt: string | null }
48
+ export interface SkillProbe { found: boolean; where?: string }
49
+
50
+ /** Every probe is optional: a standalone install has none of the pipeline
51
+ * ones, and `null` means "the server cannot see this" (runtime), not zero. */
52
+ export interface MorningBriefCoverageProbes {
53
+ meetings?: () => MeetingsProbe | null
54
+ context?: () => Promise<ContextProbe | null>
55
+ calendar?: () => Promise<CalendarProbe | null>
56
+ tasks?: () => Promise<TasksProbe | null>
57
+ reflection?: () => ReflectionProbe | null
58
+ skill?: (name: string) => SkillProbe
59
+ }
60
+
61
+ export const MORNING_BRIEF_COVERAGE_TTL_MS = 5 * 60_000
62
+ export const MORNING_BRIEF_COVERAGE_PROBE_TIMEOUT_MS = 8_000
63
+
64
+ interface ProbeResults {
65
+ meetings: MeetingsProbe | null | undefined
66
+ context: ContextProbe | null | undefined
67
+ calendar: CalendarProbe | null | undefined
68
+ tasks: TasksProbe | null | undefined
69
+ reflection: ReflectionProbe | null | undefined
70
+ }
71
+
72
+ function n(value: number): string {
73
+ return Math.max(0, Math.trunc(value)).toLocaleString('en-US')
74
+ }
75
+
76
+ function plural(count: number, unit: string): string {
77
+ return `${n(count)} ${unit}${count === 1 ? '' : 's'}`
78
+ }
79
+
80
+ function str(options: MorningBriefSource['options'], key: string): string {
81
+ const value = options[key]
82
+ return typeof value === 'string' ? value.trim() : ''
83
+ }
84
+
85
+ function monthLabel(month: string | null): string {
86
+ if (!month || !/^\d{4}-\d{2}$/.test(month)) return ''
87
+ const [y, m] = month.split('-').map(Number)
88
+ const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
89
+ return names[m - 1] ? `${names[m - 1]} ${y}` : ''
90
+ }
91
+
92
+ function dayLabel(iso: string | null): string {
93
+ if (!iso) return ''
94
+ const ms = Date.parse(iso)
95
+ if (!Number.isFinite(ms)) return ''
96
+ const d = new Date(ms)
97
+ const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
98
+ return `${names[d.getUTCMonth()]} ${d.getUTCDate()}`
99
+ }
100
+
101
+ function unavailableReason(state?: string): string {
102
+ switch (state) {
103
+ case 'qdrant_unavailable': return 'memory store unreachable'
104
+ case 'bridge_error': return 'pipeline bridge failed'
105
+ case 'bridge_missing': return 'pipeline bridge missing'
106
+ case 'thread_store_unavailable': return 'thread store unreachable'
107
+ default: return state ? state.replace(/_/g, ' ') : 'unreachable'
108
+ }
109
+ }
110
+
111
+ const RUNTIME_SUMMARY: Record<'waiting' | 'health' | 'pulse', string> = {
112
+ waiting: 'Slack, email, and chat connectors are read by your COS when the brief runs.',
113
+ health: 'Any connected health source (Oura or similar) is read when the brief runs.',
114
+ pulse: 'Dashboards and metrics connectors are read when the brief runs.',
115
+ }
116
+
117
+ /** The coverage row for one source, from probe results already gathered. */
118
+ export function describeSourceCoverage(
119
+ source: MorningBriefSource,
120
+ probes: ProbeResults,
121
+ skillProbe?: (name: string) => SkillProbe,
122
+ ): MorningBriefSourceCoverage {
123
+ const o = source.options
124
+ switch (source.id) {
125
+ case 'calendar': {
126
+ const probe = probes.calendar
127
+ if (probe === undefined || probe === null) {
128
+ return { id: source.id, state: 'runtime', summary: 'Calendars are read through your COS connectors when the brief runs.' }
129
+ }
130
+ const counts = { today: probe.todayCount }
131
+ if (probe.todayCount > 0) {
132
+ return { id: source.id, state: 'ready', summary: `${plural(probe.todayCount, 'event')} on today's calendar${probe.source ? ` (${probe.source.replace(/_/g, ' ')})` : ''}.`, counts }
133
+ }
134
+ return { id: source.id, state: 'empty', summary: 'Calendar reachable; nothing on it today.', counts }
135
+ }
136
+ case 'meetings': {
137
+ const probe = probes.meetings
138
+ if (probe === undefined || probe === null) {
139
+ return { id: source.id, state: 'empty', summary: 'No meeting library configured yet.' }
140
+ }
141
+ const counts = { stored: probe.count }
142
+ if (probe.count > 0) {
143
+ const newest = monthLabel(probe.newestMonth)
144
+ return { id: source.id, state: 'ready', summary: `${plural(probe.count, 'meeting')} stored${newest ? `, newest ${newest}` : ''}.`, counts }
145
+ }
146
+ return { id: source.id, state: 'empty', summary: 'Meeting library is empty so far.', counts }
147
+ }
148
+ case 'tasks': {
149
+ const probe = probes.tasks
150
+ if (probe === undefined || probe === null) {
151
+ return { id: source.id, state: 'runtime', summary: 'Task files in your workspace are read when the brief runs.' }
152
+ }
153
+ const counts = { open: probe.open, files: probe.files }
154
+ if (probe.open > 0) {
155
+ return { id: source.id, state: 'ready', summary: `${plural(probe.open, 'open task')} across ${plural(probe.files, 'task file')}.`, counts }
156
+ }
157
+ return { id: source.id, state: 'empty', summary: 'Task files reachable; nothing open.', counts }
158
+ }
159
+ case 'waiting':
160
+ return { id: source.id, state: 'runtime', summary: RUNTIME_SUMMARY.waiting }
161
+ case 'knowledge': {
162
+ const probe = probes.context
163
+ if (probe === undefined || probe === null) {
164
+ return { id: source.id, state: 'empty', summary: 'No memory or thread store yet; this section skips itself.' }
165
+ }
166
+ const memoryOk = probe.memory.available
167
+ const threadsOk = probe.threads.available
168
+ const counts = { memories: probe.memory.total, threads: probe.threads.total, activeThreads: probe.threads.active ?? 0 }
169
+ if (!memoryOk && !threadsOk) {
170
+ return { id: source.id, state: 'unavailable', summary: `Memory and threads unreachable (${unavailableReason(probe.memory.state)}).`, counts }
171
+ }
172
+ const parts: string[] = []
173
+ if (memoryOk) parts.push(plural(probe.memory.total, 'memory').replace('memorys', 'memories'))
174
+ else parts.push(`memories ${unavailableReason(probe.memory.state)}`)
175
+ if (threadsOk) {
176
+ const active = probe.threads.active ?? 0
177
+ parts.push(`${plural(probe.threads.total, 'thread')}${active > 0 ? ` (${n(active)} active)` : ''}`)
178
+ } else {
179
+ parts.push(`threads ${unavailableReason(probe.threads.state)}`)
180
+ }
181
+ const total = (memoryOk ? probe.memory.total : 0) + (threadsOk ? probe.threads.total : 0)
182
+ return {
183
+ id: source.id,
184
+ state: total > 0 ? (memoryOk && threadsOk ? 'ready' : 'unavailable') : 'empty',
185
+ summary: `${parts.join(' · ')}.`,
186
+ counts,
187
+ }
188
+ }
189
+ case 'reflection': {
190
+ const probe = probes.reflection
191
+ if (probe === undefined || probe === null) {
192
+ return { id: source.id, state: 'runtime', summary: 'Journals and reflection logs in your workspace are read when the brief runs.' }
193
+ }
194
+ const counts = { entries: probe.entries }
195
+ if (probe.entries > 0) {
196
+ const newest = dayLabel(probe.newestAt)
197
+ return { id: source.id, state: 'ready', summary: `${plural(probe.entries, 'reflection')} on record${newest ? `, newest ${newest}` : ''}.`, counts }
198
+ }
199
+ return { id: source.id, state: 'empty', summary: 'Reflection log present but empty.', counts }
200
+ }
201
+ case 'health':
202
+ return { id: source.id, state: 'runtime', summary: RUNTIME_SUMMARY.health }
203
+ case 'reading': {
204
+ const text = str(o, 'text') || 'proverbs'
205
+ return text.toLowerCase() === 'proverbs'
206
+ ? { id: source.id, state: 'ready', summary: 'Public-domain KJV Proverbs, one chapter per calendar day. Nothing to connect.' }
207
+ : { id: source.id, state: 'ready', summary: `Public-domain reading from "${text}", matched to the date.` }
208
+ }
209
+ case 'pulse':
210
+ return { id: source.id, state: 'runtime', summary: str(o, 'instruction') ? `${RUNTIME_SUMMARY.pulse} Instruction set.` : RUNTIME_SUMMARY.pulse }
211
+ case 'skill': {
212
+ const name = str(o, 'name')
213
+ if (!name) return { id: source.id, state: 'empty', summary: 'Name a skill to run as this section.' }
214
+ const slash = name.startsWith('/') ? name : `/${name}`
215
+ if (!skillProbe) return { id: source.id, state: 'runtime', summary: `${slash} is looked up in the workspace when the brief runs.` }
216
+ const probe = skillProbe(name)
217
+ return probe.found
218
+ ? { id: source.id, state: 'ready', summary: `${slash} found under ${probe.where ?? 'the workspace'}.` }
219
+ : { id: source.id, state: 'unavailable', summary: `${slash} not found under .claude/skills, .agents/skills, .claude/commands, or ~/.codex/prompts.` }
220
+ }
221
+ case 'custom': {
222
+ const instruction = str(o, 'instruction')
223
+ return instruction
224
+ ? { id: source.id, state: 'ready', summary: 'Runs as written.' }
225
+ : { id: source.id, state: 'empty', summary: 'Write an instruction to add this section.' }
226
+ }
227
+ }
228
+ }
229
+
230
+ function withTimeout<T>(work: Promise<T>, ms: number): Promise<T | null> {
231
+ return new Promise<T | null>(resolve => {
232
+ const timer = setTimeout(() => resolve(null), ms)
233
+ timer.unref?.()
234
+ work.then(value => { clearTimeout(timer); resolve(value) }, () => { clearTimeout(timer); resolve(null) })
235
+ })
236
+ }
237
+
238
+ export interface MorningBriefCoverageServiceOptions {
239
+ now?: () => number
240
+ ttlMs?: number
241
+ probeTimeoutMs?: number
242
+ log?: (line: string) => void
243
+ }
244
+
245
+ /**
246
+ * Cached probe results + per-call source rows. The probes are the expensive
247
+ * part (a library walk, a Python shell-out); the rows are recomputed on every
248
+ * call from the CURRENT config so a renamed skill or a cleared instruction is
249
+ * reflected immediately without re-probing.
250
+ */
251
+ export class MorningBriefCoverageService {
252
+ private cache: { at: number; results: ProbeResults } | null = null
253
+ private inFlight: Promise<ProbeResults> | null = null
254
+ private readonly now: () => number
255
+ private readonly ttlMs: number
256
+ private readonly probeTimeoutMs: number
257
+
258
+ constructor(private readonly probes: MorningBriefCoverageProbes, options: MorningBriefCoverageServiceOptions = {}) {
259
+ this.now = options.now ?? Date.now
260
+ this.ttlMs = options.ttlMs ?? MORNING_BRIEF_COVERAGE_TTL_MS
261
+ this.probeTimeoutMs = options.probeTimeoutMs ?? MORNING_BRIEF_COVERAGE_PROBE_TIMEOUT_MS
262
+ }
263
+
264
+ invalidate(): void {
265
+ this.cache = null
266
+ }
267
+
268
+ async describe(config: MorningBriefConfig, force = false): Promise<MorningBriefCoverage> {
269
+ const results = await this.results(force)
270
+ const sources = config.sources.map(source => describeSourceCoverage(source, results, this.probes.skill))
271
+ return { checkedAt: new Date(this.cache?.at ?? this.now()).toISOString(), ttlMs: this.ttlMs, sources }
272
+ }
273
+
274
+ private results(force: boolean): Promise<ProbeResults> {
275
+ if (!force && this.cache && this.now() - this.cache.at < this.ttlMs) return Promise.resolve(this.cache.results)
276
+ if (this.inFlight) return this.inFlight
277
+ this.inFlight = this.probeAll().then(results => {
278
+ this.cache = { at: this.now(), results }
279
+ return results
280
+ }).finally(() => { this.inFlight = null })
281
+ return this.inFlight
282
+ }
283
+
284
+ private async probeAll(): Promise<ProbeResults> {
285
+ const sync = <T>(fn: (() => T | null) | undefined): T | null | undefined => {
286
+ if (!fn) return undefined
287
+ try { return fn() } catch { return null }
288
+ }
289
+ const async = <T>(fn: (() => Promise<T | null>) | undefined): Promise<T | null | undefined> => {
290
+ if (!fn) return Promise.resolve(undefined)
291
+ let started: Promise<T | null>
292
+ try { started = fn() } catch { return Promise.resolve(null) }
293
+ return withTimeout(started, this.probeTimeoutMs)
294
+ }
295
+ const [context, calendar, tasks] = await Promise.all([
296
+ async(this.probes.context),
297
+ async(this.probes.calendar),
298
+ async(this.probes.tasks),
299
+ ])
300
+ return {
301
+ meetings: sync(this.probes.meetings),
302
+ context,
303
+ calendar,
304
+ tasks,
305
+ reflection: sync(this.probes.reflection),
306
+ }
307
+ }
308
+ }
309
+
310
+ // ── Section outcomes ──────────────────────────────────────────────────────────
311
+
312
+ export interface MorningBriefSectionRef {
313
+ id: MorningBriefSourceId
314
+ label: string
315
+ }
316
+
317
+ export type MorningBriefSectionState = 'present' | 'unavailable' | 'skipped' | 'missing' | 'pending'
318
+
319
+ export interface MorningBriefSectionOutcome extends MorningBriefSectionRef {
320
+ state: MorningBriefSectionState
321
+ }
322
+
323
+ const KNOWN_IDS = new Set<MorningBriefSourceId>(MORNING_BRIEF_SOURCES.map(spec => spec.id))
324
+
325
+ /** Sources that opt out silently when there is nothing behind them. */
326
+ const SILENT_SKIP = new Set<MorningBriefSourceId>(['knowledge', 'reflection', 'health'])
327
+
328
+ /** The sections a brief for `day` will carry, in order, from the config the
329
+ * run was fired with. Stored on the ledger row so a later config change does
330
+ * not rewrite what an old run was asked for. */
331
+ export function briefSections(config: MorningBriefConfig, day: string): MorningBriefSectionRef[] {
332
+ const out: MorningBriefSectionRef[] = []
333
+ for (const source of config.sources) {
334
+ if (!source.enabled || !KNOWN_IDS.has(source.id)) continue
335
+ const section = sectionInstruction(source, day)
336
+ if (section) out.push({ id: source.id, label: section.label })
337
+ }
338
+ return out
339
+ }
340
+
341
+ function normalize(line: string): string {
342
+ return line.replace(/[*_#`>]/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
343
+ }
344
+
345
+ /**
346
+ * Read a finished brief back against the sections it was asked for. Purely
347
+ * textual: a label on its own line (or opening one) is "present"; the
348
+ * "<Section>: unavailable" contract from the prompt is "unavailable"; a
349
+ * silent-skip section with no label is "skipped"; anything else is "missing".
350
+ * A skill section has no label in the skill-only prompt, so it is "present"
351
+ * unless the answer says the skill was not found.
352
+ */
353
+ export function sectionOutcomes(sections: readonly MorningBriefSectionRef[], answer: string): MorningBriefSectionOutcome[] {
354
+ const lines = answer.split(/\r?\n/).map(normalize).filter(Boolean)
355
+ const text = lines.join('\n')
356
+ return sections.map(section => {
357
+ const label = normalize(section.label)
358
+ if (section.id === 'skill') {
359
+ if (lines.length === 0) return { ...section, state: 'missing' as const }
360
+ const slash = label.replace(/^skill\s+/, '')
361
+ const notFound = new RegExp(`${escapeRegExp(slash)}[^\\n]{0,80}(does not exist|not found|no such skill|could not (?:find|locate)|is not defined)`, 'i')
362
+ const notFoundBefore = new RegExp(`(does not exist|not found|no such skill|could not (?:find|locate))[^\\n]{0,80}${escapeRegExp(slash)}`, 'i')
363
+ return { ...section, state: notFound.test(text) || notFoundBefore.test(text) ? 'unavailable' as const : 'present' as const }
364
+ }
365
+ // The prompt's unavailable line uses a sentence-case name, not the shouted label.
366
+ const unavailable = new RegExp(`^${escapeRegExp(label)}\\s*[:\\-–—]\\s*unavailable`, 'i')
367
+ if (lines.some(line => unavailable.test(line))) return { ...section, state: 'unavailable' as const }
368
+ const opened = lines.some(line => line === label || line.startsWith(`${label}:`) || line.startsWith(`${label} -`) || line.startsWith(`${label} —`))
369
+ if (opened) return { ...section, state: 'present' as const }
370
+ return { ...section, state: SILENT_SKIP.has(section.id) ? 'skipped' as const : 'missing' as const }
371
+ })
372
+ }
373
+
374
+ function escapeRegExp(value: string): string {
375
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
376
+ }
@@ -4,6 +4,9 @@
4
4
  // health, and index.ts all see the same instance, and tests construct their
5
5
  // own MorningBriefScheduler with fake deps instead of importing this file.
6
6
 
7
+ import { existsSync, readFileSync, statSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { basename, join, resolve } from 'node:path'
7
10
  import { getActiveSessions, createSession } from './conversation.js'
8
11
  import { currentMessageEra, exchangeBelongsToEra } from './message-era.js'
9
12
  import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
@@ -12,11 +15,32 @@ import { getOwnerName } from './profile.js'
12
15
  import { durableQueryJobsEnabled } from './query-job-feature.js'
13
16
  import { queryJobCoordinator } from './query-job-runtime.js'
14
17
  import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
18
+ import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
15
19
  import { morningBriefPaths } from './morning-brief-config.js'
16
20
  import { MorningBriefScheduler } from './morning-brief-scheduler.js'
21
+ import {
22
+ MorningBriefCoverageService,
23
+ type CalendarProbe,
24
+ type ContextProbe,
25
+ type MeetingsProbe,
26
+ type ReflectionProbe,
27
+ type SkillProbe,
28
+ type TasksProbe,
29
+ } from './morning-brief-coverage.js'
30
+ import {
31
+ listCosOperationsMeetingDays,
32
+ listCosOperationsMeetingMonths,
33
+ listDirectLibraryMeetingDays,
34
+ listDirectLibraryMeetingMonths,
35
+ resolveMeetingLibrary,
36
+ } from './cos-operations-meetings.js'
37
+ import { getMeetingStore } from './meeting-store.js'
38
+ import { COS_SCRIPTS_DIR, callPython, contextSourceAvailable, pythonBridgeAvailable } from './python-bridge.js'
39
+ import { resolveProviderWorkDir } from './launch-dir.js'
17
40
 
18
- /** Highest stamped message number in the active era: live sessions plus the
19
- * day archives. The same arithmetic /api/message-counter serves the phone. */
41
+ /** Highest stamped message number in the active era: live sessions, the day
42
+ * archives, AND every number a not-yet-projected job or brief already holds.
43
+ * The same arithmetic /api/message-counter serves the phone. */
20
44
  export function currentMessageMax(): number {
21
45
  const era = currentMessageEra()
22
46
  let liveMax = 0
@@ -26,14 +50,122 @@ export function currentMessageMax(): number {
26
50
  if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
27
51
  }
28
52
  }
29
- return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era))
53
+ return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era), maxReservedGlobalMsgNum(era))
54
+ }
55
+
56
+ // ── Coverage probes (the same wells COS Control's Activity tiles read) ────────
57
+
58
+ function sumDayCounts(months: string[], days: (month: string) => Array<{ count: number }>): number {
59
+ let total = 0
60
+ for (const month of months) for (const day of days(month)) total += day.count
61
+ return total
62
+ }
63
+
64
+ export function probeMeetings(): MeetingsProbe | null {
65
+ const library = resolveMeetingLibrary()
66
+ if (library.layout === 'direct') {
67
+ const months = listDirectLibraryMeetingMonths()
68
+ return { count: sumDayCounts(months, listDirectLibraryMeetingDays), newestMonth: months[0] ?? null, layout: library.layout }
69
+ }
70
+ if (library.layout === 'multi_domain') {
71
+ const months = listCosOperationsMeetingMonths('all')
72
+ return { count: sumDayCounts(months, month => listCosOperationsMeetingDays(month, 'all')), newestMonth: months[0] ?? null, layout: library.layout }
73
+ }
74
+ if (library.layout === 'invalid_explicit_root') return null
75
+ const store = getMeetingStore()
76
+ const months = store.listMonths()
77
+ return { count: sumDayCounts(months, month => store.listDayCounts(month)), newestMonth: months[0] ?? null, layout: 'standalone' }
78
+ }
79
+
80
+ export async function probeContext(): Promise<ContextProbe | null> {
81
+ if (contextSourceAvailable() === null) return null
82
+ const data = await callPython(['context-status'], 8_000) as {
83
+ memory?: { available?: unknown; total?: unknown; state?: unknown; reason?: unknown }
84
+ threads?: { available?: unknown; total?: unknown; active?: unknown; state?: unknown; reason?: unknown }
85
+ } | null
86
+ if (!data || typeof data !== 'object') return null
87
+ const num = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : 0
88
+ const state = (part?: { state?: unknown; reason?: unknown }) =>
89
+ typeof part?.state === 'string' ? part.state : typeof part?.reason === 'string' ? part.reason : undefined
90
+ return {
91
+ memory: { available: data.memory?.available === true, total: num(data.memory?.total), state: state(data.memory) },
92
+ threads: { available: data.threads?.available === true, total: num(data.threads?.total), active: num(data.threads?.active), state: state(data.threads) },
93
+ }
94
+ }
95
+
96
+ export async function probeCalendar(): Promise<CalendarProbe | null> {
97
+ if (!pythonBridgeAvailable()) return null
98
+ const data = await callPython(['calendar'], 8_000) as { meetings_today_count?: unknown; today_events?: unknown; data_source?: unknown } | null
99
+ if (!data || typeof data !== 'object') return null
100
+ const today = typeof data.meetings_today_count === 'number'
101
+ ? data.meetings_today_count
102
+ : Array.isArray(data.today_events) ? data.today_events.length : 0
103
+ return { todayCount: Math.max(0, Math.trunc(today)), ...(typeof data.data_source === 'string' ? { source: data.data_source } : {}) }
104
+ }
105
+
106
+ export async function probeTasks(): Promise<TasksProbe | null> {
107
+ if (!pythonBridgeAvailable()) return null
108
+ const data = await callPython(['tasks'], 8_000) as Record<string, unknown> | null
109
+ if (!data || typeof data !== 'object') return null
110
+ let open = 0
111
+ let files = 0
112
+ for (const rows of Object.values(data)) {
113
+ if (!Array.isArray(rows)) continue
114
+ files += 1
115
+ for (const row of rows) {
116
+ if (row && typeof row === 'object' && (row as { is_checked?: unknown }).is_checked !== true) open += 1
117
+ }
118
+ }
119
+ return { open, files }
120
+ }
121
+
122
+ const REFLECTION_LOG_MAX_BYTES = 8 * 1024 * 1024
123
+
124
+ export function probeReflection(): ReflectionProbe | null {
125
+ if (!COS_SCRIPTS_DIR) return null
126
+ const file = resolve(COS_SCRIPTS_DIR, '.cos_reflect_log.jsonl')
127
+ let stat
128
+ try { stat = statSync(file) } catch { return null }
129
+ if (!stat.isFile()) return null
130
+ const newestAt = stat.mtime.toISOString()
131
+ if (stat.size > REFLECTION_LOG_MAX_BYTES) return { entries: -1, newestAt }
132
+ let entries = 0
133
+ try {
134
+ for (const line of readFileSync(file, 'utf8').split('\n')) if (line.trim()) entries += 1
135
+ } catch { return null }
136
+ return { entries, newestAt }
137
+ }
138
+
139
+ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
140
+
141
+ /** Where a skill would be found, in the order the prompt tells the agent to
142
+ * look. Returns a folder LABEL, never an absolute path. */
143
+ export function probeSkill(rawName: string, workDir = resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }), home = homedir()): SkillProbe {
144
+ const name = rawName.trim().replace(/^\//, '')
145
+ if (!SKILL_NAME_RE.test(name) || basename(name) !== name) return { found: false }
146
+ const nested = name.replace(/:/g, '/')
147
+ const candidates: Array<[string, string]> = [
148
+ ['.claude/skills', join(workDir, '.claude', 'skills', name, 'SKILL.md')],
149
+ ['.agents/skills', join(workDir, '.agents', 'skills', name, 'SKILL.md')],
150
+ ['.agents/skills', join(workDir, '.agents', 'skills', nested, 'SKILL.md')],
151
+ ['.claude/commands', join(workDir, '.claude', 'commands', `${name}.md`)],
152
+ ['.claude/commands', join(workDir, '.claude', 'commands', `${nested}.md`)],
153
+ ['~/.claude/skills', join(home, '.claude', 'skills', name, 'SKILL.md')],
154
+ ['~/.claude/commands', join(home, '.claude', 'commands', `${name}.md`)],
155
+ ['~/.codex/prompts', join(home, '.codex', 'prompts', `${name}.md`)],
156
+ ]
157
+ for (const [where, path] of candidates) {
158
+ try { if (existsSync(path)) return { found: true, where } } catch { /* unreadable: keep looking */ }
159
+ }
160
+ return { found: false }
30
161
  }
31
162
 
32
163
  let scheduler: MorningBriefScheduler | null = null
164
+ let unregisterReservations: (() => void) | null = null
33
165
 
34
166
  export function getMorningBriefScheduler(): MorningBriefScheduler {
35
167
  if (!scheduler) {
36
- scheduler = new MorningBriefScheduler({
168
+ const instance = new MorningBriefScheduler({
37
169
  paths: morningBriefPaths(),
38
170
  submit: raw => queryJobCoordinator.submit(raw),
39
171
  findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
@@ -44,7 +176,18 @@ export function getMorningBriefScheduler(): MorningBriefScheduler {
44
176
  ownerName: getOwnerName,
45
177
  durableJobsEnabled: durableQueryJobsEnabled,
46
178
  admissionsOpen: maintenanceAdmissionsOpen,
179
+ coverage: new MorningBriefCoverageService({
180
+ meetings: probeMeetings,
181
+ context: probeContext,
182
+ calendar: probeCalendar,
183
+ tasks: probeTasks,
184
+ reflection: probeReflection,
185
+ skill: name => probeSkill(name),
186
+ }),
47
187
  })
188
+ // The ledger row exists before the job does; its number must count.
189
+ unregisterReservations = registerMessageReservationSource(() => instance.liveReservations(currentMessageEra()))
190
+ scheduler = instance
48
191
  }
49
192
  return scheduler
50
193
  }
@@ -55,4 +198,6 @@ export function startMorningBriefScheduler(): void {
55
198
 
56
199
  export function stopMorningBriefScheduler(): void {
57
200
  scheduler?.stop()
201
+ unregisterReservations?.()
202
+ unregisterReservations = null
58
203
  }
@@ -36,6 +36,14 @@ import {
36
36
  serverTimezone,
37
37
  } from './morning-brief-config.js'
38
38
  import { composeMorningBriefPrompt } from './morning-brief-prompt.js'
39
+ import {
40
+ briefSections,
41
+ sectionOutcomes,
42
+ type MorningBriefCoverage,
43
+ type MorningBriefCoverageService,
44
+ type MorningBriefSectionOutcome,
45
+ } from './morning-brief-coverage.js'
46
+ import type { MessageReservation } from './message-reservations.js'
39
47
  import { decideScheduledFire, localClock, nextScheduledFire } from './morning-brief-schedule.js'
40
48
  import type { QueryJobSnapshot } from './query-job-types.js'
41
49
  import { isTerminalQueryJobStatus } from './query-job-types.js'
@@ -57,6 +65,8 @@ export interface MorningBriefSchedulerDeps {
57
65
  ownerName: () => string
58
66
  durableJobsEnabled: () => boolean
59
67
  admissionsOpen: () => boolean
68
+ /** Per-source reach (counts behind each source). Absent on a bare harness. */
69
+ coverage?: MorningBriefCoverageService
60
70
  now?: () => number
61
71
  tickMs?: number
62
72
  log?: (line: string) => void
@@ -69,12 +79,14 @@ export class MorningBriefRunError extends Error {
69
79
  }
70
80
  }
71
81
 
72
- export interface MorningBriefRunView extends MorningBriefRun {
82
+ export interface MorningBriefRunView extends Omit<MorningBriefRun, 'sections'> {
73
83
  status: string
74
84
  completedAt?: string
75
85
  error?: { code: string; message: string }
76
86
  /** First ~120 chars of the answer, for a list row. Never the whole brief. */
77
87
  preview?: string
88
+ /** Which asked-for sections the answer opened, once the run is terminal. */
89
+ sections?: MorningBriefSectionOutcome[]
78
90
  }
79
91
 
80
92
  export interface MorningBriefStatus {
@@ -241,6 +253,7 @@ export class MorningBriefScheduler {
241
253
  sessionId,
242
254
  messageEra,
243
255
  globalMsgNum,
256
+ sections: resume?.sections ?? briefSections(this.config, day),
244
257
  }
245
258
  // Ledger first. A crash after this line is a resume, not a second brief.
246
259
  this.replaceRun(run)
@@ -291,21 +304,26 @@ export class MorningBriefScheduler {
291
304
  const rows = this.ledger.runs.slice(-Math.max(1, Math.min(limit, MORNING_BRIEF_LIMITS.retainedRuns))).reverse()
292
305
  const views: MorningBriefRunView[] = []
293
306
  for (const run of rows) {
307
+ const pending = run.sections?.length ? run.sections.map(section => ({ ...section, state: 'pending' as const })) : undefined
294
308
  if (!run.jobId) {
295
- views.push({ ...run, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
309
+ views.push({ ...run, sections: pending, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
296
310
  continue
297
311
  }
298
312
  const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
299
313
  if (!snapshot) {
300
- views.push({ ...run, status: run.lastKnownStatus ?? 'unknown' })
314
+ views.push({ ...run, sections: pending, status: run.lastKnownStatus ?? 'unknown' })
301
315
  continue
302
316
  }
303
317
  if (snapshot.status !== run.lastKnownStatus && isTerminalQueryJobStatus(snapshot.status)) {
304
318
  this.replaceRun({ ...run, lastKnownStatus: snapshot.status })
305
319
  }
306
320
  const answer = snapshot.response ?? snapshot.partialText ?? ''
321
+ const sections = run.sections?.length && snapshot.status === 'completed'
322
+ ? sectionOutcomes(run.sections, snapshot.response ?? '')
323
+ : pending
307
324
  views.push({
308
325
  ...run,
326
+ sections,
309
327
  status: snapshot.status,
310
328
  ...(snapshot.completedAt ? { completedAt: snapshot.completedAt } : {}),
311
329
  ...(snapshot.error ? { error: { code: snapshot.error.code, message: snapshot.error.message } } : {}),
@@ -315,6 +333,39 @@ export class MorningBriefScheduler {
315
333
  return views
316
334
  }
317
335
 
336
+ /** Per-source reach for the settings surfaces. `null` when the harness has
337
+ * no probes. `force` re-runs the probes instead of serving the cache. */
338
+ async coverage(force = false): Promise<MorningBriefCoverage | null> {
339
+ if (!this.deps.coverage) return null
340
+ try {
341
+ return await this.deps.coverage.describe(this.config, force)
342
+ } catch (error) {
343
+ this.log(`coverage failed: ${(error as Error).message}`)
344
+ return null
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Numbers this scheduler has minted for runs that are not terminal yet: the
350
+ * ledger row exists BEFORE the job does, and the job's own reservation
351
+ * (query-job-store identities) ends the moment its terminal projection
352
+ * writes the exchange. Bounded to a day so a crashed row cannot pin the
353
+ * counter forever; the resume path reuses the same number anyway.
354
+ */
355
+ liveReservations(era: string): MessageReservation[] {
356
+ const floor = this.now() - 24 * 60 * 60_000
357
+ const out: MessageReservation[] = []
358
+ for (const run of this.ledger.runs) {
359
+ if (typeof run.globalMsgNum !== 'number' || run.submitError) continue
360
+ if (run.lastKnownStatus && isTerminalQueryJobStatus(run.lastKnownStatus as never)) continue
361
+ const fired = Date.parse(run.firedAt)
362
+ if (!Number.isFinite(fired) || fired < floor) continue
363
+ if (era !== run.messageEra) continue
364
+ out.push({ globalMsgNum: run.globalMsgNum, messageEra: run.messageEra, owner: `brief:${run.day}` })
365
+ }
366
+ return out
367
+ }
368
+
318
369
  async status(): Promise<MorningBriefStatus> {
319
370
  const [lastRun] = await this.listRuns(1)
320
371
  const gate: MorningBriefStatus['gate'] = !this.deps.durableJobsEnabled()
@@ -110,12 +110,23 @@ export function runBounded(
110
110
  export const CLAUDE_PROOF_MODEL = 'haiku'
111
111
  export const CLAUDE_PROOF_TIMEOUT_MS = 45_000
112
112
 
113
+ /** No MCP servers for the proof. Claude Code 2.1.251 turns `--tools ''`
114
+ * into "load the whole catalog, expose none of it", and on a Mac with a
115
+ * large MCP fleet that catalog alone was ~244K tokens against Haiku's 200K
116
+ * window: "Prompt is too long", exit 1, zero API time. Six 6.43.1 update
117
+ * attempts rolled back on 2026-09-01/02 before a single request was made.
118
+ * An explicit empty config with --strict-mcp-config keeps the proof under
119
+ * 20K tokens and is what a readiness check should be anyway. */
120
+ export const CLAUDE_PROOF_MCP_CONFIG = '{"mcpServers":{}}'
121
+
113
122
  export function claudeProofArgs(): string[] {
114
123
  return [
115
124
  '-p',
116
125
  '--model', CLAUDE_PROOF_MODEL,
117
126
  '--output-format', 'json',
118
127
  '--permission-mode', 'dontAsk',
128
+ '--strict-mcp-config',
129
+ '--mcp-config', CLAUDE_PROOF_MCP_CONFIG,
119
130
  '--tools', '',
120
131
  '--allowedTools', '',
121
132
  '--system-prompt', PROOF_PROMPT,
@@ -596,6 +596,12 @@ export class QueryJobCoordinator {
596
596
  }
597
597
  }
598
598
 
599
+ /** Message numbers held by admitted, not-yet-terminal jobs (see
600
+ * lib/message-reservations.ts). Sync: read straight off the store's identities. */
601
+ liveMessageReservations(): Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> {
602
+ return this.store.listLiveMessageReservations()
603
+ }
604
+
599
605
  getHealth(): QueryJobCoordinatorHealth {
600
606
  return {
601
607
  activeRuns: this.active.size,
@@ -33,6 +33,7 @@ import {
33
33
  type QueryJobSnapshot,
34
34
  } from './query-job-types.js'
35
35
  import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
36
+ import { registerMessageReservationSource } from './message-reservations.js'
36
37
 
37
38
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
38
39
  WebSearch: 'Searching web...',
@@ -338,6 +339,14 @@ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner
338
339
  acquireMaintenanceWork: () => acquireMaintenanceWork('durable_query', { phase: 'queued' }),
339
340
  })
340
341
 
342
+ // A job's number is a live reservation from admission until its terminal
343
+ // projection; the counter must see it or the phone mints it again.
344
+ registerMessageReservationSource(() => queryJobCoordinator.liveMessageReservations().map(item => ({
345
+ globalMsgNum: item.globalMsgNum,
346
+ ...(item.messageEra ? { messageEra: item.messageEra } : {}),
347
+ owner: `job:${item.jobId}`,
348
+ })))
349
+
341
350
  export function initQueryJobRuntime() {
342
351
  if (!durableQueryJobsEnabled()) return Promise.resolve(queryJobCoordinator.getHealth())
343
352
  return queryJobCoordinator.init()
@@ -69,6 +69,11 @@ interface QueryJobIdentity {
69
69
  status: QueryJobStatus
70
70
  updatedAt: string
71
71
  orphanFenceUntil?: string
72
+ /** The number and era the client minted for this job, carried on the
73
+ * always-in-memory identity so a live reservation can be enumerated without
74
+ * hydrating the journal. See lib/message-reservations.ts. */
75
+ messageEra?: string
76
+ globalMsgNum?: number
72
77
  }
73
78
 
74
79
  export interface QueryJobMutationResult {
@@ -541,6 +546,8 @@ export class QueryJobStore {
541
546
  status: record.status,
542
547
  updatedAt: record.persistedAt,
543
548
  ...(snapshot.orphanFenceUntil ? { orphanFenceUntil: snapshot.orphanFenceUntil } : {}),
549
+ ...(typeof hydrated.request.messageEra === 'string' ? { messageEra: hydrated.request.messageEra } : {}),
550
+ ...(typeof hydrated.request.globalMsgNum === 'number' ? { globalMsgNum: hydrated.request.globalMsgNum } : {}),
544
551
  }
545
552
  this.identitiesByJobId.set(identity.jobId, identity)
546
553
  this.identitiesByKey.set(identityKey(identity.clientJobId, identity.generation), identity)
@@ -1018,6 +1025,27 @@ export class QueryJobStore {
1018
1025
  return out
1019
1026
  }
1020
1027
 
1028
+ /**
1029
+ * Numbers held by jobs that are admitted but not yet terminal. Synchronous
1030
+ * and in-memory on purpose: /api/message-counter answers on every phone
1031
+ * send, and identities are never evicted (only hydrated bodies are).
1032
+ * Before init the map is empty, so the answer is an honest nothing.
1033
+ */
1034
+ listLiveMessageReservations(): Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> {
1035
+ const out: Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> = []
1036
+ for (const identity of this.identitiesByJobId.values()) {
1037
+ if (isTerminalQueryJobStatus(identity.status)) continue
1038
+ if (typeof identity.globalMsgNum !== 'number') continue
1039
+ out.push({
1040
+ jobId: identity.jobId,
1041
+ sessionId: identity.sessionId,
1042
+ ...(identity.messageEra ? { messageEra: identity.messageEra } : {}),
1043
+ globalMsgNum: identity.globalMsgNum,
1044
+ })
1045
+ }
1046
+ return out
1047
+ }
1048
+
1021
1049
  async findByClientGeneration(clientJobId: string, generation: number): Promise<QueryJobSnapshot | undefined> {
1022
1050
  await this.ensureInitialized()
1023
1051
  const identity = this.identitiesByKey.get(identityKey(clientJobId.toLowerCase(), generation))
@@ -26,6 +26,7 @@ import {
26
26
  exchangeBelongsToEra,
27
27
  } from '../lib/message-era.js'
28
28
  import { MessageEraResetError, resetLiveMessageEra } from '../lib/message-era-reset.js'
29
+ import { maxReservedGlobalMsgNum } from '../lib/message-reservations.js'
29
30
 
30
31
  // v6.3.0 — read archives from the SAME persistent location the archive-mirror
31
32
  // writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
@@ -248,5 +249,9 @@ messageRefRouter.get('/message-counter', (_req, res) => {
248
249
  if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
249
250
  }
250
251
  }
251
- res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR, era)), era })
252
+ // 6.43.1 numbers minted for jobs that are admitted but not yet projected
253
+ // (a running morning brief, a desk session's durable job) are part of the
254
+ // ceiling. Without them the phone re-minted #74 on 2026-09-01.
255
+ const reservedMax = maxReservedGlobalMsgNum(era)
256
+ res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR, era), reservedMax), era })
252
257
  })
@@ -3,7 +3,8 @@
3
3
  // GET /api/morning-brief config + source catalog + status + recent runs
4
4
  // PUT /api/morning-brief patch the config (validated; 400 on a bad field)
5
5
  // POST /api/morning-brief/run fire a brief now (202; 409 while one runs; 429 past the daily cap)
6
- // GET /api/morning-brief/runs recent runs with live job status and message numbers
6
+ // GET /api/morning-brief/runs recent runs with live job status, message numbers, section outcomes
7
+ // GET /api/morning-brief/coverage what each source can reach right now (?refresh=1 re-probes)
7
8
  // GET /api/morning-brief/preview the exact prompt today's brief would send
8
9
  //
9
10
  // Authenticated by the global /api middleware like every other settings route.
@@ -24,6 +25,7 @@ export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler)
24
25
  sources: describeMorningBriefSources(),
25
26
  status: await instance.status(),
26
27
  runs: await instance.listRuns(7),
28
+ coverage: await instance.coverage(),
27
29
  }
28
30
  }
29
31
 
@@ -72,6 +74,18 @@ export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler)
72
74
  }
73
75
  })
74
76
 
77
+ router.get('/morning-brief/coverage', async (req, res) => {
78
+ const refresh = req.query.refresh === '1' || req.query.refresh === 'true'
79
+ try {
80
+ const coverage = await scheduler().coverage(refresh)
81
+ if (!coverage) return res.status(503).json({ error: { code: 'coverage_unavailable', message: 'This server has no coverage probes.' } })
82
+ res.json({ coverage })
83
+ } catch (error) {
84
+ console.error('[morning-brief] coverage failed:', error)
85
+ res.status(500).json({ error: { code: 'coverage_failed', message: 'Could not read source coverage.' } })
86
+ }
87
+ })
88
+
75
89
  router.get('/morning-brief/preview', (_req, res) => {
76
90
  res.type('text/plain').send(scheduler().previewPrompt('scheduled'))
77
91
  })