@gotcos/glasses-server 6.50.3 → 6.51.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.
- package/CHANGELOG.md +18 -0
- package/bin/hooks/cos-session-hook +24 -1
- package/package.json +1 -1
- package/server/index.ts +34 -4
- package/server/lib/client-instance-claim.ts +90 -3
- package/server/lib/codex-live-queue-deps.ts +135 -0
- package/server/lib/codex-live-queue.ts +232 -0
- package/server/lib/codex-turn-clock.ts +36 -0
- package/server/lib/cursor-stop-followup.ts +134 -0
- package/server/lib/occupancy-probes.ts +52 -0
- package/server/lib/session-stream-events.ts +3 -1
- package/server/lib/thread-occupancy.ts +30 -7
- package/server/routes/agent-session-bindings.ts +98 -6
- package/server/routes/agent-sessions.ts +12 -1
- package/server/routes/client-instance.ts +57 -9
- package/server/routes/cursor-stop-followup.ts +67 -0
- package/server/routes/health.ts +14 -0
- package/server/routes/thread-turn-queue.ts +15 -5
|
@@ -344,6 +344,16 @@ export interface AgentSessionBindingsDeps {
|
|
|
344
344
|
*/
|
|
345
345
|
deliverLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number; clientTurnId?: string }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null }>
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* 6.51.0: put a Codex turn into the Codex app's OWN queue when the app holds the thread.
|
|
349
|
+
*
|
|
350
|
+
* `makeCodexLiveDeliverer` from `server/lib/codex-live-queue-deps.ts`. Separate from
|
|
351
|
+
* `deliverLiveTurn` on purpose, so the Claude branch is byte-for-byte 6.50. Asked only
|
|
352
|
+
* when the gate's verdict carries a foreign writer on a Codex thread; absent, that turn
|
|
353
|
+
* spawns exactly as before.
|
|
354
|
+
*/
|
|
355
|
+
deliverCodexLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; foreignHolder: boolean }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null }>
|
|
356
|
+
|
|
347
357
|
/**
|
|
348
358
|
* The self-recursion ledger. Defaults to the real process-wide one.
|
|
349
359
|
*
|
|
@@ -498,6 +508,11 @@ export type WriteRefusal =
|
|
|
498
508
|
// held as retryable and the retry re-reads before doing anything (see
|
|
499
509
|
// `session-peer-inbox.ts`); it is never a fence, because nothing here ran a child.
|
|
500
510
|
| 'live_unverified'
|
|
511
|
+
// 6.51.0: the Codex app holds this thread and `codex queue` did not take the turn.
|
|
512
|
+
// Nothing was queued. Never answered by spawning: a resume child against a held
|
|
513
|
+
// thread dies on "already has an active writer" after the prompt is written, which
|
|
514
|
+
// is the ambiguous fence this whole branch exists to avoid.
|
|
515
|
+
| 'codex_live_unavailable'
|
|
501
516
|
// ------------------------------------------------------------------- fork
|
|
502
517
|
//
|
|
503
518
|
// Fork gets its OWN members rather than reusing the ones above, and the reason
|
|
@@ -572,6 +587,8 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
|
|
|
572
587
|
'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
|
|
573
588
|
live_unverified:
|
|
574
589
|
'Sent, not yet confirmed. Send again: COS checks before sending twice.',
|
|
590
|
+
codex_live_unavailable:
|
|
591
|
+
'The Codex app has this thread open, and COS could not hand it the message. Nothing was sent. Send it from the Codex app, or fork it.',
|
|
575
592
|
turn_failed:
|
|
576
593
|
'COS could not run this turn. Nothing was sent. You can try again.',
|
|
577
594
|
|
|
@@ -1284,6 +1301,8 @@ export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
|
|
|
1284
1301
|
export const TURN_SENT_COPY = 'Sent to the original thread.'
|
|
1285
1302
|
/** 6.49.0: the turn went into the running session, where the desk will see it. */
|
|
1286
1303
|
export const TURN_SENT_LIVE_COPY = 'Sent to the open session. It will show where you left it.'
|
|
1304
|
+
/** 6.51.0: a Codex turn handed to the Codex app's own queue for the thread it has open. */
|
|
1305
|
+
export const TURN_SENT_CODEX_QUEUE_COPY = 'Queued in the Codex app. It runs when the current turn ends, or right away if the thread is idle.'
|
|
1287
1306
|
/**
|
|
1288
1307
|
* How long the route waits for the session's transcript to show acceptance.
|
|
1289
1308
|
*
|
|
@@ -1392,18 +1411,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1392
1411
|
* one place, so the write routes cannot come to a different conclusion than the
|
|
1393
1412
|
* probe the user was shown a second earlier.
|
|
1394
1413
|
*/
|
|
1395
|
-
const
|
|
1396
|
-
let verdict: Occupancy
|
|
1414
|
+
const detectOccupancy = (provider: string, threadId: string): Occupancy => {
|
|
1397
1415
|
try {
|
|
1398
|
-
|
|
1416
|
+
return detect(provider, threadId, deps.probes, deps.dirs)
|
|
1399
1417
|
} catch (error) {
|
|
1400
1418
|
// `threadOccupancy` already contains its own probe try/catch, so reaching
|
|
1401
1419
|
// here means the detector itself threw. Logged without the thread id.
|
|
1402
1420
|
console.error(`[agent-session-bindings] occupancy threw: ${error instanceof Error ? error.message : error}`)
|
|
1403
|
-
|
|
1421
|
+
return { attachable: false, owners: [], reason: 'probe_failed' }
|
|
1404
1422
|
}
|
|
1405
|
-
return projectAttachability(verdict)
|
|
1406
1423
|
}
|
|
1424
|
+
const runOccupancy = (provider: string, threadId: string): AttachabilityBody =>
|
|
1425
|
+
projectAttachability(detectOccupancy(provider, threadId))
|
|
1407
1426
|
|
|
1408
1427
|
const refuseAttach = (res: Response, reason: WriteRefusal): void => {
|
|
1409
1428
|
res.status(refusalStatus(reason)).json({
|
|
@@ -2153,9 +2172,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2153
2172
|
// session started in the gap is exactly the residual risk option B leaves
|
|
2154
2173
|
// open, and it is terminal here rather than a warning because COS has no
|
|
2155
2174
|
// cross-process lock that could fence a live desktop writer.
|
|
2156
|
-
const
|
|
2175
|
+
const detected = detectOccupancy(binding.provider, binding.nativeThreadId)
|
|
2176
|
+
const verdict = projectAttachability(detected)
|
|
2157
2177
|
stage('gate')
|
|
2158
2178
|
if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
|
|
2179
|
+
// 6.51.0: the SAME verdict says whether a live process that is not ours holds the
|
|
2180
|
+
// thread. Only the declared idle-holder exemption can carry one on an attachable
|
|
2181
|
+
// verdict (`projectAttachability` refuses anything else), so this is that fact.
|
|
2182
|
+
const foreignHolder = detected.idleHolder === true
|
|
2183
|
+
&& Array.isArray(detected.owners)
|
|
2184
|
+
&& detected.owners.some(owner => owner?.selfOwned !== true)
|
|
2159
2185
|
|
|
2160
2186
|
const head = await readHead(binding.provider, binding.nativeThreadId)
|
|
2161
2187
|
stage('head')
|
|
@@ -2238,6 +2264,72 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2238
2264
|
// suspect_expired: nothing reached a session. Spawn as always.
|
|
2239
2265
|
}
|
|
2240
2266
|
|
|
2267
|
+
// 6.51.0: CODEX, WHEN THE CODEX APP HOLDS THE THREAD. Its app-server keeps the
|
|
2268
|
+
// writer lock for as long as the thread is open, so the resume child below cannot
|
|
2269
|
+
// run (it exits on "already has an active writer" after the prompt is written, and
|
|
2270
|
+
// that fence is what August's two Codex Continues left behind). `codex queue`
|
|
2271
|
+
// hands the turn to that app-server's own queue instead: it runs at the end of the
|
|
2272
|
+
// current turn, or within seconds if the thread is idle (canary in
|
|
2273
|
+
// `codex-live-queue.ts`). A thread nobody holds never gets here.
|
|
2274
|
+
if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
|
|
2275
|
+
let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
|
|
2276
|
+
try {
|
|
2277
|
+
live = await deps.deliverCodexLiveTurn({
|
|
2278
|
+
provider: binding.provider,
|
|
2279
|
+
sessionId: binding.nativeThreadId,
|
|
2280
|
+
prompt,
|
|
2281
|
+
foreignHolder: true,
|
|
2282
|
+
})
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
console.error(`[agent-session-bindings] codex live delivery threw: ${error instanceof Error ? error.message : error}`)
|
|
2285
|
+
live = null
|
|
2286
|
+
}
|
|
2287
|
+
stage(`codex-live(${live?.reason ?? 'threw'})`)
|
|
2288
|
+
if (live?.ok) {
|
|
2289
|
+
console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
|
|
2290
|
+
respond(200, {
|
|
2291
|
+
turnId,
|
|
2292
|
+
outcome: 'completed',
|
|
2293
|
+
deliveryState: 'delivered',
|
|
2294
|
+
via: 'live',
|
|
2295
|
+
retryable: false,
|
|
2296
|
+
changed: false,
|
|
2297
|
+
revision: null,
|
|
2298
|
+
reason: null,
|
|
2299
|
+
reasonCopy: TURN_SENT_CODEX_QUEUE_COPY,
|
|
2300
|
+
})
|
|
2301
|
+
return
|
|
2302
|
+
}
|
|
2303
|
+
// The operator switched this off: the 6.50 path, unchanged, whatever it does.
|
|
2304
|
+
if (live?.reason !== 'disabled') {
|
|
2305
|
+
// A throw, a timeout, or a confirmation we could not read: a row MAY be in the
|
|
2306
|
+
// app's queue. Unlike the Claude hop, nothing can re-check that before a resend
|
|
2307
|
+
// (QA, 6.51.0), and a retryable answer would have the drainer insert the same
|
|
2308
|
+
// sentence at every turn end until the TTL. So it is what every other unproven
|
|
2309
|
+
// delivery is: ambiguous, ledgered (a replay answers the same), and fenced until
|
|
2310
|
+
// a person looks at the Codex app's queue and releases it.
|
|
2311
|
+
if (live === null || live.reason === 'unverified') {
|
|
2312
|
+
guard.fence(key, 'native_target_fenced', {
|
|
2313
|
+
provider: binding.provider,
|
|
2314
|
+
headBefore: head.digest,
|
|
2315
|
+
turnId,
|
|
2316
|
+
bindingId,
|
|
2317
|
+
now: Date.now(),
|
|
2318
|
+
adapterReason: 'codex_queue_unverified',
|
|
2319
|
+
fenceSite: 'ambiguous',
|
|
2320
|
+
spawns: [],
|
|
2321
|
+
})
|
|
2322
|
+
console.warn(`[agent-session-bindings] fence set site=codex_live provider=codex target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=codex_queue_unverified`)
|
|
2323
|
+
return reportAmbiguous()
|
|
2324
|
+
}
|
|
2325
|
+
// Nothing was queued, and the child cannot run against a held thread. Not
|
|
2326
|
+
// retryable: a missing binary or a CLI that refused does not heal on a 20 s
|
|
2327
|
+
// sweep, and a retryable reason would have the queue drainer re-run it for the
|
|
2328
|
+
// whole TTL (`isRetryableDelivery` trusts the route's own verdict).
|
|
2329
|
+
return refuseTurn('codex_live_unavailable', { retryable: false })
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2241
2333
|
// THE QUEUE POINT. Every gate is now behind us — body, replay, queued-prompt,
|
|
2242
2334
|
// lease, target, epoch, fence, claim, occupancy, head baseline, pin — so a
|
|
2243
2335
|
// refusal still reaches the user immediately and precisely. Only the spawn is
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
|
|
40
40
|
import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
|
|
41
41
|
import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
|
|
42
|
-
import { deriveForRow } from '../lib/session-hooks-runtime.js'
|
|
42
|
+
import { deriveForRow, signalFor } from '../lib/session-hooks-runtime.js'
|
|
43
43
|
import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
|
|
44
44
|
import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
|
|
45
45
|
import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
|
|
@@ -387,6 +387,14 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
387
387
|
attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', row.session_id)),
|
|
388
388
|
}))
|
|
389
389
|
}
|
|
390
|
+
// 6.51.0: a Cursor composer whose OWN hook events reached the store (Cursor runs the COS
|
|
391
|
+
// session hook from ~/.claude/settings.json) gets the same one derivation, from the
|
|
392
|
+
// signal alone. Hook-grounded only: a Cursor row with no events keeps exactly the 6.50
|
|
393
|
+
// shape, and nothing is inferred from its file times here.
|
|
394
|
+
for (const row of sessions) {
|
|
395
|
+
if (row.provider !== 'cursor' || !signalFor(row.session_id)) continue
|
|
396
|
+
derivedById.set(row.session_id, deriveForRow({ sessionId: row.session_id, now, remember: false }))
|
|
397
|
+
}
|
|
390
398
|
const queuedOf = queuedWaitingLookup(now)
|
|
391
399
|
res.json({
|
|
392
400
|
sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running)),
|
|
@@ -506,6 +514,9 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
506
514
|
transcript: { inFlight: running.occupied.get(parsed.session_id)?.activeRecently === true, lastActivityAt: activity.lastActivityAt ? Date.parse(activity.lastActivityAt) : null },
|
|
507
515
|
attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', parsed.session_id)),
|
|
508
516
|
})
|
|
517
|
+
} else if (provider === 'cursor' && signalFor(parsed.session_id)) {
|
|
518
|
+
// 6.51.0: the detail agrees with its list row (hook-grounded Cursor state).
|
|
519
|
+
derived = deriveForRow({ sessionId: parsed.session_id, remember: false })
|
|
509
520
|
}
|
|
510
521
|
res.json({
|
|
511
522
|
...withRunning({ session_id: parsed.session_id }, running),
|
|
@@ -6,29 +6,77 @@
|
|
|
6
6
|
// is still there, and must not steal the ring by asking (after a phone lock both copies'
|
|
7
7
|
// timers were frozen, so the owner only LOOKS quiet; the older copy's first tick would
|
|
8
8
|
// otherwise take it).
|
|
9
|
+
//
|
|
10
|
+
// 6.50.4: one owner PER DEVICE (the request's address), so an Even simulator on this Mac or
|
|
11
|
+
// a second phone on the same token cannot take the ring from the glasses (QA, 6.50.3); and
|
|
12
|
+
// the ring never moves in a way that stops the only recording. The router notes every
|
|
13
|
+
// meeting-chunk POST as it passes (it is mounted BEFORE the transcribe-stream router, and
|
|
14
|
+
// only notes, never answers): whose chunk it was (`?clientInstance=`, glasses 6.9.509+) or,
|
|
15
|
+
// untagged, which device sent it. `captureLive` in the answer: the owner is recording.
|
|
9
16
|
|
|
10
17
|
import { Router } from 'express'
|
|
11
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
arbitrateClientInstance,
|
|
20
|
+
ownerIsRecording,
|
|
21
|
+
parseClientInstanceClaim,
|
|
22
|
+
parseInstanceTag,
|
|
23
|
+
ClientChunkLedger,
|
|
24
|
+
NO_CAPTURE,
|
|
25
|
+
type ClientInstanceOwner,
|
|
26
|
+
} from '../lib/client-instance-claim.js'
|
|
27
|
+
|
|
28
|
+
/** Owners kept at once; the least recently seen device is dropped past this. */
|
|
29
|
+
export const CLIENT_INSTANCE_MAX_DEVICES = 16
|
|
12
30
|
|
|
13
|
-
export
|
|
31
|
+
export interface ClientInstanceRouterDeps {
|
|
32
|
+
now?: () => number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A POST that carries a meeting chunk: a live one, or an iPhone offline chunk replayed. The preview is not one. */
|
|
36
|
+
export function isMeetingChunkPost(method: string, path: string): boolean {
|
|
37
|
+
return method === 'POST' && (path === '/transcribe-stream' || /^\/transcribe-stream\/offline-sessions\/[^/]+\/chunks$/.test(path))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `::ffff:100.64.1.2` and `100.64.1.2` are one device. */
|
|
41
|
+
export function deviceKey(address: string | undefined): string {
|
|
42
|
+
const a = (address ?? '').trim()
|
|
43
|
+
return a.startsWith('::ffff:') ? a.slice(7) : (a || 'unknown')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (() => number) = {}): Router {
|
|
47
|
+
const opts: ClientInstanceRouterDeps = typeof deps === 'function' ? { now: deps } : deps
|
|
48
|
+
const now = opts.now ?? Date.now
|
|
14
49
|
const router = Router()
|
|
15
|
-
|
|
50
|
+
const owners = new Map<string, ClientInstanceOwner>()
|
|
51
|
+
const chunks = new ClientChunkLedger()
|
|
52
|
+
router.use((req, _res, next) => {
|
|
53
|
+
if (isMeetingChunkPost(req.method, req.path)) chunks.note(deviceKey(req.socket?.remoteAddress ?? req.ip), parseInstanceTag(req.query?.clientInstance), now())
|
|
54
|
+
next()
|
|
55
|
+
})
|
|
16
56
|
router.post('/client-instance/claim', (req, res) => {
|
|
17
|
-
const
|
|
57
|
+
const at = now()
|
|
58
|
+
const claim = parseClientInstanceClaim(req.body, at)
|
|
18
59
|
if (!claim) return void res.status(400).json({ error: 'invalid_claim' })
|
|
19
|
-
const
|
|
20
|
-
const
|
|
60
|
+
const device = deviceKey(req.socket?.remoteAddress ?? req.ip)
|
|
61
|
+
const previous = owners.get(device) ?? null
|
|
62
|
+
const evidence = previous ? chunks.evidence(device, previous.id, claim.id, at) : NO_CAPTURE
|
|
63
|
+
const captureLive = previous ? ownerIsRecording(previous, evidence, at) : false
|
|
64
|
+
const result = arbitrateClientInstance(previous, claim, at, evidence)
|
|
21
65
|
if (req.body?.check === true) {
|
|
22
66
|
const shown = previous ?? result.owner
|
|
23
|
-
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true })
|
|
67
|
+
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true, captureLive })
|
|
24
68
|
}
|
|
25
|
-
|
|
69
|
+
owners.delete(device)
|
|
70
|
+
owners.set(device, result.owner)
|
|
71
|
+
while (owners.size > CLIENT_INSTANCE_MAX_DEVICES) owners.delete(owners.keys().next().value as string)
|
|
72
|
+
const owner = result.owner
|
|
26
73
|
if (result.took && previous && previous.id !== claim.id) {
|
|
27
|
-
console.log(`[client-instance] ${new Date(
|
|
74
|
+
console.log(`[client-instance] ${new Date(at).toISOString()} ${claim.id} (${claim.version}) took the ring from ${previous.id} on ${device}`)
|
|
28
75
|
}
|
|
29
76
|
res.json({
|
|
30
77
|
verdict: result.verdict,
|
|
31
78
|
owner: { id: owner.id, bootAt: owner.bootAt, version: owner.version, seenAt: owner.seenAt },
|
|
79
|
+
captureLive,
|
|
32
80
|
})
|
|
33
81
|
})
|
|
34
82
|
return router
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// POST /hooks/cursor/stop-followup -- the COS session hook asks, at a Cursor composer's
|
|
2
|
+
// Stop, whether a turn is queued for it (6.51.0). See `lib/cursor-stop-followup.ts`.
|
|
3
|
+
//
|
|
4
|
+
// NOT under /api, on purpose. The hook holds the per-install HOOK token
|
|
5
|
+
// (`~/.cos-glasses/hook-token`, 0600), never the pairing token the /api gate checks, and
|
|
6
|
+
// widening that gate for one caller would be the larger change. This route checks the
|
|
7
|
+
// hook token itself, in constant time, before it reads anything. It answers only `{}` or
|
|
8
|
+
// `{"followup_message": ...}` so the hook can print it verbatim, and it never answers
|
|
9
|
+
// with anything a Claude Stop could act on: a body that is not a Cursor Stop is `{}`.
|
|
10
|
+
|
|
11
|
+
import express, { Router, type Request, type Response } from 'express'
|
|
12
|
+
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
13
|
+
import { claimNextCursorTurn, cursorStopAcceptsFollowup, cursorStopIsFresh, parseCursorStopEnvelope } from '../lib/cursor-stop-followup.js'
|
|
14
|
+
import { MAX_QUEUED_PER_THREAD, type QueuedThreadTurn } from '../lib/thread-turn-queue.js'
|
|
15
|
+
|
|
16
|
+
export interface CursorStopFollowupDeps {
|
|
17
|
+
/** The per-install hook token, or null when none has been minted (then nothing is answered). */
|
|
18
|
+
hookToken: () => string | null
|
|
19
|
+
readQueue: (provider: string, threadId: string, now: number) => QueuedThreadTurn[]
|
|
20
|
+
writeQueue: (provider: string, threadId: string, queue: QueuedThreadTurn[]) => void
|
|
21
|
+
now: () => number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CURSOR_STOP_FOLLOWUP_PATH = '/hooks/cursor/stop-followup'
|
|
25
|
+
|
|
26
|
+
export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Router {
|
|
27
|
+
const router = Router()
|
|
28
|
+
// The hook posts its spool envelope, capped at 1 MiB by the script itself.
|
|
29
|
+
router.post(CURSOR_STOP_FOLLOWUP_PATH, express.json({ limit: '2mb' }), (req: Request, res: Response) => {
|
|
30
|
+
res.set('Cache-Control', 'private, no-store')
|
|
31
|
+
let expected: string | null = null
|
|
32
|
+
try { expected = deps.hookToken() } catch { expected = null }
|
|
33
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-hook-token'], expected)) {
|
|
34
|
+
return res.status(401).json({})
|
|
35
|
+
}
|
|
36
|
+
const facts = parseCursorStopEnvelope(req.body)
|
|
37
|
+
if (!facts || !cursorStopAcceptsFollowup(facts, MAX_QUEUED_PER_THREAD)) return res.json({})
|
|
38
|
+
// The hook waits three seconds and then moves on. A claim made after it stopped
|
|
39
|
+
// listening marks a turn delivered that Cursor never received (QA, 6.51.0: a Stop that
|
|
40
|
+
// lands while the event loop is busy). A socket that already reads closed is one sign;
|
|
41
|
+
// the reliable one is the hook's own start stamp (see cursorStopIsFresh). Everything
|
|
42
|
+
// from here to the reply is synchronous.
|
|
43
|
+
if (req.socket?.destroyed === true || res.writableEnded) {
|
|
44
|
+
console.warn('[cursor-stop-followup] the hook hung up before the claim; nothing claimed')
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
let claimed: ReturnType<typeof claimNextCursorTurn> = null
|
|
48
|
+
try {
|
|
49
|
+
const now = deps.now()
|
|
50
|
+
if (!cursorStopIsFresh(facts.hookStartedAtMs, now)) {
|
|
51
|
+
console.warn(`[cursor-stop-followup] the Stop is ${facts.hookStartedAtMs === null ? 'unstamped' : `${now - facts.hookStartedAtMs} ms old`}; the hook may have stopped listening, so nothing is claimed and the turn waits for the next Stop`)
|
|
52
|
+
return res.json({})
|
|
53
|
+
}
|
|
54
|
+
claimed = claimNextCursorTurn(deps.readQueue('cursor', facts.conversationId, now), now)
|
|
55
|
+
if (!claimed) return res.json({})
|
|
56
|
+
// Written BEFORE the text leaves: the row must never read `waiting` after the
|
|
57
|
+
// composer has been handed it, or a later Stop would hand it over again.
|
|
58
|
+
deps.writeQueue('cursor', facts.conversationId, claimed.queue)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`[cursor-stop-followup] claim failed: ${error instanceof Error ? error.message : error}`)
|
|
61
|
+
return res.json({})
|
|
62
|
+
}
|
|
63
|
+
console.log(`[cursor-stop-followup] handed a queued turn to Cursor clientTurnId=${claimed.turn.clientTurnId} chars=${claimed.turn.prompt.length} loop=${facts.loopCount ?? 'null'} cursor=${facts.cursorVersion}`)
|
|
64
|
+
return res.json({ followup_message: claimed.turn.prompt })
|
|
65
|
+
})
|
|
66
|
+
return router
|
|
67
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -81,6 +81,8 @@ import {
|
|
|
81
81
|
} from '../lib/thread-attach-capability.js'
|
|
82
82
|
import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
83
83
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
84
|
+
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
85
|
+
import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
88
|
* 6.49.0: which last hop a Continue takes, and how often each fell back.
|
|
@@ -101,6 +103,18 @@ export function continueLiveHealthFields(): Record<string, unknown> {
|
|
|
101
103
|
lastReason: stats.lastReason,
|
|
102
104
|
lastAt: stats.lastAt,
|
|
103
105
|
},
|
|
106
|
+
// 6.51.0: Codex's last hop, counted apart so the Claude row above keeps its meaning.
|
|
107
|
+
codexLiveQueue: (() => {
|
|
108
|
+
const codex = codexLiveStats()
|
|
109
|
+
return {
|
|
110
|
+
enabled: codexLiveQueueEnabled(),
|
|
111
|
+
attempts: codex.attempts,
|
|
112
|
+
delivered: codex.delivered,
|
|
113
|
+
fallbacks: codex.fallbacks,
|
|
114
|
+
lastReason: codex.lastReason,
|
|
115
|
+
lastAt: codex.lastAt,
|
|
116
|
+
}
|
|
117
|
+
})(),
|
|
104
118
|
}
|
|
105
119
|
}
|
|
106
120
|
|
|
@@ -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
|
-
|
|
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 })
|