@gotcos/glasses-server 6.43.1 → 6.43.3

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,17 +1,53 @@
1
+ ## 6.43.3
2
+
3
+ Managed-server updates no longer hinge on one vendor's meter.
4
+
5
+ - **The readiness proof reports why it failed, as a code.** COS Control
6
+ verifies a candidate server with one real no-tool query per installed
7
+ provider. On 2026-09-01 Claude had hit its session limit, Codex proved in
8
+ seven seconds, and six 6.43.1 updates still rolled back because the only
9
+ answer was "provider process exited 1". `POST /api/diagnostics/provider-proof`
10
+ now returns `code`: `provider_quota` (vendor session or usage limit),
11
+ `provider_auth` (not signed in), `provider_context_overflow`,
12
+ `provider_missing`, `provider_timeout`, `provider_canceled`,
13
+ `provider_bad_answer`, or `provider_failed`. The `error` sentence is one
14
+ fixed phrase per code; vendor text never leaves the server. COS Control
15
+ 0.5.184 uses the code to treat a quota as a skip when another provider
16
+ proves (on 6.43.2 and older it classifies the error sentence instead).
17
+ - **Cursor is a proof provider.** `{"provider":"cursor"}` runs the Cursor
18
+ `agent` in documented read-only ask mode with the prompt on stdin and reads
19
+ the result event, so a Mac with Cursor signed in can prove an update while
20
+ Claude and Codex are both at their limits.
21
+
22
+ ## 6.43.2
23
+
24
+ What actually reached npm under this number (published 2026-09-02 07:27 CDT
25
+ from the commit before the proof codes landed):
26
+
27
+ - **The Claude proof states its empty MCP config explicitly**
28
+ (`--strict-mcp-config --mcp-config '{"mcpServers":{}}'`) instead of relying
29
+ on `CLAUDE_CODE_SAFE_MODE` alone. Without safe mode, `--tools ''` on Claude
30
+ Code 2.1.251 still loads the whole catalog, which on a large fleet is a
31
+ 244K-token request Haiku refuses before any API call. Belt and braces; the
32
+ 2026-09-01 rollbacks were the session limit, not this.
33
+ - The message-reservation rationale in code and changelog states the
34
+ observed double #74 accurately (one exchange shown twice by the companion).
35
+
1
36
  ## 6.43.1
2
37
 
3
38
  Two things the first night of the morning brief taught us, plus the depth
4
39
  behind each source.
5
40
 
6
- - **A running brief no longer collides with your next message.** The brief
7
- reserved #74 at 22:18 and was still running when the phone minted #74 for
8
- the next prompt at 22:25, because `/api/message-counter` only counted
9
- exchanges that had already been projected. Numbers held by admitted,
10
- not-yet-terminal durable jobs (and by the brief's own ledger row, which
11
- exists before the job does) now count toward the ceiling. New
12
- `lib/message-reservations.ts` registry; the job store keeps the number on
13
- its always-in-memory identity so the answer is synchronous and survives a
14
- restart.
41
+ - **A running brief can no longer collide with your next message.** A brief
42
+ holds its number for the minutes it runs, but `/api/message-counter` only
43
+ counted exchanges that had already been projected, so a phone whose counter
44
+ last synced at boot could mint the same number for its next prompt. Numbers
45
+ held by admitted, not-yet-terminal durable jobs (and by the brief's own
46
+ ledger row, which exists before the job does) now count toward the ceiling.
47
+ New `lib/message-reservations.ts` registry; the job store keeps the number
48
+ on its always-in-memory identity so the answer is synchronous and survives
49
+ a restart. (The double #74 reported on 6.9.443 was the companion showing one
50
+ exchange twice; companion 6.9.445 fixes that side.)
15
51
  - **Sources show what is behind them.** `GET /api/morning-brief` now carries
16
52
  `coverage`: one row per source with a state (`ready`, `empty`,
17
53
  `unavailable`, `runtime`) and a summary line such as "2,312 meetings
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.43.1",
3
+ "version": "6.43.3",
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": {
@@ -4,9 +4,12 @@
4
4
  // reports, and a durable job carries the number it was minted with until its
5
5
  // terminal projection writes the exchange into the session. Between admission
6
6
  // and projection the number existed nowhere the counter looked, so a second
7
- // producer minted it again: on 2026-09-01 the scheduled morning brief reserved
8
- // #74 at 22:18:40 and the phone handed #74 to "Cross surface" at 22:25:15,
9
- // because its counter only re-syncs at boot and the server said max was 73.
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.)
10
13
  //
11
14
  // Every holder of a not-yet-projected number registers a source here. The
12
15
  // counter route and the brief's own reservation read the union. Sources
@@ -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,12 +131,24 @@ export function runBounded(
110
131
  export const CLAUDE_PROOF_MODEL = 'haiku'
111
132
  export const CLAUDE_PROOF_TIMEOUT_MS = 45_000
112
133
 
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. */
142
+ export const CLAUDE_PROOF_MCP_CONFIG = '{"mcpServers":{}}'
143
+
113
144
  export function claudeProofArgs(): string[] {
114
145
  return [
115
146
  '-p',
116
147
  '--model', CLAUDE_PROOF_MODEL,
117
148
  '--output-format', 'json',
118
149
  '--permission-mode', 'dontAsk',
150
+ '--strict-mcp-config',
151
+ '--mcp-config', CLAUDE_PROOF_MCP_CONFIG,
119
152
  '--tools', '',
120
153
  '--allowedTools', '',
121
154
  '--system-prompt', PROOF_PROMPT,
@@ -161,11 +194,85 @@ function safeProofError(result: ProcessResult): string {
161
194
  return 'provider returned no valid proof response'
162
195
  }
163
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
+
164
269
  async function executeProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
165
270
  const started = Date.now()
166
- const result = provider === 'claude'
167
- ? await runBounded('claude', claudeProofArgs(), '', CLAUDE_PROOF_TIMEOUT_MS, signal)
168
- : 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', [
169
276
  'exec',
170
277
  '--sandbox', 'read-only',
171
278
  '--skip-git-repo-check',
@@ -174,16 +281,25 @@ async function executeProof(provider: ProofProvider, signal?: AbortSignal): Prom
174
281
  '--ephemeral',
175
282
  '-',
176
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
+ }
177
290
  const text = provider === 'claude'
178
291
  ? claudeProofText(result.stdout)
179
- : codexProofText(result.stdout)
292
+ : provider === 'codex' ? codexProofText(result.stdout) : cursorProofText(result.stdout)
180
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)
181
296
  return {
182
297
  provider,
183
- ok,
298
+ ok: false,
184
299
  durationMs: Date.now() - started,
185
300
  cached: false,
186
- ...(ok ? {} : { error: safeProofError(result) }),
301
+ error: describeProofCode(code, result),
302
+ code,
187
303
  }
188
304
  }
189
305
 
@@ -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'