@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.
- package/.env.example +16 -0
- package/CHANGELOG.md +78 -0
- package/README.md +56 -3
- package/bin/hooks/cos-session-hook +24 -1
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +83 -5
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- 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-bridge.ts +6 -0
- package/server/lib/cursor-stop-followup.ts +134 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/occupancy-probes.ts +52 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +600 -15
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/lib/thread-occupancy.ts +30 -7
- package/server/routes/agent-session-bindings.ts +98 -6
- package/server/routes/agent-sessions.ts +24 -2
- package/server/routes/cursor-stop-followup.ts +74 -0
- package/server/routes/health.ts +34 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
- package/server/routes/thread-turn-queue.ts +15 -5
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type SessionStreamProvider,
|
|
23
23
|
} from './session-stream-events.js'
|
|
24
24
|
import { beginAttachedTurn, publishSessionStream, sessionStreamKey } from './session-stream-bus.js'
|
|
25
|
+
import { releaseAfterTranscriptCatchUp } from './session-transcript-watcher.js'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Ceiling on the incomplete trailing line held between chunks.
|
|
@@ -68,8 +69,15 @@ export function createLineAssembler(maxCarry: number = MAX_LINE_CARRY_CHARS): Li
|
|
|
68
69
|
export interface AttachedTurnStream {
|
|
69
70
|
/** Wire this as the adapter's `observeStdout`. Never throws. */
|
|
70
71
|
observeStdout(chunk: string): void
|
|
71
|
-
/**
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Call exactly once when the turn settles, whatever its outcome. Never throws.
|
|
74
|
+
*
|
|
75
|
+
* The terminal status is published before this returns. The hold-off lifts once the
|
|
76
|
+
* session's transcript watcher is at end of file (6.52.0 QA round 2): at once when no
|
|
77
|
+
* watcher tails the session, within `CATCH_UP_TIMEOUT_MS` otherwise. The promise
|
|
78
|
+
* settles then and never rejects; callers need not await it.
|
|
79
|
+
*/
|
|
80
|
+
finish(outcome: 'done' | 'idle'): Promise<void>
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
export interface AttachedTurnStreamOptions {
|
|
@@ -96,6 +104,7 @@ export function createAttachedTurnStream(options: AttachedTurnStreamOptions): At
|
|
|
96
104
|
const assembler = createLineAssembler()
|
|
97
105
|
const endTurn = beginAttachedTurn(key)
|
|
98
106
|
let finished = false
|
|
107
|
+
let released: Promise<void> = Promise.resolve()
|
|
99
108
|
|
|
100
109
|
const emit = (draft: SessionStreamDraft) => {
|
|
101
110
|
try {
|
|
@@ -117,8 +126,8 @@ export function createAttachedTurnStream(options: AttachedTurnStreamOptions): At
|
|
|
117
126
|
/* a malformed chunk costs its own events and nothing else */
|
|
118
127
|
}
|
|
119
128
|
},
|
|
120
|
-
finish(outcome: 'done' | 'idle'): void {
|
|
121
|
-
if (finished) return
|
|
129
|
+
finish(outcome: 'done' | 'idle'): Promise<void> {
|
|
130
|
+
if (finished) return released
|
|
122
131
|
finished = true
|
|
123
132
|
try {
|
|
124
133
|
for (const line of assembler.flush()) {
|
|
@@ -128,11 +137,21 @@ export function createAttachedTurnStream(options: AttachedTurnStreamOptions): At
|
|
|
128
137
|
/* fall through: the terminal status and the gate release matter more */
|
|
129
138
|
}
|
|
130
139
|
emit({ kind: 'status', state: outcome })
|
|
140
|
+
// NOT released here. The CLI wrote this turn's records to the transcript as well,
|
|
141
|
+
// and the watcher reads the hold-off only when it reads: released now, its next
|
|
142
|
+
// check would show the last lines again and put `working` after this `done`
|
|
143
|
+
// (6.52.0 QA round 2). The watcher module brings every watcher on this session to
|
|
144
|
+
// end of file while the hold still applies, then releases, within a bound.
|
|
131
145
|
try {
|
|
132
|
-
endTurn
|
|
146
|
+
released = releaseAfterTranscriptCatchUp(key, endTurn)
|
|
133
147
|
} catch {
|
|
134
|
-
|
|
148
|
+
try {
|
|
149
|
+
endTurn()
|
|
150
|
+
} catch {
|
|
151
|
+
/* the gate is a Map delete; there is no failure mode to report */
|
|
152
|
+
}
|
|
135
153
|
}
|
|
154
|
+
return released
|
|
136
155
|
},
|
|
137
156
|
}
|
|
138
157
|
}
|
|
@@ -141,6 +141,12 @@ export interface TranscriptTailer {
|
|
|
141
141
|
state(): 'working' | 'idle' | 'done' | null
|
|
142
142
|
/** True once this tailer has handed off to the poll. Terminal; never clears. */
|
|
143
143
|
degraded(): boolean
|
|
144
|
+
/**
|
|
145
|
+
* One HELD pass (6.52.0 QA round 2): move the cursor to end of file and emit nothing.
|
|
146
|
+
* Only `releaseAfterTranscriptCatchUp` calls it, while a Continue turn still holds the
|
|
147
|
+
* session. Resolves; never rejects.
|
|
148
|
+
*/
|
|
149
|
+
skipToEnd(): Promise<void>
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
/**
|
|
@@ -319,6 +325,18 @@ export function createTranscriptTailer(options: TranscriptTailerOptions): Transc
|
|
|
319
325
|
/* a tick that throws is a tick that produced nothing; the next one retries */
|
|
320
326
|
}
|
|
321
327
|
},
|
|
328
|
+
async skipToEnd(): Promise<void> {
|
|
329
|
+
if (degraded) return
|
|
330
|
+
try {
|
|
331
|
+
const st = await stat(options.path)
|
|
332
|
+
if (!st.isFile()) return
|
|
333
|
+
// The RAW end, not the last newline: see `releaseAfterTranscriptCatchUp`. Moving
|
|
334
|
+
// backward on a truncated file is what `tick` does too.
|
|
335
|
+
cursor = { offset: st.size }
|
|
336
|
+
} catch {
|
|
337
|
+
/* unreadable right now: the cursor stays, and the caller's bound still releases */
|
|
338
|
+
}
|
|
339
|
+
},
|
|
322
340
|
}
|
|
323
341
|
}
|
|
324
342
|
|
|
@@ -330,6 +348,8 @@ interface WatcherEntry {
|
|
|
330
348
|
refs: number
|
|
331
349
|
timer: ReturnType<typeof setInterval>
|
|
332
350
|
ticking: boolean
|
|
351
|
+
/** The pass `ticking` guards, so a catch-up can wait for it. Null when none runs. */
|
|
352
|
+
inflight: Promise<void> | null
|
|
333
353
|
tailer: TranscriptTailer
|
|
334
354
|
}
|
|
335
355
|
|
|
@@ -342,6 +362,8 @@ export interface AcquireWatcherOptions {
|
|
|
342
362
|
/** Where to start. Production passes the file's current size. */
|
|
343
363
|
offset: number
|
|
344
364
|
intervalMs?: number
|
|
365
|
+
/** Injected for tests: the tail this entry drives. Production builds it from the fields above. */
|
|
366
|
+
tailer?: TranscriptTailer
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
/**
|
|
@@ -358,7 +380,7 @@ export function acquireTranscriptWatcher(options: AcquireWatcherOptions): () =>
|
|
|
358
380
|
return releaseOnce(options.key)
|
|
359
381
|
}
|
|
360
382
|
|
|
361
|
-
const tailer = createTranscriptTailer(options)
|
|
383
|
+
const tailer = options.tailer ?? createTranscriptTailer(options)
|
|
362
384
|
const timer = setInterval(() => {
|
|
363
385
|
const entry = watchers.get(options.key)
|
|
364
386
|
if (!entry) return
|
|
@@ -367,12 +389,16 @@ export function acquireTranscriptWatcher(options: AcquireWatcherOptions): () =>
|
|
|
367
389
|
// next tick sees everything the skipped one would have.
|
|
368
390
|
if (entry.ticking) return
|
|
369
391
|
entry.ticking = true
|
|
370
|
-
|
|
392
|
+
// Kept on the entry rather than voided, so a catch-up can wait for it (below).
|
|
393
|
+
entry.inflight = tailer.tick().catch(() => {}).finally(() => {
|
|
394
|
+
entry.ticking = false
|
|
395
|
+
entry.inflight = null
|
|
396
|
+
})
|
|
371
397
|
}, options.intervalMs ?? POLL_INTERVAL_MS)
|
|
372
398
|
// Never hold the process open. A tail is a view, not work.
|
|
373
399
|
if (typeof (timer as any).unref === 'function') (timer as any).unref()
|
|
374
400
|
|
|
375
|
-
watchers.set(options.key, { refs: 1, timer, ticking: false, tailer })
|
|
401
|
+
watchers.set(options.key, { refs: 1, timer, ticking: false, inflight: null, tailer })
|
|
376
402
|
return releaseOnce(options.key)
|
|
377
403
|
}
|
|
378
404
|
|
|
@@ -410,6 +436,97 @@ export function transcriptWatcherDegraded(key: string): boolean {
|
|
|
410
436
|
return watchers.get(key)?.tailer.degraded() ?? false
|
|
411
437
|
}
|
|
412
438
|
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Catch-up before a Continue turn's hold-off lifts (6.52.0 QA round 2)
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// The hold-off is read when a tick READS, and a tick runs once a second. The CLI
|
|
443
|
+
// (`claude -p`, `codex exec resume`) writes each record to the transcript tailed here
|
|
444
|
+
// AND prints it on stdout, which the producer maps. Records written after the watcher's
|
|
445
|
+
// last check of the turn were therefore read by its first check AFTER the release, when
|
|
446
|
+
// nothing held them off: the final reply appeared twice on the lens, and the watcher's
|
|
447
|
+
// resume put `working` after `done`. QA reproduced it with the real tailer and producer
|
|
448
|
+
// on the real codex exec sample.
|
|
449
|
+
//
|
|
450
|
+
// So the producer hands its release to `releaseAfterTranscriptCatchUp`, which, while the
|
|
451
|
+
// hold still applies:
|
|
452
|
+
// 1. waits for a tick already in flight, so it reads the hold-off while it holds;
|
|
453
|
+
// 2. runs one held pass that moves the cursor to END OF FILE and emits nothing;
|
|
454
|
+
// 3. only then releases.
|
|
455
|
+
//
|
|
456
|
+
// RAW END OF FILE, not the last newline. On an ordinary exit the child has closed, so
|
|
457
|
+
// every byte there is the turn's. A record cut mid-write left BEHIND the cursor would
|
|
458
|
+
// join the next real record into one unparseable line and lose it; a fragment AHEAD of
|
|
459
|
+
// it is the tail of a record, which never parses (the outer record's closing brace
|
|
460
|
+
// follows it), so the grammar drops it.
|
|
461
|
+
//
|
|
462
|
+
// BOUNDED. The pass is one stat, but a wedged volume or a tick that never settles must
|
|
463
|
+
// not keep the hold-off: that would silence the session for good, which the producer
|
|
464
|
+
// already names as worse than a duplicated line. After `CATCH_UP_TIMEOUT_MS` the release
|
|
465
|
+
// happens anyway and is counted, as is a pass that throws.
|
|
466
|
+
|
|
467
|
+
/** Ceiling on the catch-up. The pass is one stat; this is for a wedged disk, not an expected wait. */
|
|
468
|
+
export const CATCH_UP_TIMEOUT_MS = 500
|
|
469
|
+
|
|
470
|
+
const catchUpCounts = { timedOut: 0, failed: 0 }
|
|
471
|
+
|
|
472
|
+
/** Catch-ups that released on the bound, or on a pass that threw, since this server started. */
|
|
473
|
+
export function transcriptCatchUpCounts(): { timedOut: number; failed: number } {
|
|
474
|
+
return { ...catchUpCounts }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function catchUpToEnd(entry: WatcherEntry): Promise<void> {
|
|
478
|
+
// 1. A check already reading: let it read the hold-off while it still holds.
|
|
479
|
+
while (entry.ticking && entry.inflight) await entry.inflight
|
|
480
|
+
// 2. One held pass, guarded like a tick so the timer cannot run another beside it.
|
|
481
|
+
entry.ticking = true
|
|
482
|
+
const pass = Promise.resolve().then(() => entry.tailer.skipToEnd())
|
|
483
|
+
entry.inflight = pass.catch(() => {}).finally(() => {
|
|
484
|
+
entry.ticking = false
|
|
485
|
+
entry.inflight = null
|
|
486
|
+
})
|
|
487
|
+
await pass
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Bring every watcher on this session to end of file, THEN call `release`.
|
|
492
|
+
*
|
|
493
|
+
* `release` runs exactly once: at once when nothing tails the session, otherwise after
|
|
494
|
+
* the catch-up or after `timeoutMs`, whichever is first. The promise settles when it has
|
|
495
|
+
* run and never rejects.
|
|
496
|
+
*/
|
|
497
|
+
export function releaseAfterTranscriptCatchUp(
|
|
498
|
+
key: string,
|
|
499
|
+
release: () => void,
|
|
500
|
+
timeoutMs: number = CATCH_UP_TIMEOUT_MS,
|
|
501
|
+
): Promise<void> {
|
|
502
|
+
const releaseSafely = () => {
|
|
503
|
+
try {
|
|
504
|
+
release()
|
|
505
|
+
} catch {
|
|
506
|
+
/* the release is a Map delete; there is no failure mode to report */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const entry = watchers.get(key)
|
|
510
|
+
if (!entry) {
|
|
511
|
+
// Nothing tails this session, so nothing can read the turn's records late.
|
|
512
|
+
releaseSafely()
|
|
513
|
+
return Promise.resolve()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
517
|
+
const bound = new Promise<'timed_out'>(resolve => {
|
|
518
|
+
timer = setTimeout(() => resolve('timed_out'), Math.max(0, timeoutMs))
|
|
519
|
+
if (typeof (timer as any).unref === 'function') (timer as any).unref()
|
|
520
|
+
})
|
|
521
|
+
const work = catchUpToEnd(entry).then(() => 'caught_up' as const, () => 'failed' as const)
|
|
522
|
+
return Promise.race([work, bound]).then(outcome => {
|
|
523
|
+
clearTimeout(timer)
|
|
524
|
+
if (outcome === 'timed_out') catchUpCounts.timedOut++
|
|
525
|
+
else if (outcome === 'failed') catchUpCounts.failed++
|
|
526
|
+
releaseSafely()
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
413
530
|
export function __resetTranscriptWatchersForTests(): void {
|
|
414
531
|
for (const entry of watchers.values()) clearInterval(entry.timer)
|
|
415
532
|
watchers.clear()
|
|
@@ -156,11 +156,18 @@ export interface OccupancyProbes {
|
|
|
156
156
|
* sub-agent is open, the session has not ended, AND the registry record says `idle`
|
|
157
157
|
* with `statusUpdatedAt` at or after the Stop (Claude flips it only when every Stop
|
|
158
158
|
* hook has returned, 12-38 s after the Stop on the release Mac, which is when a prompt
|
|
159
|
-
* queued at the desk would dequeue). Null otherwise
|
|
159
|
+
* queued at the desk would dequeue). Null otherwise.
|
|
160
160
|
* OPTIONAL, and its absence is the default: attached by `withHookTurnClock` at the
|
|
161
161
|
* composition root only when `COS_SESSION_HOOKS` is on, so the pure gate never reads
|
|
162
162
|
* the environment.
|
|
163
163
|
*
|
|
164
|
+
* 6.51.0, CODEX: `withCodexTurnClock` answers for Codex threads from the rollout's own
|
|
165
|
+
* turn markers: the newest is `task_complete`/`turn_complete`, so the engine closed the
|
|
166
|
+
* turn. Safe to read idle at once for Codex in a way it never was for a Claude Stop,
|
|
167
|
+
* because every write into a held Codex thread goes through the app's own queue
|
|
168
|
+
* (`codex-live-queue.ts`), which the engine itself serialises behind any turn it starts
|
|
169
|
+
* next; nothing is ever written beside the holder.
|
|
170
|
+
*
|
|
164
171
|
* What it buys: a foreign Desktop holder whose turn the engine has closed reads `idle`
|
|
165
172
|
* at once instead of after the 30 s transcript window. The transcript's mtime is not
|
|
166
173
|
* consulted: the bookkeeping rows Claude writes as the hooks finish are exactly what
|
|
@@ -177,6 +184,13 @@ export interface OccupancyProbes {
|
|
|
177
184
|
cwd: string
|
|
178
185
|
hasConversation: boolean
|
|
179
186
|
} | null
|
|
187
|
+
/**
|
|
188
|
+
* 6.51.0: is this Cursor IDE composer mid-turn, by its own hook events (Cursor runs the
|
|
189
|
+
* COS session hook from `~/.claude/settings.json`)? True only on positive evidence: a
|
|
190
|
+
* prompt or tool event newer than the last Stop, inside the open-turn ceiling. OPTIONAL;
|
|
191
|
+
* absent, null, or a throw keeps the 6.50 verdict (`unsupported_provider`).
|
|
192
|
+
*/
|
|
193
|
+
cursorComposerTurnOpen?: (threadId: string) => boolean | null
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
/**
|
|
@@ -287,11 +301,11 @@ function readHolderActivity(
|
|
|
287
301
|
}
|
|
288
302
|
const activity = holderActivity(mtime, nowMs)
|
|
289
303
|
if (activity !== 'working') return activity
|
|
290
|
-
// THE B6 CLAUSE. Only a `working` verdict is revisited, only for Claude
|
|
291
|
-
//
|
|
292
|
-
// the turn (null is strict). `unknown`
|
|
293
|
-
// never a doubt.
|
|
294
|
-
if (provider !== 'claude' || typeof probes.holderTurnEndedAtMs !== 'function' || typeof mtime !== 'number') return activity
|
|
304
|
+
// THE B6 CLAUSE. Only a `working` verdict is revisited, only for Claude (6.51.0: and
|
|
305
|
+
// Codex, from its rollout's turn markers), only with a real clock behind it, and only
|
|
306
|
+
// when the probe VOUCHES that the engine closed the turn (null is strict). `unknown`
|
|
307
|
+
// stays unknown: the clause narrows a refusal, never a doubt.
|
|
308
|
+
if ((provider !== 'claude' && provider !== 'codex') || typeof probes.holderTurnEndedAtMs !== 'function' || typeof mtime !== 'number') return activity
|
|
295
309
|
let stopAt: number | null
|
|
296
310
|
try {
|
|
297
311
|
stopAt = probes.holderTurnEndedAtMs(provider, threadId)
|
|
@@ -612,7 +626,16 @@ export function threadOccupancy(
|
|
|
612
626
|
return { attachable: false, owners: [], reason: 'probe_failed' }
|
|
613
627
|
}
|
|
614
628
|
if (!session || session.hasConversation !== true) {
|
|
615
|
-
|
|
629
|
+
// 6.51.0: still never attachable (Gate 0: an IDE composer is not resumed from
|
|
630
|
+
// outside), but a composer the hooks show MID-TURN is working, not unsupported, so
|
|
631
|
+
// a turn spoken at it can wait for its Stop hook. The reason is the only change.
|
|
632
|
+
let open: boolean | null = null
|
|
633
|
+
try {
|
|
634
|
+
open = typeof probes.cursorComposerTurnOpen === 'function' ? probes.cursorComposerTurnOpen(threadId) : null
|
|
635
|
+
} catch {
|
|
636
|
+
open = null
|
|
637
|
+
}
|
|
638
|
+
return { attachable: false, owners: [], reason: open === true ? 'native_thread_working' : 'unsupported_provider' }
|
|
616
639
|
}
|
|
617
640
|
return { attachable: true, owners: [], reason: null }
|
|
618
641
|
}
|
|
@@ -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
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
emptySessionListDropped,
|
|
30
30
|
loadClaudeDesktopAliases,
|
|
31
31
|
loadCursorComposerNames,
|
|
32
|
+
loadCodexThreadNames,
|
|
33
|
+
idFromCodexFilename,
|
|
32
34
|
parseAgentSession,
|
|
33
35
|
type AgentProvider,
|
|
34
36
|
type AgentSessionRoots,
|
|
@@ -39,7 +41,7 @@ import {
|
|
|
39
41
|
import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
|
|
40
42
|
import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
|
|
41
43
|
import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
|
|
42
|
-
import { deriveForRow } from '../lib/session-hooks-runtime.js'
|
|
44
|
+
import { deriveForRow, signalFor } from '../lib/session-hooks-runtime.js'
|
|
43
45
|
import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
|
|
44
46
|
import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
|
|
45
47
|
import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
|
|
@@ -387,6 +389,14 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
387
389
|
attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', row.session_id)),
|
|
388
390
|
}))
|
|
389
391
|
}
|
|
392
|
+
// 6.51.0: a Cursor composer whose OWN hook events reached the store (Cursor runs the COS
|
|
393
|
+
// session hook from ~/.claude/settings.json) gets the same one derivation, from the
|
|
394
|
+
// signal alone. Hook-grounded only: a Cursor row with no events keeps exactly the 6.50
|
|
395
|
+
// shape, and nothing is inferred from its file times here.
|
|
396
|
+
for (const row of sessions) {
|
|
397
|
+
if (row.provider !== 'cursor' || !signalFor(row.session_id)) continue
|
|
398
|
+
derivedById.set(row.session_id, deriveForRow({ sessionId: row.session_id, now, remember: false }))
|
|
399
|
+
}
|
|
390
400
|
const queuedOf = queuedWaitingLookup(now)
|
|
391
401
|
res.json({
|
|
392
402
|
sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running)),
|
|
@@ -474,7 +484,16 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
474
484
|
// `parsed.truncated` says so out loud, and the counts are then counts of what was
|
|
475
485
|
// READ. Presenting a partial count as the session total would be the same
|
|
476
486
|
// dishonesty as a silent cap, so the digest omits the number entirely instead.
|
|
477
|
-
|
|
487
|
+
// 6.52.0: a Codex thread is titled by its own name, as its list row already is. Keyed
|
|
488
|
+
// by the rollout's FILENAME id first: a cloned thread's `session_meta` carries its
|
|
489
|
+
// parent's id (see `listCodexSessions`), and the parent's name would be the wrong one.
|
|
490
|
+
let codexThreadName: string | undefined
|
|
491
|
+
if (provider === 'codex') {
|
|
492
|
+
const names = await loadCodexThreadNames(agentSessionRoots().codexSessions)
|
|
493
|
+
const fileId = idFromCodexFilename(found.split('/').pop() || '')
|
|
494
|
+
codexThreadName = (fileId ? names.get(fileId) : undefined) || names.get(sessionId) || undefined
|
|
495
|
+
}
|
|
496
|
+
const parsed = await parseAgentSession(provider, found, codexThreadName ? { codexThreadName } : {})
|
|
478
497
|
// 6.50.0 `?turns=N`: the recent conversation, for the lens's scroll-up history. Only
|
|
479
498
|
// when asked, so the detail poll and every older client get the same bytes as before.
|
|
480
499
|
// A failure here costs the history, never the detail page.
|
|
@@ -506,6 +525,9 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
506
525
|
transcript: { inFlight: running.occupied.get(parsed.session_id)?.activeRecently === true, lastActivityAt: activity.lastActivityAt ? Date.parse(activity.lastActivityAt) : null },
|
|
507
526
|
attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', parsed.session_id)),
|
|
508
527
|
})
|
|
528
|
+
} else if (provider === 'cursor' && signalFor(parsed.session_id)) {
|
|
529
|
+
// 6.51.0: the detail agrees with its list row (hook-grounded Cursor state).
|
|
530
|
+
derived = deriveForRow({ sessionId: parsed.session_id, remember: false })
|
|
509
531
|
}
|
|
510
532
|
res.json({
|
|
511
533
|
...withRunning({ session_id: parsed.session_id }, running),
|
|
@@ -0,0 +1,74 @@
|
|
|
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 its body is read or parsed (6.52.0: it is
|
|
8
|
+
// mounted ahead of the global 10 MB parser, and its own 2 MB parser runs only after the
|
|
9
|
+
// token check). It answers only `{}` or `{"followup_message": ...}` so the hook can print
|
|
10
|
+
// it verbatim, and it never answers with anything a Claude Stop could act on: a body that
|
|
11
|
+
// is not a Cursor Stop is `{}`.
|
|
12
|
+
|
|
13
|
+
import express, { Router, type NextFunction, type Request, type Response } from 'express'
|
|
14
|
+
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
15
|
+
import { claimNextCursorTurn, cursorStopAcceptsFollowup, cursorStopIsFresh, parseCursorStopEnvelope } from '../lib/cursor-stop-followup.js'
|
|
16
|
+
import { MAX_QUEUED_PER_THREAD, type QueuedThreadTurn } from '../lib/thread-turn-queue.js'
|
|
17
|
+
|
|
18
|
+
export interface CursorStopFollowupDeps {
|
|
19
|
+
/** The per-install hook token, or null when none has been minted (then nothing is answered). */
|
|
20
|
+
hookToken: () => string | null
|
|
21
|
+
readQueue: (provider: string, threadId: string, now: number) => QueuedThreadTurn[]
|
|
22
|
+
writeQueue: (provider: string, threadId: string, queue: QueuedThreadTurn[]) => void
|
|
23
|
+
now: () => number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const CURSOR_STOP_FOLLOWUP_PATH = '/hooks/cursor/stop-followup'
|
|
27
|
+
|
|
28
|
+
export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Router {
|
|
29
|
+
const router = Router()
|
|
30
|
+
// The token first, then the body: the hook posts its spool envelope, capped at 1 MiB by
|
|
31
|
+
// the script itself.
|
|
32
|
+
const requireHookToken = (req: Request, res: Response, next: NextFunction): void => {
|
|
33
|
+
res.set('Cache-Control', 'private, no-store')
|
|
34
|
+
let expected: string | null = null
|
|
35
|
+
try { expected = deps.hookToken() } catch { expected = null }
|
|
36
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-hook-token'], expected)) {
|
|
37
|
+
res.status(401).json({})
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
next()
|
|
41
|
+
}
|
|
42
|
+
router.post(CURSOR_STOP_FOLLOWUP_PATH, requireHookToken, express.json({ limit: '2mb' }), (req: Request, res: Response) => {
|
|
43
|
+
const facts = parseCursorStopEnvelope(req.body)
|
|
44
|
+
if (!facts || !cursorStopAcceptsFollowup(facts, MAX_QUEUED_PER_THREAD)) return res.json({})
|
|
45
|
+
// The hook waits three seconds and then moves on. A claim made after it stopped
|
|
46
|
+
// listening marks a turn delivered that Cursor never received (QA, 6.51.0: a Stop that
|
|
47
|
+
// lands while the event loop is busy). A socket that already reads closed is one sign;
|
|
48
|
+
// the reliable one is the hook's own start stamp (see cursorStopIsFresh). Everything
|
|
49
|
+
// from here to the reply is synchronous.
|
|
50
|
+
if (req.socket?.destroyed === true || res.writableEnded) {
|
|
51
|
+
console.warn('[cursor-stop-followup] the hook hung up before the claim; nothing claimed')
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
let claimed: ReturnType<typeof claimNextCursorTurn> = null
|
|
55
|
+
try {
|
|
56
|
+
const now = deps.now()
|
|
57
|
+
if (!cursorStopIsFresh(facts.hookStartedAtMs, now)) {
|
|
58
|
+
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`)
|
|
59
|
+
return res.json({})
|
|
60
|
+
}
|
|
61
|
+
claimed = claimNextCursorTurn(deps.readQueue('cursor', facts.conversationId, now), now)
|
|
62
|
+
if (!claimed) return res.json({})
|
|
63
|
+
// Written BEFORE the text leaves: the row must never read `waiting` after the
|
|
64
|
+
// composer has been handed it, or a later Stop would hand it over again.
|
|
65
|
+
deps.writeQueue('cursor', facts.conversationId, claimed.queue)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`[cursor-stop-followup] claim failed: ${error instanceof Error ? error.message : error}`)
|
|
68
|
+
return res.json({})
|
|
69
|
+
}
|
|
70
|
+
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}`)
|
|
71
|
+
return res.json({ followup_message: claimed.turn.prompt })
|
|
72
|
+
})
|
|
73
|
+
return router
|
|
74
|
+
}
|