@gotcos/glasses-server 6.43.2 → 6.43.4

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,16 +1,76 @@
1
+ ## 6.43.4
2
+
3
+ Every message now says who started it.
4
+
5
+ - **Server-started jobs carry an origin label.** A durable job request may
6
+ carry `origin: { kind: 'routine' | 'task', id }`; the scheduled morning
7
+ brief sends `{ kind: 'routine', id: 'morning-brief' }`. The label lands on
8
+ both halves of the projected exchange (`origin`, `originId`), on the display
9
+ bus (`start`, `done`, `error`, flattened to the same two keys), and on the
10
+ today/all-messages and day-archive views the phone and COS Control read.
11
+ A prompt typed on the phone carries nothing; the phone's own `G2` stamp
12
+ never reaches the server. Nothing is inferred from the label's absence.
13
+ - **The request fingerprint is pinned to the sixteen identity keys it always
14
+ had.** `origin` (and any future provenance or enforcement key) is outside
15
+ it, so a build that does not know the key still hydrates a journal written
16
+ by one that does, and a rollback from this version to 6.43.3 keeps every
17
+ retained job. Stored fingerprints are byte-identical before and after.
18
+ - **Admission is strict only about a known kind with a bad id (400
19
+ `invalid_origin`).** An unknown kind, and the phone's bare-string stamp, are
20
+ dropped and counted on both paths, so a newer client's label degrades to
21
+ unlabeled and never refuses the job; hydration is lenient throughout. Two new
22
+ store counters on `/api/health`: `originDropped` (once per job created, once
23
+ per journal record hydrated) and `fingerprintMismatches` — the second is a
24
+ job that did NOT hydrate, and the first mismatch logs at error level. No
25
+ client reads either yet; `/api/health` is where a human sees them.
26
+ - **The label is validated at every boundary it crosses.** The conversation
27
+ store drops an unknown kind (and an orphan id) when it loads from disk, and
28
+ the bus stamp is spread after provider metadata so no provider key can
29
+ overwrite it. A real 6.43.3-written journal is committed as a fixture and
30
+ hydrated by the suite, in addition to the in-process hash oracle.
31
+ - **The scheduler adopts an admitted brief on every scheduled fire**, not only
32
+ on the crash-resume branch, and replaces the last of the day's scheduled
33
+ ledger rows instead of pushing another; "Run now" and the scheduled tick
34
+ are serialised, so two overlapping admissions can no longer interleave. A
35
+ manual and a scheduled brief on the same day remain two briefs, by design.
36
+ - Briefs that ran on 6.43.0–6.43.3 stay unlabeled; badges start with runs
37
+ from this version. A routine's reply is unlabeled while it is still running
38
+ and gains the label when it lands.
39
+
40
+ ## 6.43.3
41
+
42
+ Managed-server updates no longer hinge on one vendor's meter.
43
+
44
+ - **The readiness proof reports why it failed, as a code.** COS Control
45
+ verifies a candidate server with one real no-tool query per installed
46
+ provider. On 2026-09-01 Claude had hit its session limit, Codex proved in
47
+ seven seconds, and six 6.43.1 updates still rolled back because the only
48
+ answer was "provider process exited 1". `POST /api/diagnostics/provider-proof`
49
+ now returns `code`: `provider_quota` (vendor session or usage limit),
50
+ `provider_auth` (not signed in), `provider_context_overflow`,
51
+ `provider_missing`, `provider_timeout`, `provider_canceled`,
52
+ `provider_bad_answer`, or `provider_failed`. The `error` sentence is one
53
+ fixed phrase per code; vendor text never leaves the server. COS Control
54
+ 0.5.184 uses the code to treat a quota as a skip when another provider
55
+ proves (on 6.43.2 and older it classifies the error sentence instead).
56
+ - **Cursor is a proof provider.** `{"provider":"cursor"}` runs the Cursor
57
+ `agent` in documented read-only ask mode with the prompt on stdin and reads
58
+ the result event, so a Mac with Cursor signed in can prove an update while
59
+ Claude and Codex are both at their limits.
60
+
1
61
  ## 6.43.2
2
62
 
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.
63
+ What actually reached npm under this number (published 2026-09-02 07:27 CDT
64
+ from the commit before the proof codes landed):
65
+
66
+ - **The Claude proof states its empty MCP config explicitly**
67
+ (`--strict-mcp-config --mcp-config '{"mcpServers":{}}'`) instead of relying
68
+ on `CLAUDE_CODE_SAFE_MODE` alone. Without safe mode, `--tools ''` on Claude
69
+ Code 2.1.251 still loads the whole catalog, which on a large fleet is a
70
+ 244K-token request Haiku refuses before any API call. Belt and braces; the
71
+ 2026-09-01 rollbacks were the session limit, not this.
72
+ - The message-reservation rationale in code and changelog states the
73
+ observed double #74 accurately (one exchange shown twice by the companion).
14
74
 
15
75
  ## 6.43.1
16
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.43.2",
3
+ "version": "6.43.4",
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,2 @@
1
+ {"schemaVersion":1,"recordId":"02b09947-ed7d-47ef-99c5-4985af3ec924","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"43b397bd-dd67-4312-9a5c-4c5d4b246f1d","clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"turnId":"a456fd1d-1e82-4b2d-8a20-5d34681201fd","requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"query":"written by 6.43.3","sessionId":"session-6-43-3","model":"opus","effort":"high","cursorExecutionMode":"ask","messageEra":"era1","globalMsgNum":78,"reference":{"query":"earlier q","response":"earlier a"},"handoffCode":"ABCD","handoffLatest":true,"clientQueueItemId":"q1","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95"}}
2
+ {"schemaVersion":1,"recordId":"a1145a40-2ced-4da3-9cf7-76d84cffc946","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"64c5b502-13f2-4871-b6c4-007b91be2414","clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"turnId":"843e633f-0f92-4fe3-b2a1-9dc683715791","requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"query":"minimal by 6.43.3","sessionId":"session-6-43-3-min","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638"}}
@@ -472,7 +472,8 @@ export function getArchiveChats(date: string): ArchiveChatSummary[] {
472
472
  }
473
473
 
474
474
  /** Get paired Q&A messages for a specific chat within a day. Request refs on
475
- * the user turn and model-output refs on the assistant turn surface together. */
475
+ * the user turn and model-output refs on the assistant turn surface together.
476
+ * Deliberately no origin label here (6.43.4): the day view carries it. */
476
477
  export function getArchiveChatMessages(
477
478
  date: string,
478
479
  chatIndex: number,
@@ -507,11 +508,11 @@ export function getArchiveChatMessages(
507
508
  * (sessionId, timestamp) instead of bare timestamp (collision-prone). */
508
509
  export function getArchiveDayMessages(
509
510
  date: string,
510
- ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> {
511
+ ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> {
511
512
  const archive = loadArchive(date)
512
513
  if (!archive) return []
513
514
 
514
- const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> = []
515
+ const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> = []
515
516
  for (const chat of archive.chats) {
516
517
  for (let i = 0; i < chat.exchanges.length; i++) {
517
518
  const ex = chat.exchanges[i]
@@ -522,6 +523,8 @@ export function getArchiveDayMessages(
522
523
  const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
523
524
  const messageEra = ex.messageEra ?? next.messageEra
524
525
  const modelPreference = resolveExchangePairModel(ex, next, null)
526
+ // Same one-half rule as the live view (routes/sessions.ts).
527
+ const stamped = ex.origin ? ex : next
525
528
  messages.push({
526
529
  query: ex.content,
527
530
  text: next.content,
@@ -531,6 +534,7 @@ export function getArchiveDayMessages(
531
534
  ...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
532
535
  ...(messageEra ? { messageEra } : {}),
533
536
  ...(modelPreference ? { modelPreference } : {}),
537
+ ...(stamped.origin ? { origin: stamped.origin, ...(stamped.originId ? { originId: stamped.originId } : {}) } : {}),
534
538
  ...(attachments.length > 0 ? { attachments } : {}),
535
539
  })
536
540
  i++
@@ -37,6 +37,20 @@ export interface Exchange {
37
37
  * photos on the user exchange, model-published images on the assistant
38
38
  * exchange. Bytes, filesystem paths, and capabilities never persist here. */
39
39
  attachments?: MediaAttachmentRef[]
40
+ /** Who started the job that produced this pair, when it was not the person
41
+ * on the phone or the glasses: `routine` (the scheduler) or `task` (a
42
+ * dispatched task). Absent on every human prompt. The phone's own `g2` stamp
43
+ * never reaches the server; this is the server's half of the label. */
44
+ origin?: 'routine' | 'task'
45
+ /** `morning-brief`, or a task's 12-hex id. */
46
+ originId?: string
47
+ }
48
+
49
+ /** Structural twin of `QueryJobOrigin` so the conversation store takes no
50
+ * import from the job module. */
51
+ export interface ExchangeOriginStamp {
52
+ kind: 'routine' | 'task'
53
+ id: string
40
54
  }
41
55
 
42
56
  export interface ExchangeJobProvenance {
@@ -159,6 +173,14 @@ function loadFromDisk(): void {
159
173
  if (refs.length > 0) exchange.attachments = refs
160
174
  else delete exchange.attachments
161
175
  }
176
+ // Same boundary for the origin label (6.43.4): only the two kinds this
177
+ // build renders survive a load, and an id never survives without its kind.
178
+ if (exchange.origin !== 'routine' && exchange.origin !== 'task') {
179
+ delete exchange.origin
180
+ delete exchange.originId
181
+ } else if (typeof exchange.originId !== 'string' || !exchange.originId) {
182
+ delete exchange.originId
183
+ }
162
184
  }
163
185
  sessions.set(id, session)
164
186
  loaded++
@@ -528,6 +550,7 @@ export function reconcileExchangeByJobIdentity(
528
550
  attachments?: MediaAttachmentRef[],
529
551
  messageEra?: string,
530
552
  modelPreference?: ModelPreference | null,
553
+ origin?: ExchangeOriginStamp,
531
554
  ): ReconciledExchange {
532
555
  if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
533
556
  throw new Error('conversation: invalid durable job identity')
@@ -572,6 +595,7 @@ export function reconcileExchangeByJobIdentity(
572
595
  generation: identity.generation,
573
596
  ...(stampedModel ? { modelPreference: stampedModel } : {}),
574
597
  ...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
598
+ ...(origin ? { origin: origin.kind, originId: origin.id } : {}),
575
599
  }
576
600
  session.exchanges.push(exchange)
577
601
  } else {
@@ -580,6 +604,9 @@ export function reconcileExchangeByJobIdentity(
580
604
  exchange.globalMsgNum = globalMsgNum
581
605
  exchange.messageEra = projectedMessageEra
582
606
  if (stampedModel) exchange.modelPreference = stampedModel
607
+ // Guarded like the model: the cursor and ollama bridges reconcile the user
608
+ // half mid-run WITHOUT an origin, and must not erase the runtime's stamp.
609
+ if (origin) { exchange.origin = origin.kind; exchange.originId = origin.id }
583
610
  if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
584
611
  else delete exchange.attachments
585
612
 
@@ -108,6 +108,9 @@ export type TickResult =
108
108
 
109
109
  /** Deterministic v4-shaped client id for one local day, so a retry after a
110
110
  * crashed submission admits as the SAME job. The store dedupes on it. */
111
+ /** The routine id every surface renders as ROUTINE for the morning brief. */
112
+ export const MORNING_BRIEF_ROUTINE_ID = 'morning-brief'
113
+
111
114
  export function scheduledClientJobId(day: string, timezone: string): string {
112
115
  const digest = createHash('sha256').update(`morning-brief|${timezone}|${day}`).digest()
113
116
  const bytes = Buffer.from(digest.subarray(0, 16))
@@ -128,6 +131,9 @@ export class MorningBriefScheduler {
128
131
  private ledger: MorningBriefLedger
129
132
  private timer: ReturnType<typeof setInterval> | null = null
130
133
  private tickInFlight: Promise<TickResult> | null = null
134
+ /** One chain for tick() AND runNow(): the in-progress read in runNow and the
135
+ * ledger write in fire() must never interleave with a scheduled fire. */
136
+ private serial: Promise<unknown> = Promise.resolve()
131
137
  private readonly now: () => number
132
138
  private readonly log: (line: string) => void
133
139
  readonly quarantinedConfig?: string
@@ -151,7 +157,11 @@ export class MorningBriefScheduler {
151
157
  start(): void {
152
158
  if (this.timer) return
153
159
  const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
154
- this.timer = setInterval(() => { void this.tick() }, tickMs)
160
+ // A rejected tick must never become an unhandled rejection (Node exits on
161
+ // one by default): log it and let the next interval try again.
162
+ this.timer = setInterval(() => {
163
+ this.tick().catch(error => this.log(`tick failed: ${error instanceof Error ? error.message : String(error)}`))
164
+ }, tickMs)
155
165
  this.timer.unref?.()
156
166
  this.log(`scheduled ${this.config.enabled ? `daily at ${this.config.time} ${this.config.timezone}` : 'off'} · next ${nextScheduledFire(this.config, this.now()) ?? 'none'}`)
157
167
  }
@@ -181,10 +191,16 @@ export class MorningBriefScheduler {
181
191
  /** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
182
192
  tick(): Promise<TickResult> {
183
193
  if (this.tickInFlight) return this.tickInFlight
184
- this.tickInFlight = this.runTick().finally(() => { this.tickInFlight = null })
194
+ this.tickInFlight = this.serialize(() => this.runTick()).finally(() => { this.tickInFlight = null })
185
195
  return this.tickInFlight
186
196
  }
187
197
 
198
+ private serialize<T>(fn: () => Promise<T>): Promise<T> {
199
+ const next = this.serial.then(fn, fn)
200
+ this.serial = next.then(() => undefined, () => undefined)
201
+ return next
202
+ }
203
+
188
204
  private async runTick(): Promise<TickResult> {
189
205
  if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
190
206
  if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
@@ -195,7 +211,11 @@ export class MorningBriefScheduler {
195
211
  }
196
212
 
197
213
  /** "Run now" from a settings surface. Bounded per day; refused while a brief is live. */
198
- async runNow(): Promise<MorningBriefRun> {
214
+ runNow(): Promise<MorningBriefRun> {
215
+ return this.serialize(() => this.runNowInner())
216
+ }
217
+
218
+ private async runNowInner(): Promise<MorningBriefRun> {
199
219
  if (!this.deps.durableJobsEnabled()) {
200
220
  throw new MorningBriefRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run the brief.')
201
221
  }
@@ -227,14 +247,45 @@ export class MorningBriefScheduler {
227
247
  ? scheduledClientJobId(day, this.config.timezone)
228
248
  : randomUUID()
229
249
 
230
- // Resume: a ledger row with no job id. Adopt an admission the coordinator
231
- // may already hold for this identity before considering a fresh submit.
232
- if (resume) {
250
+ // Adopt before minting, on EVERY scheduled fire not only the crash-resume
251
+ // branch. A submit that threw after admission, a ledger row lost to
252
+ // quarantine, and a plain resume all find the coordinator already holding
253
+ // this day's identity. A manual fire mints a fresh UUID and can never hit,
254
+ // so it skips the round trip. Reusing `existing.globalMsgNum` is safe even
255
+ // though liveReservations() skips submitError rows: the store's own
256
+ // reservation source (query-job-runtime registers the coordinator's live
257
+ // identities) holds an admitted job's number independently of the ledger.
258
+ if (trigger === 'scheduled') {
233
259
  const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
234
260
  if (existing) {
235
- const adopted: MorningBriefRun = { ...resume, jobId: existing.jobId, lastKnownStatus: existing.status }
261
+ const priorRow = resume ?? this.ledger.runs.filter(r => r.day === day && r.trigger === trigger).at(-1)
262
+ // Carry every current and future ledger field from the row being
263
+ // replaced; drop only the submit error (the job exists), then override
264
+ // the identity and provenance columns from what is actually admitted.
265
+ const { submitError: _dropped, ...carried } = priorRow ?? {}
266
+ const adopted: MorningBriefRun = {
267
+ ...carried,
268
+ id: priorRow?.id ?? randomUUID(),
269
+ day,
270
+ trigger,
271
+ attempt,
272
+ firedAt: priorRow?.firedAt ?? existing.acceptedAt,
273
+ clientJobId,
274
+ generation: 1,
275
+ sessionId: priorRow?.sessionId ?? existing.sessionId,
276
+ messageEra: priorRow?.messageEra ?? existing.messageEra ?? this.deps.currentMessageEra(),
277
+ globalMsgNum: priorRow?.globalMsgNum ?? existing.globalMsgNum ?? this.deps.currentMessageMax() + 1,
278
+ sections: priorRow?.sections ?? briefSections(this.config, day),
279
+ jobId: existing.jobId,
280
+ lastKnownStatus: existing.status,
281
+ }
282
+ if (priorRow?.globalMsgNum == null && existing.globalMsgNum == null) {
283
+ this.log(`adopted job ${existing.jobId} carried no message number; minted #${adopted.globalMsgNum}`)
284
+ }
285
+ // Reusing the prior row's id makes replaceRun REPLACE the day's row
286
+ // rather than push a second one for the same job.
236
287
  this.replaceRun(adopted)
237
- this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId}`)
288
+ this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId} (${priorRow ? `replacing ledger row ${priorRow.id}` : 'no ledger row'})`)
238
289
  return adopted
239
290
  }
240
291
  }
@@ -272,6 +323,9 @@ export class MorningBriefScheduler {
272
323
  activityToolMode: 'status',
273
324
  attachmentIds: [],
274
325
  attachmentRefs: [],
326
+ // The label every surface renders as ROUTINE. Outside the fingerprint,
327
+ // so a run admitted on 6.43.3 still adopts here by identity.
328
+ origin: { kind: 'routine', id: MORNING_BRIEF_ROUTINE_ID },
275
329
  })
276
330
  const accepted: MorningBriefRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
277
331
  this.replaceRun(accepted)
@@ -1,8 +1,28 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { cosBrainDir } from './launch-dir.js'
3
+ import { resolveAgentBinary } from './cursor-model-catalog.js'
3
4
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
4
5
 
5
- export type ProofProvider = 'claude' | 'codex'
6
+ export type ProofProvider = 'claude' | 'codex' | 'cursor'
7
+ export const PROOF_PROVIDERS: readonly ProofProvider[] = ['claude', 'codex', 'cursor']
8
+
9
+ /**
10
+ * Why a proof did not pass, as a stable code COS Control can branch on
11
+ * without reading logs. `provider_quota` is the one that matters: a vendor
12
+ * session or usage limit is a fact about the vendor's meter, not about the
13
+ * candidate server, and an update must not roll back on it when another
14
+ * provider proves. (2026-09-01: six 6.43.1 updates rolled back on Claude's
15
+ * session limit while Codex proved in 7 s.)
16
+ */
17
+ export type ProviderProofCode =
18
+ | 'provider_quota'
19
+ | 'provider_auth'
20
+ | 'provider_context_overflow'
21
+ | 'provider_missing'
22
+ | 'provider_timeout'
23
+ | 'provider_canceled'
24
+ | 'provider_bad_answer'
25
+ | 'provider_failed'
6
26
 
7
27
  export interface ProviderProofResult {
8
28
  provider: ProofProvider
@@ -10,6 +30,7 @@ export interface ProviderProofResult {
10
30
  durationMs: number
11
31
  cached: boolean
12
32
  error?: string
33
+ code?: ProviderProofCode
13
34
  }
14
35
 
15
36
  const PROOF_TOKEN = 'COS_CONTROL_OK'
@@ -110,13 +131,14 @@ export function runBounded(
110
131
  export const CLAUDE_PROOF_MODEL = 'haiku'
111
132
  export const CLAUDE_PROOF_TIMEOUT_MS = 45_000
112
133
 
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. */
134
+ /** No MCP servers for the proof, stated explicitly rather than relied on
135
+ * from CLAUDE_CODE_SAFE_MODE alone. Without safe mode, Claude Code 2.1.251
136
+ * turns `--tools ''` into "load the whole catalog, expose none of it", and
137
+ * on a Mac with a large MCP fleet that catalog alone is ~244K tokens against
138
+ * Haiku's 200K window ("Prompt is too long", exit 1, zero API time). Safe
139
+ * mode currently prevents that; an explicit empty config with
140
+ * --strict-mcp-config keeps the proof under 20K tokens whatever a future
141
+ * CLI does with the safe-mode flag. */
120
142
  export const CLAUDE_PROOF_MCP_CONFIG = '{"mcpServers":{}}'
121
143
 
122
144
  export function claudeProofArgs(): string[] {
@@ -172,11 +194,85 @@ function safeProofError(result: ProcessResult): string {
172
194
  return 'provider returned no valid proof response'
173
195
  }
174
196
 
197
+ const QUOTA_RE = /usage limit|hit your (?:usage |session )?limit|session limit|rate.?limit|too many requests|\b429\b|\bquota\b|resets? (?:at|in) |over capacity|overloaded|\b529\b|plan limit/i
198
+ const AUTH_RE = /not logged in|please (?:log ?in|sign in)|invalid api key|authentication|unauthori[sz]ed|\b401\b|\b403\b|login required|token (?:has )?expired|no credentials/i
199
+ const OVERFLOW_RE = /prompt is too long|context window|too many tokens|exceeds the (?:model|context)/i
200
+ const MISSING_RE = /ENOENT|command not found|no such file/i
201
+
202
+ /** Vendor text the provider printed, so the code can be derived without ever
203
+ * returning that text to a caller. Claude's JSON `result` carries the error
204
+ * sentence on `is_error`; Codex and Cursor print it on stderr or as a
205
+ * result/error event. */
206
+ export function classifyProofFailure(result: ProcessResult, answer: string, expected = PROOF_TOKEN): ProviderProofCode {
207
+ if (result.aborted) return 'provider_canceled'
208
+ if (result.timedOut) return 'provider_timeout'
209
+ const haystack = `${result.stderr}\n${result.stdout}`.slice(0, 40_000)
210
+ if (result.code === null && MISSING_RE.test(haystack)) return 'provider_missing'
211
+ if (OVERFLOW_RE.test(haystack)) return 'provider_context_overflow'
212
+ if (QUOTA_RE.test(haystack)) return 'provider_quota'
213
+ if (AUTH_RE.test(haystack)) return 'provider_auth'
214
+ if (result.code === 0) return answer === expected ? 'provider_failed' : 'provider_bad_answer'
215
+ return 'provider_failed'
216
+ }
217
+
218
+ /** One safe sentence per code; never the vendor's text. */
219
+ export function describeProofCode(code: ProviderProofCode, result: ProcessResult): string {
220
+ switch (code) {
221
+ case 'provider_quota': return 'provider session or usage limit reached'
222
+ case 'provider_auth': return 'provider is not signed in'
223
+ case 'provider_context_overflow': return 'provider refused the proof prompt as too long'
224
+ case 'provider_missing': return 'provider binary is not installed or not on PATH'
225
+ case 'provider_timeout': return 'provider proof timed out'
226
+ case 'provider_canceled': return 'provider proof canceled'
227
+ case 'provider_bad_answer': return 'provider answered but not with the proof token'
228
+ default: return safeProofError(result)
229
+ }
230
+ }
231
+
232
+ export const CURSOR_PROOF_TIMEOUT_MS = 120_000
233
+
234
+ /** Cursor `agent` in documented read-only ask mode, one stream-json turn, the
235
+ * prompt on stdin like the bridge. No `--model`: the account default is the
236
+ * readiness question, not any particular model. */
237
+ export function cursorProofArgs(workspace = cosBrainDir() ?? process.cwd()): string[] {
238
+ return ['-p', '--mode', 'ask', '--output-format', 'stream-json', '--trust', '--workspace', workspace]
239
+ }
240
+
241
+ /** The final `result` event wins; assistant deltas with a timestamp are the
242
+ * fallback, mirroring cursor-bridge's extractCursorResponseText. */
243
+ export function cursorProofText(stdout: string): string {
244
+ let deltas = ''
245
+ for (const line of stdout.split('\n')) {
246
+ if (!line.trim()) continue
247
+ let event: any
248
+ try { event = JSON.parse(line) } catch { continue }
249
+ const type = String(event?.type ?? '').toLowerCase()
250
+ if (type === 'result') {
251
+ if (event?.subtype === 'success' && event?.is_error !== true && typeof event?.result === 'string') return event.result.trim()
252
+ continue
253
+ }
254
+ if (type !== 'assistant' || typeof event?.timestamp_ms !== 'number') continue
255
+ if (event?.model_call_id != null && event.model_call_id !== '') continue
256
+ const content = event?.message?.content
257
+ if (Array.isArray(content)) {
258
+ for (const block of content) {
259
+ if (typeof block === 'string') deltas += block
260
+ else if (typeof block?.text === 'string') deltas += block.text
261
+ }
262
+ } else if (typeof event?.text === 'string') {
263
+ deltas += event.text
264
+ }
265
+ }
266
+ return deltas.trim()
267
+ }
268
+
175
269
  async function executeProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
176
270
  const started = Date.now()
177
- const result = provider === 'claude'
178
- ? await runBounded('claude', claudeProofArgs(), '', CLAUDE_PROOF_TIMEOUT_MS, signal)
179
- : await runBounded('codex', [
271
+ let result: ProcessResult
272
+ if (provider === 'claude') {
273
+ result = await runBounded('claude', claudeProofArgs(), '', CLAUDE_PROOF_TIMEOUT_MS, signal)
274
+ } else if (provider === 'codex') {
275
+ result = await runBounded('codex', [
180
276
  'exec',
181
277
  '--sandbox', 'read-only',
182
278
  '--skip-git-repo-check',
@@ -185,16 +281,25 @@ async function executeProof(provider: ProofProvider, signal?: AbortSignal): Prom
185
281
  '--ephemeral',
186
282
  '-',
187
283
  ], PROOF_PROMPT, 120_000, signal)
284
+ } else {
285
+ const binary = resolveAgentBinary()
286
+ result = binary
287
+ ? await runBounded(binary, cursorProofArgs(), PROOF_PROMPT, CURSOR_PROOF_TIMEOUT_MS, signal)
288
+ : { code: null, stdout: '', stderr: 'ENOENT: cursor agent binary not found', timedOut: false, aborted: false }
289
+ }
188
290
  const text = provider === 'claude'
189
291
  ? claudeProofText(result.stdout)
190
- : codexProofText(result.stdout)
292
+ : provider === 'codex' ? codexProofText(result.stdout) : cursorProofText(result.stdout)
191
293
  const ok = result.code === 0 && text === PROOF_TOKEN
294
+ if (ok) return { provider, ok, durationMs: Date.now() - started, cached: false }
295
+ const code = classifyProofFailure(result, text)
192
296
  return {
193
297
  provider,
194
- ok,
298
+ ok: false,
195
299
  durationMs: Date.now() - started,
196
300
  cached: false,
197
- ...(ok ? {} : { error: safeProofError(result) }),
301
+ error: describeProofCode(code, result),
302
+ code,
198
303
  }
199
304
  }
200
305
 
@@ -112,6 +112,14 @@ function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'o
112
112
  return isCodexModel(model) ? 'codex' : 'claude'
113
113
  }
114
114
 
115
+ /** The origin label as it travels on the display bus and the message views:
116
+ * FLATTENED (`origin: 'routine', originId: 'morning-brief'`) so every client
117
+ * parses one shape, and identical on `start`, `done` and `error`. Spread LAST
118
+ * into each event so no provider metadata key can overwrite it. */
119
+ function originStamp(request: QueryJobRequest): { origin?: NonNullable<QueryJobRequest['origin']>['kind']; originId?: string } {
120
+ return request.origin ? { origin: request.origin.kind, originId: request.origin.id } : {}
121
+ }
122
+
115
123
  /** Project the authoritative terminal journal into the derived conversation
116
124
  * cache. Journaled request/response text always wins over bridge-written
117
125
  * partial rows; validated media refs may be merged because output media can
@@ -148,6 +156,7 @@ async function projectPublicConversationTerminal(
148
156
  request.attachmentRefs,
149
157
  request.messageEra,
150
158
  normalizeModelPreference(request.model),
159
+ request.origin,
151
160
  )
152
161
  reconcileExchangeByJobIdentity(
153
162
  request.sessionId,
@@ -158,6 +167,7 @@ async function projectPublicConversationTerminal(
158
167
  mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
159
168
  request.messageEra,
160
169
  normalizeModelPreference(request.model),
170
+ request.origin,
161
171
  )
162
172
  flushConversationToDisk()
163
173
  }
@@ -203,6 +213,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
203
213
  sessionId,
204
214
  cliSessionId,
205
215
  ...metadata,
216
+ ...originStamp(request),
206
217
  } })
207
218
  },
208
219
  onProviderProcess: metadata => callbacks.onProviderProcess({
@@ -281,6 +292,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
281
292
  cliSessionId,
282
293
  ...runMetadata,
283
294
  ...(attachments.length > 0 ? { attachments } : {}),
295
+ ...originStamp(request),
284
296
  } })
285
297
  } finally {
286
298
  attachmentLease?.release()
@@ -296,6 +308,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
296
308
  turnId,
297
309
  messageEra: request.messageEra,
298
310
  globalMsgNum: request.globalMsgNum,
311
+ ...originStamp(request),
299
312
  error,
300
313
  } })
301
314
  },
@@ -13,6 +13,7 @@ import {
13
13
  isTerminalQueryJobStatus,
14
14
  normalizeQueryJobError,
15
15
  parseQueryJobOutputImageStats,
16
+ originWasDropped,
16
17
  parseQueryJobRequest,
17
18
  requestFingerprint,
18
19
  sanitizeQueryJobActivity,
@@ -317,6 +318,8 @@ export class QueryJobStore {
317
318
  journalFailures: 0,
318
319
  interruptedOnBoot: 0,
319
320
  evictedHydratedJobs: 0,
321
+ originDropped: 0,
322
+ fingerprintMismatches: 0,
320
323
  lastErrorCode: null,
321
324
  lastSuccessfulWriteAt: null,
322
325
  rootFingerprint: createHash('sha256').update(options.root).digest('hex').slice(0, 16),
@@ -436,10 +439,25 @@ export class QueryJobStore {
436
439
  if (!hydrated) {
437
440
  if (record.type !== 'accepted' || !record.request || record.eventSeq !== 1) return undefined
438
441
  let request: QueryJobRequest
442
+ // Lenient: a record written by another build hydrates with an unknown
443
+ // origin dropped (and counted) rather than being discarded.
439
444
  try { request = parseQueryJobRequest(record.request) } catch { return undefined }
445
+ // Counted once per job: ensureHydrated replays this same record after an
446
+ // LRU eviction, and by then the identity is already registered below.
447
+ // (The mismatch counter needs no such guard: a mismatch returns before
448
+ // the identity is registered, so a replay can never reach it.)
449
+ if (!this.identitiesByJobId.has(record.jobId) && originWasDropped(record.request, request)) this.health.originDropped++
440
450
  const fingerprint = requestFingerprint(request)
441
- if (fingerprint !== record.requestFingerprint
442
- || request.clientJobId !== record.clientJobId
451
+ if (fingerprint !== record.requestFingerprint) {
452
+ // A mismatch DROPS the job from hydration. Say so once, loudly: a
453
+ // counter in /api/health is read after the history has already gone.
454
+ if (this.health.fingerprintMismatches === 0) {
455
+ console.error(`[query-jobs] journal fingerprint mismatch for ${record.jobId}; the job is not hydrated (the running build picks different identity keys than the writer, or the record was edited)`)
456
+ }
457
+ this.health.fingerprintMismatches++
458
+ return undefined
459
+ }
460
+ if (request.clientJobId !== record.clientJobId
443
461
  || request.generation !== record.generation) return undefined
444
462
  const acceptedAt = record.persistedAt
445
463
  const retentionUntil = new Date(new Date(acceptedAt).getTime() + this.retentionDays * 86_400_000).toISOString()
@@ -626,7 +644,13 @@ export class QueryJobStore {
626
644
 
627
645
  async admit(raw: unknown): Promise<QueryJobAdmissionResult> {
628
646
  await this.ensureInitialized()
629
- const request = parseQueryJobRequest(raw)
647
+ // Strict only where strictness is safe on the public route: a KNOWN kind
648
+ // with an id this server cannot accept is a bug in the scheduler or the
649
+ // dispatcher that stamped it and throws (→ 400). An unknown kind, or the
650
+ // phone's bare-string stamp, is dropped and counted below — once per job
651
+ // created, never per retry of the same identity.
652
+ const request = parseQueryJobRequest(raw, { strictOrigin: true })
653
+ const originDropped = originWasDropped(raw, request)
630
654
  const fingerprint = requestFingerprint(request)
631
655
  const existingIdentity = this.identitiesByKey.get(identityKey(request.clientJobId, request.generation))
632
656
  if (existingIdentity) await this.ensureHydrated(existingIdentity.jobId)
@@ -663,6 +687,7 @@ export class QueryJobStore {
663
687
  const highestGeneration = lineage.reduce((max, item) => Math.max(max, item.generation), 0)
664
688
  if (request.generation <= highestGeneration) throw new QueryJobGenerationOrderError(request.clientJobId)
665
689
 
690
+ if (originDropped) this.health.originDropped++
666
691
  const now = this.now().toISOString()
667
692
  const jobId = randomUUID()
668
693
  const turnId = randomUUID()
@@ -52,6 +52,36 @@ export interface QueryJobPromptReference {
52
52
  response: string
53
53
  }
54
54
 
55
+ /**
56
+ * Who STARTED a job, when it was not the person holding the phone or the
57
+ * glasses. A label, nothing more: the server never infers anything from its
58
+ * absence (Miles, 2026-09-02). `routine` is the scheduler (`morning-brief`),
59
+ * `task` is a dispatch of a captured task (the id is the task's 12-hex id).
60
+ * Human prompts typed on the phone carry no origin. The phone stamps `g2`
61
+ * locally for prompts spoken on the glasses, as a bare string; if that string
62
+ * ever reaches this route it is dropped and counted, never rejected.
63
+ */
64
+ export interface QueryJobOrigin {
65
+ kind: 'routine' | 'task'
66
+ id: string
67
+ }
68
+
69
+ /** One alphabet for both kinds: `morning-brief` and a 12-hex task id both fit.
70
+ * Up to 64 chars is this server's own bound (a routine slug, a 12-hex task id
71
+ * and a bare sha256 all fit); COS Control 0.5.185 mirrors it in its allowlist. */
72
+ export const QUERY_JOB_ORIGIN_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/
73
+
74
+ export interface ParseQueryJobRequestOptions {
75
+ /** Admission (the public job route reaches the parser with this on): a KNOWN
76
+ * kind whose id fails the alphabet is a bug in the server's own scheduler or
77
+ * dispatcher, and throws. An UNKNOWN kind never throws on any path — it is a
78
+ * label this build cannot render, dropped and counted, so a newer client is
79
+ * never refused. Hydration leaves this off: a record written by another
80
+ * build must still hydrate. A bare STRING origin is dropped on both paths —
81
+ * it is the phone's local `'g2'` shape, never an error. */
82
+ strictOrigin?: boolean
83
+ }
84
+
55
85
  /** Immutable, persistence-safe request. Provider-only objects (paths, image
56
86
  * bytes, AbortControllers, handoff runtime state) deliberately do not fit. */
57
87
  export interface QueryJobRequest {
@@ -72,6 +102,10 @@ export interface QueryJobRequest {
72
102
  attachmentIds: string[]
73
103
  attachmentRefs: MediaAttachmentRef[]
74
104
  activityToolMode: QueryJobActivityMode
105
+ /** Present only on server-started jobs. NOT part of the request fingerprint
106
+ * (see `FINGERPRINT_KEYS`), so a rollback to a build that drops it still
107
+ * hydrates the journal. */
108
+ origin?: QueryJobOrigin
75
109
  }
76
110
 
77
111
  export interface QueryJobProviderLinkage {
@@ -177,6 +211,13 @@ export interface QueryJobStoreHealth {
177
211
  journalFailures: number
178
212
  interruptedOnBoot: number
179
213
  evictedHydratedJobs: number
214
+ /** Requests whose `origin` was present but not one this build recognises
215
+ * (a bare string, or an object of a later build's kind) and was dropped. */
216
+ originDropped: number
217
+ /** Journal `accepted` records whose stored fingerprint no longer matches the
218
+ * running build's `requestFingerprint` — each one is a job that did NOT
219
+ * hydrate. Zero is the only healthy value. */
220
+ fingerprintMismatches: number
180
221
  lastErrorCode: string | null
181
222
  lastSuccessfulWriteAt: string | null
182
223
  rootFingerprint: string
@@ -226,7 +267,7 @@ function boundedContent(value: unknown, field: string, max: number, allowEmpty =
226
267
 
227
268
  /** Parse untrusted admission input into the only request shape allowed in the
228
269
  * private journal. Unknown keys are dropped before fingerprinting. */
229
- export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
270
+ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequestOptions = {}): QueryJobRequest {
230
271
  if (!raw || typeof raw !== 'object') throw new QueryJobValidationError('invalid_request')
231
272
  const input = raw as Record<string, unknown>
232
273
  const clientJobId = requiredString(input.clientJobId, 'client_job_id', 36).toLowerCase()
@@ -272,6 +313,29 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
272
313
  ? input.activityToolMode
273
314
  : 'status'
274
315
 
316
+ // `== null` covers both absent and JSON null, the way `reference` and
317
+ // `globalMsgNum` are read above. A non-object (the phone's local `'g2'`
318
+ // string) is silently absent on every path; the store counts the drop.
319
+ let origin: QueryJobOrigin | undefined
320
+ if (input.origin != null && typeof input.origin === 'object') {
321
+ const candidate = input.origin as Record<string, unknown>
322
+ const kind = candidate.kind
323
+ const id = candidate.id
324
+ if (kind === 'routine' || kind === 'task') {
325
+ if (typeof id === 'string' && QUERY_JOB_ORIGIN_ID_RE.test(id)) {
326
+ origin = { kind, id }
327
+ } else if (options.strictOrigin) {
328
+ // A kind this build knows with an id it cannot accept is a bug in the
329
+ // caller that stamped it (the scheduler, the dispatcher): loud.
330
+ throw new QueryJobValidationError('invalid_origin')
331
+ }
332
+ }
333
+ // A kind this build does NOT know is a label it cannot render, never a
334
+ // reason to refuse the job: the public job route reaches this parser with
335
+ // strictOrigin, so an unknown kind must degrade to unlabeled (and be
336
+ // counted by the store), or a newer client would have every prompt 400'd.
337
+ }
338
+
275
339
  const attachmentIds = parseMediaIdList(input.attachmentIds)
276
340
  const attachmentRefs = parseMediaAttachmentRefs(input.attachmentRefs ?? input.attachments)
277
341
  if (!query.trim() && attachmentIds.length === 0 && attachmentRefs.length === 0) {
@@ -295,9 +359,19 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
295
359
  attachmentIds,
296
360
  attachmentRefs,
297
361
  activityToolMode,
362
+ ...(origin ? { origin } : {}),
298
363
  }
299
364
  }
300
365
 
366
+ /** True when the raw admission/journal input carried an `origin` the parser
367
+ * did not keep — a bare string, or an object shape this build does not know.
368
+ * The store counts these; the parser stays pure. */
369
+ export function originWasDropped(raw: unknown, request: QueryJobRequest): boolean {
370
+ if (!raw || typeof raw !== 'object') return false
371
+ const rawOrigin = (raw as Record<string, unknown>).origin
372
+ return rawOrigin != null && request.origin == null
373
+ }
374
+
301
375
  function canonical(value: unknown): unknown {
302
376
  if (Array.isArray(value)) return value.map(canonical)
303
377
  if (!value || typeof value !== 'object') return value
@@ -305,8 +379,49 @@ function canonical(value: unknown): unknown {
305
379
  return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonical(record[key])]))
306
380
  }
307
381
 
382
+ /**
383
+ * The request keys that make up a job's IDENTITY — frozen at the sixteen keys
384
+ * `QueryJobRequest` had before `origin` existed. Provenance and enforcement
385
+ * keys (`origin`, and any later `trustMode`/`toolAllowlist`) are deliberately
386
+ * outside it: a build that does not know a key must still hydrate the journal
387
+ * a newer build wrote, and a rollback must never discard a week of jobs.
388
+ *
389
+ * Consequence, stated: two submissions for the same (clientJobId, generation)
390
+ * that differ only in those excluded keys are the SAME job, and the second
391
+ * returns the first. The scheduled brief reuses one identity per DAY across
392
+ * its three attempts, and its excluded keys never differ between attempts; a
393
+ * genuine resubmit differs in `globalMsgNum`, which IS in the fingerprint, so
394
+ * it conflicts rather than silently adopting. Phone prompts never send an
395
+ * excluded key.
396
+ *
397
+ * Pick contract: an absent key stays absent. The parser never materialises an
398
+ * optional as null/''/0/[] (every optional is a conditional spread in its
399
+ * return literal), and `JSON.stringify` drops an undefined value, so the pick
400
+ * needs no guard of its own. A `?? null` pick WOULD re-hash every stored
401
+ * request and silently drop the journal; the test pins that property.
402
+ */
403
+ export const FINGERPRINT_KEYS = [
404
+ 'clientJobId', 'generation', 'query', 'sessionId', 'model', 'effort',
405
+ 'cursorExecutionMode', 'messageEra', 'globalMsgNum', 'reference', 'handoffCode',
406
+ 'handoffLatest', 'clientQueueItemId', 'attachmentIds', 'attachmentRefs',
407
+ 'activityToolMode',
408
+ ] as const satisfies readonly (keyof QueryJobRequest)[]
409
+
410
+ /** The request keys deliberately OUTSIDE the identity. Every key of
411
+ * `QueryJobRequest` must appear in exactly one of the two lists: adding a key
412
+ * without naming it here or above fails to compile, so the author picks a side. */
413
+ export const FINGERPRINT_EXCLUDED = ['origin'] as const satisfies readonly (keyof QueryJobRequest)[]
414
+ type FingerprintUncovered = Exclude<keyof QueryJobRequest, typeof FINGERPRINT_KEYS[number] | typeof FINGERPRINT_EXCLUDED[number]>
415
+ export const FINGERPRINT_KEYS_COVER_REQUEST: FingerprintUncovered extends never ? true : never = true
416
+
417
+ function pickFingerprintKeys(request: QueryJobRequest): Record<string, unknown> {
418
+ const picked: Record<string, unknown> = {}
419
+ for (const key of FINGERPRINT_KEYS) picked[key] = request[key]
420
+ return picked
421
+ }
422
+
308
423
  export function requestFingerprint(request: QueryJobRequest): string {
309
- return createHash('sha256').update(JSON.stringify(canonical(request))).digest('hex')
424
+ return createHash('sha256').update(JSON.stringify(canonical(pickFingerprintKeys(request)))).digest('hex')
310
425
  }
311
426
 
312
427
  export function isTerminalQueryJobStatus(status: QueryJobStatus): status is QueryJobTerminalStatus {
@@ -101,6 +101,9 @@ function durableQueryJobStatus() {
101
101
  retainedIdentities: runtime.store.retainedIdentities,
102
102
  subscribers: runtime.store.subscribers,
103
103
  malformedRows: runtime.store.malformedRows,
104
+ originDropped: runtime.store.originDropped,
105
+ fingerprintMismatches: runtime.store.fingerprintMismatches,
106
+ evictedHydratedJobs: runtime.store.evictedHydratedJobs,
104
107
  journalFailures: runtime.store.journalFailures,
105
108
  interruptedOnBoot: runtime.store.interruptedOnBoot,
106
109
  lastErrorCode: runtime.store.lastErrorCode,
@@ -126,7 +126,8 @@ export function resolveFromArchiveDir(
126
126
  * global numbers (the archive-lib read path strips globalMsgNum; the browser
127
127
  * needs it so "reference message N" is self-evident from the screen). Same
128
128
  * user->next-assistant pairing as the lib; the pair's number is the user
129
- * turn's stamp (falling back to the assistant's). Dir-param form for tests. */
129
+ * turn's stamp (falling back to the assistant's). Dir-param form for tests.
130
+ * Deliberately no origin label here (6.43.4): the day view carries it. */
130
131
  export function readArchiveChatNumbered(
131
132
  dir: string,
132
133
  date: string,
@@ -1,5 +1,5 @@
1
1
  import { Router } from 'express'
2
- import { runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
2
+ import { PROOF_PROVIDERS, runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
3
3
  import {
4
4
  acquireMaintenanceWork,
5
5
  maintenanceErrorPayload,
@@ -17,8 +17,8 @@ providerProofRouter.post('/diagnostics/provider-proof', async (req, res) => {
17
17
  || address.startsWith('127.') || address.startsWith('::ffff:127.')
18
18
  if (!loopback) return res.status(403).json({ error: 'loopback_required' })
19
19
  const provider = req.body?.provider
20
- if (provider !== 'claude' && provider !== 'codex') {
21
- return res.status(400).json({ error: 'provider must be claude or codex' })
20
+ if (!PROOF_PROVIDERS.includes(provider)) {
21
+ return res.status(400).json({ error: 'provider must be claude, codex, or cursor', code: 'provider_unknown' })
22
22
  }
23
23
  const controllerProof = maintenanceOperationCredentialsValid({
24
24
  leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
@@ -79,7 +79,9 @@ sessionsRouter.post('/sessions/:id/context-break', (req, res) => {
79
79
  res.json({ ok: true })
80
80
  })
81
81
 
82
- // GET /api/sessions/:id/messages — client-compatible format (paired Q&A)
82
+ // GET /api/sessions/:id/messages — client-compatible format (paired Q&A).
83
+ // Deliberately carries no origin/originId (6.43.4): this is the legacy paired
84
+ // shape. The labelled views are today/all-messages and the day archive.
83
85
  sessionsRouter.get('/sessions/:id/messages', (req, res) => {
84
86
  const exchanges = getHistory(req.params.id)
85
87
  if (exchanges.length === 0) {
@@ -315,6 +317,8 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
315
317
  globalMsgNum?: number
316
318
  messageEra?: string
317
319
  modelPreference?: string
320
+ origin?: 'routine' | 'task'
321
+ originId?: string
318
322
  attachments?: MediaAttachmentRef[]
319
323
  }> = []
320
324
  const liveSessions = getActiveSessions()
@@ -331,6 +335,10 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
331
335
  const messageEra = ex.messageEra ?? next.messageEra
332
336
  const attachments = turnAttachments(session.id, globalMsgNum, messageEra, activeEra, ex.attachments, next.attachments)
333
337
  const modelPreference = resolveExchangePairModel(ex, next, session.modelPreference)
338
+ // Both halves of a pair are stamped together by the terminal
339
+ // projection; pick BOTH fields from ONE half so a mid-run insert
340
+ // (stamped later) can never pair one half's kind with the other's id.
341
+ const stamped = ex.origin ? ex : next
334
342
  liveMessages.push({
335
343
  query: ex.content,
336
344
  text: next.content,
@@ -341,6 +349,7 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
341
349
  ...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
342
350
  ...(messageEra ? { messageEra } : {}),
343
351
  ...(modelPreference ? { modelPreference } : {}),
352
+ ...(stamped.origin ? { origin: stamped.origin, ...(stamped.originId ? { originId: stamped.originId } : {}) } : {}),
344
353
  ...(attachments.length > 0 ? { attachments } : {}),
345
354
  })
346
355
  i++