@gotcos/glasses-server 6.53.3 → 6.54.0

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.
@@ -194,7 +194,9 @@ function safeProofError(result: ProcessResult): string {
194
194
  return 'provider returned no valid proof response'
195
195
  }
196
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
197
+ /** A provider's own words for a spent session, usage, rate or capacity limit. Also the lens
198
+ * gist's (lens-gist-engines.ts), so both read a limit the same way. */
199
+ export const PROVIDER_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
200
  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
201
  const OVERFLOW_RE = /prompt is too long|context window|too many tokens|exceeds the (?:model|context)/i
200
202
  const MISSING_RE = /ENOENT|command not found|no such file/i
@@ -209,7 +211,7 @@ export function classifyProofFailure(result: ProcessResult, answer: string, expe
209
211
  const haystack = `${result.stderr}\n${result.stdout}`.slice(0, 40_000)
210
212
  if (result.code === null && MISSING_RE.test(haystack)) return 'provider_missing'
211
213
  if (OVERFLOW_RE.test(haystack)) return 'provider_context_overflow'
212
- if (QUOTA_RE.test(haystack)) return 'provider_quota'
214
+ if (PROVIDER_QUOTA_RE.test(haystack)) return 'provider_quota'
213
215
  if (AUTH_RE.test(haystack)) return 'provider_auth'
214
216
  if (result.code === 0) return answer === expected ? 'provider_failed' : 'provider_bad_answer'
215
217
  return 'provider_failed'
@@ -38,6 +38,7 @@ import { join } from 'node:path'
38
38
  import { atomicWriteFileSync } from './atomic-fs.js'
39
39
  import { dataPath } from './data-dir.js'
40
40
  import { isValidNativeThreadId } from './native-thread-id.js'
41
+ import { unwrapPeerMessage } from './session-stream-events.js'
41
42
 
42
43
  /**
43
44
  * How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
@@ -46,6 +47,14 @@ import { isValidNativeThreadId } from './native-thread-id.js'
46
47
  */
47
48
  export const HALT_MARKER_TTL_MS = 60 * 60_000
48
49
 
50
+ /**
51
+ * 6.53.3 /qa: how long a re-arm waits when the hand-off was NOT verified (`maybe`: the frame
52
+ * may or may not have reached the session). A verified hand-off keeps the marker's hour; an
53
+ * unverified one that has not surfaced as a prompt within two minutes most likely never
54
+ * arrived, and holding it longer only widens the window for an identical desk prompt.
55
+ */
56
+ export const HALT_REARM_UNVERIFIED_MS = 2 * 60_000
57
+
49
58
  export interface HaltMarker {
50
59
  at: number
51
60
  clientCancelId: string
@@ -121,15 +130,30 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
121
130
  // plain write would then be deleted by the very turn it was written to stop.
122
131
  //
123
132
  // So the marker is written, and when the delivered turn has not visibly started yet, it is
124
- // written AGAIN, once, on the first UserPromptSubmit for that session whose prompt carries
125
- // the delivered text. Matching the text keeps the person's own next desk prompt from ever
126
- // being the one that re-arms it; the window is the marker's own hour; SessionEnd drops it.
127
- // In memory on purpose: a restart forgets it, like every other hold here.
133
+ // written AGAIN, once, on the first UserPromptSubmit for that session AFTER the send, if that
134
+ // prompt IS the delivered one. The window is the marker's own hour (two minutes for an
135
+ // unverified hand-off); SessionEnd drops it. In memory on purpose: a restart forgets it, like
136
+ // every other hold here.
137
+ //
138
+ // 6.53.3 /qa (Skeptic W1): the first cut matched `prompt.includes(<first 120 characters>)`.
139
+ // A short COS prompt ("yes") is inside most desk prompts, so the person's own next prompt
140
+ // re-armed the marker: THEIR run was denied "Cancelled from COS", and the delivered turn's
141
+ // own prompt, finding nothing pending, then ran the cancelled turn. Now:
142
+ // - EXACT: the delivered words and the prompt's words are equal once whitespace runs are
143
+ // collapsed and Claude Code's peer wrapper ("Another Claude session sent a message: ...",
144
+ // which a live Continue arrives in) is taken off. The frame carries no id of its own and
145
+ // its text is not changed to add one.
146
+ // - FIRST PROMPT DECIDES: the first prompt after the send spends the re-arm whether or not
147
+ // it matches. A non-match is logged (never the prompt). This gives up the cancel when
148
+ // the person's own prompt reaches the session first, rather than risk halting their run:
149
+ // a halted desk run is the worse error, and the lens already said "at its next step".
150
+ // - LIMIT, stated: a desk prompt with exactly the delivered words, typed after the send and
151
+ // taken first, is indistinguishable from the delivered turn and re-arms.
128
152
 
129
153
  interface PendingRearm {
130
154
  marker: HaltMarker
131
- /** Text the delivered prompt carries (the peer-inbox acceptance marker). */
132
- promptMarker: string
155
+ /** The delivered prompt's words, whitespace-normalised (`normalizePromptWords`). */
156
+ words: string
133
157
  /** Prompts submitted before this are someone else's. */
134
158
  after: number
135
159
  until: number
@@ -147,18 +171,22 @@ const MAX_PENDING_REARMS = 64
147
171
  export function haltDeliveredTurn(
148
172
  sessionId: string,
149
173
  marker: HaltMarker,
150
- turn: { promptMarker: string; after: number; rearm: boolean; now?: number },
174
+ turn: { prompt: string; after: number; rearm: boolean; now?: number; windowMs?: number },
151
175
  dir = haltDir(),
152
176
  ): boolean {
153
177
  if (!writeHaltMarker(sessionId, marker, dir)) return false
154
178
  const id = sessionId.toLowerCase()
155
- if (!turn.rearm || typeof turn.promptMarker !== 'string' || turn.promptMarker.trim().length === 0) {
179
+ const words = normalizePromptWords(turn.prompt)
180
+ if (!turn.rearm || words.length === 0) {
156
181
  pendingRearms.delete(id)
157
182
  return true
158
183
  }
159
184
  const now = turn.now ?? Date.now()
185
+ const windowMs = typeof turn.windowMs === 'number' && Number.isFinite(turn.windowMs) && turn.windowMs > 0
186
+ ? Math.min(turn.windowMs, HALT_MARKER_TTL_MS)
187
+ : HALT_MARKER_TTL_MS
160
188
  pendingRearms.delete(id)
161
- pendingRearms.set(id, { marker, promptMarker: turn.promptMarker, after: turn.after, until: now + HALT_MARKER_TTL_MS })
189
+ pendingRearms.set(id, { marker, words, after: turn.after, until: now + windowMs })
162
190
  while (pendingRearms.size > MAX_PENDING_REARMS) {
163
191
  const oldest = pendingRearms.keys().next()
164
192
  if (oldest.done) break
@@ -167,10 +195,26 @@ export function haltDeliveredTurn(
167
195
  return true
168
196
  }
169
197
 
198
+ /** Whitespace runs collapsed to one space, ends trimmed. Non-strings are empty. */
199
+ export function normalizePromptWords(text: unknown): string {
200
+ return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
201
+ }
202
+
203
+ /**
204
+ * Is `prompt` (a UserPromptSubmit's `prompt`) the delivered prompt? Equal words, compared
205
+ * whole, after taking off Claude Code's peer wrapper when the prompt arrived in one.
206
+ */
207
+ export function isDeliveredPrompt(prompt: unknown, delivered: string): boolean {
208
+ const want = normalizePromptWords(delivered)
209
+ if (want.length === 0 || typeof prompt !== 'string') return false
210
+ return normalizePromptWords(unwrapPeerMessage(prompt) ?? prompt) === want
211
+ }
212
+
170
213
  /**
171
214
  * A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
172
- * the session's marker. When it was the delivered turn's own prompt, write the marker back.
173
- * One shot. True when the marker was re-armed.
215
+ * the session's marker. The FIRST such prompt after the send spends the pending re-arm: when
216
+ * it is the delivered turn's own prompt the marker is written back, otherwise the re-arm is
217
+ * dropped and logged. True when the marker was re-armed.
174
218
  */
175
219
  export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: unknown, dir = haltDir(), now = Date.now()): boolean {
176
220
  const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
@@ -181,8 +225,11 @@ export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: u
181
225
  return false
182
226
  }
183
227
  if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
184
- if (typeof prompt !== 'string' || !prompt.includes(pending.promptMarker)) return false
185
228
  pendingRearms.delete(id)
229
+ if (!isDeliveredPrompt(prompt, pending.words)) {
230
+ console.warn(`[session-halt] re-arm dropped: the first prompt after the hand-off was not the delivered turn session=${id.slice(0, 8)} clientCancelId=${pending.marker.clientCancelId}`)
231
+ return false
232
+ }
186
233
  return writeHaltMarker(id, pending.marker, dir)
187
234
  }
188
235
 
@@ -17,7 +17,7 @@ import { SessionHookLedger } from './session-hook-ledger.js'
17
17
  import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
18
18
  import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
19
19
  import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
20
- import { clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
20
+ import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
21
21
  import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
22
22
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
23
23
 
@@ -235,10 +235,17 @@ export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = cla
235
235
  * hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
236
236
  * re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
237
237
  */
238
- export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { promptMarker: string; sentAt: number }, now = Date.now()): boolean {
238
+ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean }, now = Date.now()): boolean {
239
239
  const signal = signalFor(sessionId)
240
240
  const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
241
- return haltDeliveredTurn(sessionId, marker, { promptMarker: turn.promptMarker, after: turn.sentAt, rearm: !started, now })
241
+ return haltDeliveredTurn(sessionId, marker, {
242
+ prompt: turn.prompt,
243
+ after: turn.sentAt,
244
+ rearm: !started,
245
+ now,
246
+ // 6.53.3 /qa: an unverified hand-off (`maybe`) holds its re-arm two minutes, not an hour.
247
+ windowMs: turn.unverified === true ? HALT_REARM_UNVERIFIED_MS : HALT_MARKER_TTL_MS,
248
+ })
242
249
  }
243
250
 
244
251
  let ledger: SessionHookLedger | null = null
@@ -96,8 +96,7 @@
96
96
  // and answer 400. They never treat an unparsed body as an empty one.
97
97
 
98
98
  import type { FenceRecord } from '../lib/thread-fence-store.js'
99
- import { execFileSync } from 'node:child_process'
100
- import { fenceLiveness, makePidStartProbe, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
99
+ import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
101
100
  import { Router, type Request, type Response } from 'express'
102
101
  import { createHash, randomUUID } from 'node:crypto'
103
102
  import {
@@ -125,7 +124,7 @@ import {
125
124
  import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
126
125
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
127
126
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
128
- import { PEER_VERIFY_TIMEOUT_MS, peerAcceptanceMarker } from '../lib/session-peer-inbox.js'
127
+ import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
129
128
  import {
130
129
  CANCEL_QUEUE_HOLD_MS,
131
130
  CLIENT_CANCEL_ID_RE,
@@ -460,7 +459,7 @@ export interface CancelDeps {
460
459
  * have reached) the open session. Writes the halt marker, and re-arms it once when that
461
460
  * turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
462
461
  */
463
- haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { promptMarker: string; sentAt: number }) => boolean
462
+ haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
464
463
  /** Append to `data/session-cancel.jsonl`. */
465
464
  ledger?: (row: SessionCancelLedgerRow) => void
466
465
  }
@@ -1560,17 +1559,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1560
1559
  const guard = deps.guard ?? new TargetGuard(deps.fencePersistence ?? null)
1561
1560
  // `ps -o lstart=` is the only start-time keyword macOS ps offers. Injectable so a
1562
1561
  // test drives recycled and unreadable pids without spawning real processes.
1563
- const livenessDeps: FenceLivenessDeps = deps.liveness ?? {
1564
- pidStartMs: makePidStartProbe((pid) => {
1565
- try {
1566
- return execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
1567
- encoding: 'utf8', timeout: 2_000,
1568
- })
1569
- } catch {
1570
- return null // not running, or ps refused the pid
1571
- }
1572
- }),
1573
- }
1562
+ const livenessDeps: FenceLivenessDeps = deps.liveness ?? { pidStartMs: defaultPidStartProbe() }
1574
1563
  // 6.53.3 (review A4): at boot, release the fences a cancel left whose children are all
1575
1564
  // gone (6.53.1's false "unreaped" among them), then keep re-checking on every fence read.
1576
1565
  guard.adoptCancelLiveness(livenessDeps)
@@ -1691,8 +1680,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1691
1680
  }) ?? 'unsupported'
1692
1681
  if (target === 'desk_run') {
1693
1682
  const armed = cancelProbe(() => cancelDeps.haltHandedOffTurn?.(threadId, { at: latch.at, clientCancelId: latch.clientCancelId }, {
1694
- promptMarker: peerAcceptanceMarker(turn.prompt),
1683
+ // 6.53.3 /qa: the WHOLE delivered prompt (the re-arm matches it exactly), and whether
1684
+ // the hand-off was only possibly delivered (`maybe`: a two-minute re-arm, not an hour).
1685
+ prompt: turn.prompt,
1695
1686
  sentAt: turn.sentAt,
1687
+ unverified: reached === 'maybe',
1696
1688
  }) === true, false)
1697
1689
  if (armed) {
1698
1690
  const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
@@ -22,6 +22,7 @@ import {
22
22
  } from '../lib/whisper-local.js'
23
23
  import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
24
24
  import { getMeetingSummaryBudgetState } from '../lib/meeting-summary-budget.js'
25
+ import { lensGistPublicHealth } from '../lib/lens-gist.js'
25
26
  import { meetingSummaryLLMEnabled } from '../lib/meeting-summary.js'
26
27
  import { getKeyStatus } from '../lib/openai-key.js'
27
28
  import {
@@ -410,6 +411,8 @@ healthRouter.get('/health', async (_req, res) => {
410
411
  whisper_health,
411
412
  openai_whisper_budget,
412
413
  meeting_summary,
414
+ // 6.54.0: the lens gist engine (lib/lens-gist.ts); detail on /api/lens-gist/config.
415
+ lens_gist: lensGistPublicHealth(),
413
416
  tts_local,
414
417
  codex_models,
415
418
  cursor_models,
@@ -0,0 +1,58 @@
1
+ import { Router } from 'express'
2
+ import {
3
+ LensGistInputError,
4
+ getLensGist,
5
+ lensGistHealth,
6
+ listLensGistEngines,
7
+ normalizeLensGistInput,
8
+ saveLensGistConfig,
9
+ } from '../lib/lens-gist.js'
10
+ import { maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
11
+ import { errMsg } from '../lib/utils.js'
12
+
13
+ // Server 6.54.0. The phone asks for the three-line card behind the G2 prompt box
14
+ // (lib/lens-gist.ts). Every answer the server gives is a 200 with a `status`, so the
15
+ // phone never reads "the server answered no" as "the server is unreachable" (gotchas:
16
+ // an HTTP status means the server ANSWERED). Only a malformed request is a 400.
17
+ export const lensGistRouter = Router()
18
+
19
+ lensGistRouter.post('/lens-gist', async (req, res) => {
20
+ let input
21
+ try {
22
+ input = normalizeLensGistInput(req.body)
23
+ } catch (err) {
24
+ res.status(err instanceof LensGistInputError ? err.status : 400).json({ error: errMsg(err) })
25
+ return
26
+ }
27
+ // Outside the request lease (index.ts lifecycleOwned): a drain for an update or restart
28
+ // closes admissions, and a gist is the first thing to give way.
29
+ if (!maintenanceAdmissionsOpen()) {
30
+ res.json({ status: 'unavailable', reason: 'maintenance' })
31
+ return
32
+ }
33
+ // No abort on disconnect: the phone asks ahead of the hold and may not wait, and the
34
+ // answer is cached for when it asks again.
35
+ try {
36
+ res.json(await getLensGist(input))
37
+ } catch (err) {
38
+ res.status(500).json({ error: errMsg(err) })
39
+ }
40
+ })
41
+
42
+ lensGistRouter.get('/lens-gist/config', async (_req, res) => {
43
+ try {
44
+ res.json({ ...lensGistHealth(), engines: await listLensGistEngines() })
45
+ } catch (err) {
46
+ res.status(500).json({ error: errMsg(err) })
47
+ }
48
+ })
49
+
50
+ lensGistRouter.put('/lens-gist/config', (req, res) => {
51
+ try {
52
+ const saved = saveLensGistConfig(req.body ?? {})
53
+ const overridden = !!process.env.COS_LENS_GIST_ENGINE?.trim()
54
+ res.json({ saved: true, effective: saved, ...(overridden ? { overriddenByEnv: 'COS_LENS_GIST_ENGINE' } : {}) })
55
+ } catch (err) {
56
+ res.status(err instanceof LensGistInputError ? err.status : 500).json({ error: errMsg(err) })
57
+ }
58
+ })