@gotcos/glasses-server 6.50.4 → 6.52.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.
Files changed (41) hide show
  1. package/.env.example +16 -0
  2. package/CHANGELOG.md +78 -0
  3. package/README.md +56 -3
  4. package/bin/hooks/cos-session-hook +24 -1
  5. package/managed-runtime-contract.json +1 -0
  6. package/package.json +1 -1
  7. package/server/index.ts +83 -5
  8. package/server/lib/activity-preview.ts +45 -2
  9. package/server/lib/agent-session-search.ts +3 -1
  10. package/server/lib/agent-session-store.ts +35 -5
  11. package/server/lib/agent-session-turns.ts +4 -1
  12. package/server/lib/claude-bridge.ts +9 -0
  13. package/server/lib/client-liveness.ts +50 -0
  14. package/server/lib/codex-bridge.ts +6 -0
  15. package/server/lib/codex-live-queue-deps.ts +135 -0
  16. package/server/lib/codex-live-queue.ts +232 -0
  17. package/server/lib/codex-turn-clock.ts +36 -0
  18. package/server/lib/cursor-bridge.ts +6 -0
  19. package/server/lib/cursor-stop-followup.ts +134 -0
  20. package/server/lib/job-trail.ts +758 -0
  21. package/server/lib/occupancy-probes.ts +52 -0
  22. package/server/lib/ollama-bridge.ts +17 -0
  23. package/server/lib/permission-broker.ts +1156 -0
  24. package/server/lib/query-job-coordinator.ts +17 -1
  25. package/server/lib/query-job-feature.ts +10 -0
  26. package/server/lib/query-job-runtime.ts +9 -1
  27. package/server/lib/query-job-store.ts +214 -5
  28. package/server/lib/query-job-types.ts +36 -0
  29. package/server/lib/session-signal-store.ts +42 -10
  30. package/server/lib/session-state-derive.ts +14 -4
  31. package/server/lib/session-stream-events.ts +600 -15
  32. package/server/lib/session-stream-producer.ts +25 -6
  33. package/server/lib/session-transcript-watcher.ts +120 -3
  34. package/server/lib/thread-occupancy.ts +30 -7
  35. package/server/routes/agent-session-bindings.ts +98 -6
  36. package/server/routes/agent-sessions.ts +24 -2
  37. package/server/routes/cursor-stop-followup.ts +74 -0
  38. package/server/routes/health.ts +34 -1
  39. package/server/routes/permission-broker.ts +176 -0
  40. package/server/routes/query-jobs.ts +84 -13
  41. package/server/routes/thread-turn-queue.ts +15 -5
@@ -49,7 +49,8 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
49
49
  // The flag lives beside the endpoints it describes, so it cannot drift from
50
50
  // whether they are actually registered.
51
51
  import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
52
- import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
52
+ import { durableQueryJobsCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
53
+ import { jobTrailStats } from '../lib/job-trail.js'
53
54
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
54
55
  import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
55
56
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
@@ -80,7 +81,10 @@ import {
80
81
  threadAttachHealthFields,
81
82
  } from '../lib/thread-attach-capability.js'
82
83
  import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
84
+ import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
83
85
  import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
86
+ import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
87
+ import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
84
88
 
85
89
  /**
86
90
  * 6.49.0: which last hop a Continue takes, and how often each fell back.
@@ -101,6 +105,18 @@ export function continueLiveHealthFields(): Record<string, unknown> {
101
105
  lastReason: stats.lastReason,
102
106
  lastAt: stats.lastAt,
103
107
  },
108
+ // 6.51.0: Codex's last hop, counted apart so the Claude row above keeps its meaning.
109
+ codexLiveQueue: (() => {
110
+ const codex = codexLiveStats()
111
+ return {
112
+ enabled: codexLiveQueueEnabled(),
113
+ attempts: codex.attempts,
114
+ delivered: codex.delivered,
115
+ fallbacks: codex.fallbacks,
116
+ lastReason: codex.lastReason,
117
+ lastAt: codex.lastAt,
118
+ }
119
+ })(),
104
120
  }
105
121
  }
106
122
 
@@ -374,6 +390,9 @@ healthRouter.get('/health', async (_req, res) => {
374
390
  server_version: managedServerVersion(),
375
391
  ...threadAttachHealthFields(threadAttach),
376
392
  ...sessionHooksHealthFields(),
393
+ // 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
394
+ // how many are held (that is on the authenticated questions route).
395
+ ...permissionBrokerHealthFields(),
377
396
  ...continueLiveHealthFields(),
378
397
  server_instance_id: getServerInstanceId(),
379
398
  boot_id: serverMetrics.bootId,
@@ -465,6 +484,12 @@ healthRouter.get('/health', async (_req, res) => {
465
484
  protocolVersion: durableJobs.protocolVersion,
466
485
  state: durableJobs.store.state,
467
486
  },
487
+ // 6.52.0: the Messages trail's readers, process-wide since boot. Counts and a
488
+ // timestamp only: stdout lines read, entries emitted, failures swallowed.
489
+ messages_trail: {
490
+ enabled: durableJobs.enabled && messagesTrailEnabled(),
491
+ ...jobTrailStats(),
492
+ },
468
493
  })
469
494
  })
470
495
 
@@ -554,7 +579,15 @@ healthRouter.get('/models', async (req, res) => {
554
579
  durableQueryJobs: {
555
580
  enabled: durableJobs.enabled,
556
581
  protocolVersion: durableJobs.protocolVersion,
582
+ // 6.52.0: jobs journal `trail` events and carry `snapshot.trail`. The app reads
583
+ // THIS surface (not /api/health) and falls back to the activity log without it.
584
+ // False whenever jobs themselves are off or COS_MESSAGES_TRAIL=0.
585
+ trail: durableJobs.enabled && messagesTrailEnabled(),
557
586
  },
587
+ // 6.52.0: the session questions API and its client contract. A client polls
588
+ // `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
589
+ // quiet for `liveWindowMs` hands every held request back to the Mac.
590
+ sessionQuestions: sessionQuestionsCapability(),
558
591
  transcription: {
559
592
  ...transcription,
560
593
  live: transcriptionLive,
@@ -0,0 +1,176 @@
1
+ // The permission broker's two doors (6.52.0). See `lib/permission-broker.ts` for the why.
2
+ //
3
+ // POST /api/permission-requests/ask -- the COS session hook, on a PermissionRequest at a
4
+ // desk idle for 90 s. THIS IS THE URL THE 6.51.0 SCRIPT ALREADY CALLS, so no Mac needs
5
+ // `--hooks install` again (QA round 1: a new URL meant a new script, and every Mac that did
6
+ // not reinstall would have shown Control's "hooks outdated" banner and kept the old door).
7
+ // The hook holds the per-install HOOK token (`~/.cos-glasses/hook-token`, 0600), never the
8
+ // pairing token the /api gate checks, so this one route is registered BEFORE that gate and
9
+ // before the global body parser, as a deliberate exemption for exactly this method and
10
+ // path: it checks the hook token itself, in constant time, BEFORE it reads a byte of the
11
+ // body, and only then parses up to 2 MB. Any other method on the path falls through to the
12
+ // /api gate like every other /api route. It answers only `{}` or a
13
+ // `{"hookSpecificOutput": ...}` the hook prints verbatim, and is mounted unconditionally
14
+ // (not behind COS_THREAD_ATTACH_ENABLED): the switch is `COS_PERMISSION_BROKER`, read per
15
+ // request. It holds no maintenance lease; the broker refuses to park during a drain itself.
16
+ //
17
+ // Mixed versions fail safe. Against a 6.51 server the same script gets 401 from the /api
18
+ // gate, prints nothing, and the native dialog shows, as it always did there.
19
+ //
20
+ // GET /api/session-questions and POST /api/session-questions/:id/answer -- the lens and the
21
+ // phone, behind the API token like every other /api route. Question and command text only
22
+ // ever travels here, never on the display bus. The GET is ALSO the broker's liveness
23
+ // signal (lib/client-liveness.ts), and only when it names the client: `?client=glasses` or
24
+ // `?client=phone`. Anything else (missing, `control`, a typo) is answered but never counts,
25
+ // so a surface that only shows a count can poll without making the Mac hold questions it
26
+ // cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
27
+ // without it is refused and never counted, whatever sits in front of this router.
28
+
29
+ import express, { Router, type NextFunction, type Request, type Response } from 'express'
30
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
31
+ import {
32
+ ANSWERING_CLIENTS,
33
+ CLIENT_LIVE_WINDOW_MS,
34
+ QUESTIONS_POLL_INTERVAL_MS,
35
+ SESSION_QUESTIONS_PROTOCOL_VERSION,
36
+ type BrokerAdmission,
37
+ type HookReplyChannel,
38
+ type PermissionBroker,
39
+ } from '../lib/permission-broker.js'
40
+ import { noteQuestionsPoll } from '../lib/client-liveness.js'
41
+
42
+ export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
43
+ /** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
44
+ export const PERMISSION_BROKER_BODY_LIMIT = '2mb'
45
+
46
+ export interface PermissionBrokerHookRouterDeps {
47
+ /** The per-install hook token, or null when none has been minted (then nothing is answered). */
48
+ hookToken: () => string | null
49
+ broker: PermissionBroker
50
+ }
51
+
52
+ /**
53
+ * The hook-token check, as middleware that runs BEFORE any body parser: a request without
54
+ * the token is answered 401 without a byte of its body being read or parsed.
55
+ */
56
+ export function requireHookToken(hookToken: () => string | null) {
57
+ return (req: Request, res: Response, next: NextFunction): void => {
58
+ res.set('Cache-Control', 'private, no-store')
59
+ let expected: string | null = null
60
+ try { expected = hookToken() } catch { expected = null }
61
+ if (!expected || !timingSafeTokenEqual(req.headers['x-cos-hook-token'], expected)) {
62
+ res.status(401).json({})
63
+ return
64
+ }
65
+ next()
66
+ }
67
+ }
68
+
69
+ /** The held response, as the broker sees it. */
70
+ export function replyChannelFor(res: Response): HookReplyChannel {
71
+ const writable = (): boolean => {
72
+ if (res.headersSent || res.writableEnded || res.destroyed) return false
73
+ const socket = res.socket
74
+ return !!socket && !socket.destroyed && socket.writable !== false
75
+ }
76
+ return {
77
+ writable,
78
+ reply(body) {
79
+ if (!writable()) return false
80
+ try {
81
+ res.status(200).json(body)
82
+ return true
83
+ } catch {
84
+ return false
85
+ }
86
+ },
87
+ }
88
+ }
89
+
90
+ export function createPermissionBrokerHookRouter(deps: PermissionBrokerHookRouterDeps): Router {
91
+ const router = Router()
92
+ router.post(
93
+ PERMISSION_BROKER_HOOK_PATH,
94
+ requireHookToken(deps.hookToken),
95
+ express.json({ limit: PERMISSION_BROKER_BODY_LIMIT }),
96
+ async (req: Request, res: Response) => {
97
+ let admission: BrokerAdmission
98
+ try {
99
+ admission = await deps.broker.admit(req.body)
100
+ } catch (error) {
101
+ console.error(`[permission-broker] admit failed: ${error instanceof Error ? error.message : error}`)
102
+ res.json({})
103
+ return
104
+ }
105
+ if (!admission.ok) {
106
+ res.json({})
107
+ return
108
+ }
109
+ const channel = replyChannelFor(res)
110
+ // The hook gave up during the desk read: nothing to hold.
111
+ if (!channel.writable()) {
112
+ deps.broker.noteFastPath('hook_gone_early')
113
+ return
114
+ }
115
+ const id = deps.broker.park(admission.request, channel)
116
+ // `res` close, not `req` close: since Node 16 the request emits `close` once its body
117
+ // is read, which is not the hook leaving. A close after the reply is a no-op.
118
+ res.on('close', () => { deps.broker.hookClosed(id) })
119
+ },
120
+ )
121
+ // A body the parser refused (too large, not JSON): `{}` and nothing else, never a stack.
122
+ router.use(PERMISSION_BROKER_HOOK_PATH, (error: unknown, _req: Request, res: Response, next: NextFunction) => {
123
+ if (res.headersSent) { next(error); return }
124
+ deps.broker.noteFastPath('malformed')
125
+ const status = Number((error as { status?: unknown })?.status)
126
+ res.status(Number.isInteger(status) && status >= 400 && status < 500 ? status : 400).json({})
127
+ })
128
+ return router
129
+ }
130
+
131
+ export interface SessionQuestionsRouterDeps {
132
+ broker: PermissionBroker
133
+ /** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
134
+ apiToken: () => string
135
+ /** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
136
+ notePoll?: (at: number) => void
137
+ now?: () => number
138
+ }
139
+
140
+ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
141
+ const router = Router()
142
+ const notePoll = deps.notePoll ?? noteQuestionsPoll
143
+ const now = deps.now ?? Date.now
144
+ router.get('/session-questions', (req: Request, res: Response) => {
145
+ res.set('Cache-Control', 'private, no-store')
146
+ let expected = ''
147
+ try { expected = deps.apiToken() } catch { expected = '' }
148
+ // A hook token, or nothing: refused, and never a live client.
149
+ if (!timingSafeTokenEqual(req.headers['x-cos-token'], expected)) {
150
+ res.status(401).json({ error: 'unauthorized' })
151
+ return
152
+ }
153
+ // Only a client that says it can answer (and shows the cards) counts as live.
154
+ const client = typeof req.query.client === 'string' ? req.query.client : ''
155
+ if (ANSWERING_CLIENTS.has(client)) notePoll(now())
156
+ const health = deps.broker.health()
157
+ // Listed first: a deadline the timer has not caught yet is settled by the listing.
158
+ const items = deps.broker.list()
159
+ res.json({
160
+ enabled: health.enabled,
161
+ mode: health.mode,
162
+ protocolVersion: SESSION_QUESTIONS_PROTOCOL_VERSION,
163
+ pollIntervalMs: QUESTIONS_POLL_INTERVAL_MS,
164
+ liveWindowMs: CLIENT_LIVE_WINDOW_MS,
165
+ pending: deps.broker.pendingCount(),
166
+ stats: deps.broker.stats(),
167
+ items,
168
+ })
169
+ })
170
+ router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
171
+ res.set('Cache-Control', 'private, no-store')
172
+ const result = deps.broker.answer(String(req.params.id ?? ''), req.body)
173
+ res.status(result.status).json(result.body)
174
+ })
175
+ return router
176
+ }
@@ -1,4 +1,4 @@
1
- import { Router, type Response } from 'express'
1
+ import { Router, type Request, type Response } from 'express'
2
2
  import { durableQueryJobsEnabled } from '../lib/query-job-feature.js'
3
3
  import {
4
4
  QueryJobCoordinator,
@@ -16,6 +16,7 @@ import {
16
16
  QueryJobPersistenceError,
17
17
  QueryJobProviderOrphanFenceError,
18
18
  QueryJobStoreError,
19
+ type QueryJobTrailFrame,
19
20
  } from '../lib/query-job-store.js'
20
21
  import {
21
22
  isTerminalQueryJobStatus,
@@ -89,7 +90,25 @@ function writeSse(res: Response, type: string, event: Record<string, unknown>):
89
90
  res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`)
90
91
  }
91
92
 
92
- function snapshotEvent(job: QueryJobSnapshot, reason: string): Record<string, unknown> {
93
+ /**
94
+ * THE TRAIL IS OPT-IN (6.52.0 QA round 1). A client that does not send `?trail=1` gets
95
+ * exactly the 6.51.0 bytes: no `trail` frames on the stream, and no `trail` field on any
96
+ * snapshot, in any response. A client that does gets `trail` frames (their own dense
97
+ * `trailSeq`, no `id:` line, so Last-Event-ID stays the job eventSeq) and, on subscribe or
98
+ * reconnect, every entry after its `trailAfter` from `snapshot.trail`, or one
99
+ * `trail_snapshot` frame to rebuild from when entries it missed are no longer held.
100
+ */
101
+ function wantsTrail(req: Request): boolean {
102
+ return req.query.trail === '1'
103
+ }
104
+
105
+ function forClient(job: QueryJobSnapshot, trail: boolean): QueryJobSnapshot {
106
+ if (trail || !('trail' in job)) return job
107
+ const { trail: _trail, ...rest } = job
108
+ return rest as QueryJobSnapshot
109
+ }
110
+
111
+ function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean): Record<string, unknown> {
93
112
  return {
94
113
  type: 'snapshot',
95
114
  eventSeq: job.eventSeq,
@@ -98,7 +117,7 @@ function snapshotEvent(job: QueryJobSnapshot, reason: string): Record<string, un
98
117
  generation: job.generation,
99
118
  status: job.status,
100
119
  at: job.updatedAt,
101
- data: { reason, job },
120
+ data: { reason, job: forClient(job, trail) },
102
121
  }
103
122
  }
104
123
 
@@ -122,7 +141,7 @@ export function createQueryJobsRouter(
122
141
  try {
123
142
  const prepared = options.prepareAdmission ? await options.prepareAdmission(req.body) : req.body
124
143
  const admission = await coordinator.submit(prepared)
125
- return res.status(202).json({ job: admission.job })
144
+ return res.status(202).json({ job: forClient(admission.job, wantsTrail(req)) })
126
145
  } catch (error) {
127
146
  const wire = wireError(error)
128
147
  return res.status(wire.status).json(wire.body)
@@ -138,7 +157,7 @@ export function createQueryJobsRouter(
138
157
  const generation = requiredGeneration(req.query.generation)
139
158
  const job = await coordinator.getByClientGeneration(clientJobId, generation)
140
159
  if (!job) throw new QueryJobNotFoundError(`${clientJobId}:${generation}`)
141
- return res.json({ job })
160
+ return res.json({ job: forClient(job, wantsTrail(req)) })
142
161
  } catch (error) {
143
162
  const wire = wireError(error)
144
163
  return res.status(wire.status).json(wire.body)
@@ -150,7 +169,7 @@ export function createQueryJobsRouter(
150
169
  const jobId = validJobId(req.params.jobId)
151
170
  const generation = requiredGeneration(req.query.generation)
152
171
  const job = await coordinator.getSnapshot(jobId, generation)
153
- return res.json({ job })
172
+ return res.json({ job: forClient(job, wantsTrail(req)) })
154
173
  } catch (error) {
155
174
  const wire = wireError(error)
156
175
  return res.status(wire.status).json(wire.body)
@@ -163,7 +182,15 @@ export function createQueryJobsRouter(
163
182
  let closed = false
164
183
  let streamReady = false
165
184
  let sentSeq = -1
185
+ let sentTrailSeq = 0
166
186
  const pendingLive: QueryJobEvent[] = []
187
+ const pendingTrail: QueryJobTrailFrame[] = []
188
+ const trail = wantsTrail(req)
189
+ const writeTrail = (frame: QueryJobTrailFrame) => {
190
+ if (closed || frame.trailSeq <= sentTrailSeq) return
191
+ sentTrailSeq = frame.trailSeq
192
+ writeSse(res, 'trail', frame as unknown as Record<string, unknown>)
193
+ }
167
194
  const cleanup = () => {
168
195
  if (closed) return
169
196
  closed = true
@@ -184,8 +211,22 @@ export function createQueryJobsRouter(
184
211
  const generation = requiredGeneration(req.query.generation)
185
212
  const after = cursor(req.query.after)
186
213
  sentSeq = after
214
+ const trailAfter = trail ? cursor(req.query.trailAfter) : 0
215
+ sentTrailSeq = trailAfter
216
+ const trailSubscription = trail
217
+ ? {
218
+ after: trailAfter,
219
+ listener: (frame: QueryJobTrailFrame) => {
220
+ if (!streamReady) {
221
+ pendingTrail.push(frame)
222
+ return
223
+ }
224
+ writeTrail(frame)
225
+ },
226
+ }
227
+ : undefined
187
228
 
188
- const subscription = await coordinator.subscribe(jobId, generation, after, (event: QueryJobEvent) => {
229
+ const onEvent = (event: QueryJobEvent) => {
189
230
  // subscribe() registers its live listener before returning the replay
190
231
  // snapshot so no append can fall into a gap. A very fast provider may
191
232
  // therefore publish while this route is still installing SSE headers.
@@ -199,7 +240,11 @@ export function createQueryJobsRouter(
199
240
  sentSeq = event.eventSeq
200
241
  writeSse(res, event.type, event as unknown as Record<string, unknown>)
201
242
  if (isTerminalQueryJobStatus(event.status)) finish()
202
- })
243
+ }
244
+ // Without `?trail=1` the call is exactly the 6.51.0 one.
245
+ const subscription = trailSubscription
246
+ ? await coordinator.subscribe(jobId, generation, after, onEvent, trailSubscription)
247
+ : await coordinator.subscribe(jobId, generation, after, onEvent)
203
248
  unsubscribe = subscription.unsubscribe
204
249
  if (closed) {
205
250
  unsubscribe()
@@ -216,9 +261,33 @@ export function createQueryJobsRouter(
216
261
  res.flushHeaders()
217
262
  res.write(': keepalive\n\n')
218
263
 
219
- const { replay } = subscription
264
+ const { replay, trailReplay } = subscription
265
+ // The trail first: every entry precedes the terminal event that may end this stream.
266
+ if (trail && trailReplay) {
267
+ if (trailReplay.gap) {
268
+ writeSse(res, 'trail_snapshot', {
269
+ type: 'trail_snapshot',
270
+ reason: trailReplay.reason ?? 'trail_gap',
271
+ jobId: replay.snapshot.jobId,
272
+ clientJobId: replay.snapshot.clientJobId,
273
+ generation: replay.snapshot.generation,
274
+ oldestTrailSeq: trailReplay.oldestTrailSeq,
275
+ latestTrailSeq: trailReplay.latestTrailSeq,
276
+ trail: trailReplay.entries,
277
+ })
278
+ sentTrailSeq = trailReplay.latestTrailSeq
279
+ } else {
280
+ for (const entry of trailReplay.entries) {
281
+ const { trailSeq, at, ...data } = entry
282
+ writeTrail({
283
+ type: 'trail', trailSeq, at, data,
284
+ jobId: replay.snapshot.jobId, clientJobId: replay.snapshot.clientJobId, generation: replay.snapshot.generation,
285
+ } as QueryJobTrailFrame)
286
+ }
287
+ }
288
+ }
220
289
  if (replay.gap) {
221
- writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap'))
290
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap', trail))
222
291
  sentSeq = replay.snapshot.eventSeq
223
292
  } else {
224
293
  for (const event of replay.events) {
@@ -230,7 +299,7 @@ export function createQueryJobsRouter(
230
299
 
231
300
  const replayTerminal = replay.events.some(event => isTerminalQueryJobStatus(event.status))
232
301
  if (isTerminalQueryJobStatus(replay.snapshot.status) && !replayTerminal && !replay.gap) {
233
- writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot'))
302
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot', trail))
234
303
  sentSeq = replay.snapshot.eventSeq
235
304
  }
236
305
  if (isTerminalQueryJobStatus(replay.snapshot.status)) {
@@ -239,6 +308,8 @@ export function createQueryJobsRouter(
239
308
  }
240
309
 
241
310
  streamReady = true
311
+ for (const frame of pendingTrail) writeTrail(frame)
312
+ pendingTrail.length = 0
242
313
  for (const event of pendingLive) {
243
314
  if (closed || event.eventSeq <= sentSeq) continue
244
315
  sentSeq = event.eventSeq
@@ -271,7 +342,7 @@ export function createQueryJobsRouter(
271
342
  const jobId = validJobId(req.params.jobId)
272
343
  const generation = requiredGeneration(req.body?.generation)
273
344
  const { job } = await coordinator.cancel(jobId, generation)
274
- return res.json({ job })
345
+ return res.json({ job: forClient(job, wantsTrail(req)) })
275
346
  } catch (error) {
276
347
  const wire = wireError(error)
277
348
  return res.status(wire.status).json(wire.body)
@@ -283,7 +354,7 @@ export function createQueryJobsRouter(
283
354
  const jobId = validJobId(req.params.jobId)
284
355
  const generation = requiredGeneration(req.body?.generation)
285
356
  const job = await coordinator.acknowledge(jobId, generation)
286
- return res.json({ job })
357
+ return res.json({ job: forClient(job, wantsTrail(req)) })
287
358
  } catch (error) {
288
359
  const wire = wireError(error)
289
360
  return res.status(wire.status).json(wire.body)
@@ -119,7 +119,14 @@ export async function drainThread(
119
119
  // for every queued row repeats a whole-process descriptor scan. A refusal
120
120
  // can delay delivery until the next sweep, but can never authorize a write.
121
121
  // Positive verdicts and all checks after a delivery remain fresh.
122
- const gate: ReturnType<ThreadTurnQueueDeps['occupancy']> = refusedGate ?? deps.occupancy(provider, threadId)
122
+ //
123
+ // 6.51.0: a Cursor turn is NEVER delivered from here. It leaves through the
124
+ // composer's own Stop hook (`routes/cursor-stop-followup.ts`), which claims it the
125
+ // moment the turn ends; a second deliverer could hand the same sentence over twice.
126
+ // The pass still runs for it so the TTL retires an unclaimed turn honestly.
127
+ const gate: ReturnType<ThreadTurnQueueDeps['occupancy']> = provider === 'cursor'
128
+ ? { attachable: false, reason: 'cursor_stop_hook_delivery' }
129
+ : refusedGate ?? deps.occupancy(provider, threadId)
123
130
  if (!gate.attachable) refusedGate = gate
124
131
  const seen: DrainObservation = {
125
132
  attachable: gate.attachable,
@@ -228,15 +235,18 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
228
235
  return res.status(400).json({ error: 'invalid_request', reason: 'invalid_cos_session_id' })
229
236
  }
230
237
 
231
- if (provider === 'cursor') {
232
- return res.status(423).json({ error: 'unsupported_provider', queueable: false })
233
- }
234
-
235
238
  // THE GATE DECIDES WHETHER PARKING IS EVEN HONEST. A structural refusal can never
236
239
  // clear, and telling someone their turn is queued when it can never run is worse
237
240
  // than refusing it. An attachable thread is not queued either -- it is sent now,
238
241
  // through the ordinary route, which the client does on this 409.
239
242
  const gate = deps.occupancy(provider, threadId)
243
+
244
+ // 6.51.0: Cursor queues ONLY a composer its hooks show mid-turn, because the only way
245
+ // in is that turn's Stop hook. An idle composer fires no Stop, so a turn parked there
246
+ // could never run; that stays the 423 it always was, as does every Agent CLI chat.
247
+ if (provider === 'cursor' && gate.reason !== 'native_thread_working') {
248
+ return res.status(423).json({ error: 'unsupported_provider', queueable: false })
249
+ }
240
250
  if (gate.attachable) return res.status(409).json({ error: 'thread_free', hint: 'send_now' })
241
251
  if (!queueableRefusal(gate.reason)) {
242
252
  return res.status(423).json({ error: gate.reason ?? 'probe_failed', queueable: false })