@gotcos/glasses-server 6.50.4 → 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 +11 -0
- package/bin/hooks/cos-session-hook +24 -1
- package/package.json +1 -1
- package/server/index.ts +33 -4
- 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/cursor-stop-followup.ts +67 -0
- package/server/routes/health.ts +14 -0
- package/server/routes/thread-turn-queue.ts +15 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 6.51.0
|
|
2
|
+
|
|
3
|
+
Codex and Cursor sessions get the Continue and queue that Claude sessions have, each through the engine's own door, and Cursor composers get hook-grounded state. Nothing on a Claude path changes.
|
|
4
|
+
|
|
5
|
+
- **Continue into a Codex thread the Codex app has open.** Before this, a Continue into any thread open in ChatGPT.app (Codex Desktop) could not work: the app's app-server holds the thread's writer lock for as long as the thread is loaded, idle included, so the `codex exec resume` child died in about 0.2 s on "thread already has an active writer" AFTER the prompt was written to its stdin. That is an `ambiguous` turn and a fence (two on record from August, both released by hand). Now, when the gate's own verdict names a foreign writer on a Codex thread, the turn goes through `codex queue --thread=<id> --message=<text>`, which inserts a row into Codex's own queue (`$CODEX_HOME/queue_1.sqlite`). Every app-server watches that database for writes from other processes, so the Codex app runs the message 10 ms after the current turn's `task_complete`, or within about 7 s when the thread is idle, as an ordinary user turn that is visible and editable in the app's own queue. Canary 2026-09-19 against a scratch `CODEX_HOME` and codex-cli 0.155.0-alpha.9.2 (the detail is in `codex-live-queue.ts`). Delivered means exit 0 AND the CLI's confirmation line naming our thread: 200 `completed`, `via: live`, copy "Queued in the Codex app...". A timeout, a kill or an unreadable confirmation might have inserted a row, and nothing can re-check that before a resend, so the turn is `delivery_ambiguous`: ledgered (a replay answers the same) and the thread fenced until a person looks at the Codex app's queue and releases it, like every other unproven delivery. The CLI's own "failed to queue session message", or a clap usage error (a `codex` without `queue`), is `codex_live_unavailable`: nothing was queued, not retryable, and no child is spawned against the held thread. A Codex thread nobody holds takes the 6.50 path unchanged. `COS_CODEX_LIVE_QUEUE=0` restores 6.50 everywhere. The prompt rides in argv for about 50 ms (the CLI has no stdin form: `--message -` queues a literal dash); it is never logged, and the `--message=` form keeps a prompt that starts with a dash as text. The runner kills its whole process group at the 5 s ceiling and answers at the ceiling even if a daemonised grandchild keeps stdout open. `/api/health` gains `codexLiveQueue` next to `continueLive`.
|
|
6
|
+
- **A queued turn on a Codex thread drains at the engine's `task_complete`**, not 30 s later. The B6 clause gains a Codex branch: a Codex holder whose rollout's newest turn marker is `task_complete`, `turn_complete` or `turn_aborted` reads idle at once, with the drain's own tail rule (an aborted Codex turn now ends the turn on the stream and for the drain too; a follow-up used to wait out the 30 min open-turn ceiling). The clause is off with `COS_CODEX_LIVE_QUEUE=0`, so that switch restores 6.50 fully. That is safe for Codex in a way it never was for a Claude Stop, because every write into a held Codex thread now goes through the app's queue, which the engine serialises behind any turn it starts next.
|
|
7
|
+
- **Cursor: a turn can queue for a composer that is mid-turn, and leaves through that composer's own Stop hook.** Cursor 3.21 runs hooks from `~/.claude/settings.json` by default, so the COS session hook installed on 9/15 has been running for Cursor composers since (478 Cursor events in the ledger by 9/18). And Cursor submits a Stop hook's `{"followup_message": ...}` as that composer's next message. So: occupancy reads a composer its hooks show mid-turn as `native_thread_working` (still never attachable: Gate 0 stands); the queue admits a Cursor turn for that reason only (every other Cursor reason stays 423, including ones that are queueable for Claude); the drainer never delivers a Cursor turn; and at the composer's Stop the hook script asks `POST /hooks/cursor/stop-followup` (outside `/api`, hook-token auth) and prints the reply. One turn per Stop, oldest first, claimed before the text leaves, and never for a hook that already hung up (the claim happens only on an open connection); only a `completed` turn gets one (an `aborted` or `error` Stop gets nothing). The hook asks only when the queue folder holds a file for that composer (almost every Stop sends nothing), falls back to asking when the folder is absent, and reads the payload with any JSON spacing. The script's new branch runs only when the environment carries `CURSOR_VERSION` AND the payload carries `"cursor_version"`; a Claude Stop takes the old line and never touches the network. An idle composer fires no Stop, so a turn cannot be put into one; that stays refused. The new script reaches `~/.cos-glasses/bin` through `--hooks install` (the settings entries are unchanged, so `settings.json` is not rewritten).
|
|
8
|
+
- **Cursor rows carry `agent_state` from their own hooks.** A composer whose events reached the signal store gets the same one derivation Claude rows get (running while its turn is open, idle after its Stop), on the list and the detail. A Cursor row with no events keeps the 6.50 shape.
|
|
9
|
+
- Tests: the Codex hop over HTTP (queued via live, replay-safe, held on unverified or a throw, refused without spawning when nothing was queued, 6.50 when disabled, without a holder, or without the dep, never for Claude, the turn clock opening the gate); every verdict rule, the argv, the confirmation, and the runner against real children (argv without a shell, a hang, an escaped grandchild, a missing binary, end to end through a fake `codex`); the Codex turn clock against real rollout lines; the Cursor composer signal (open, closed, ended, stale, throwing, CLI chat untouched, Claude and Codex untouched); the queue route's Cursor rule and the drainer's skip including TTL retirement; the Stop claim over HTTP (one per Stop, aborted gets nothing, Claude Stop and junk get `{}`, token required, only the cursor queue); the hook script run as Cursor runs it (posts and prints, `{}` prints nothing, a Claude Stop mentioning cursor_version never posts, missing env, payload or event sends nothing, a missing token or a closed port is silent and fast); Cursor rows over HTTP. The refusal vocabulary table covers `codex_live_unavailable`. Gate: 30/30 new cases (`codex-live-*`, `route-codex-*`, `codex-clock-*`, `codex-abort-*`, `cursor-*`, `hook-script-*`); `codex-live-timeout-waits-for-close` survived the first draft of the tests and the escaped-grandchild case was added for it.
|
|
10
|
+
- **QA pass (one skeptic, before publish) and the live canary.** Fixed from QA: an unverified Codex hand-off was retryable, so the drainer could insert the same sentence at every turn end (now ambiguous and fenced); a Cursor claim could be marked delivered after the hook had hung up; the Codex turn clock stayed on with the switch off; `turn_aborted` was read differently by the server and COS Control. A second, narrow QA round found the hung-up check was not enough: when the server is busy past the hook's 3 s, curl gives up and closes, but Node reads the request before the close, so the socket still looked open and the turn was claimed for a hook that was gone. A real-curl test (the event loop blocked 2.6 s against a 1 s curl) reproduced it: the row read `delivered`. Now the route claims only within 2 s of the hook's own start stamp (the envelope's `ts`, same Mac clock), so the reply always leaves with a second to spare; a later or unstamped Stop leaves the turn waiting for the next one. The hook also reads `session_id` when a payload has no `conversation_id`, and asks the server rather than skipping when it cannot read an id at all. Live canary on a scratch server (scratch `CODEX_HOME`, throwaway sessions): Codex Continue into a held thread queued in the app and the holder itself ran it 2.6 s later; a working Codex thread refused at attach, the follow-up parked, and the drainer handed it to the app, which ran it 3.8 s after the hand-off; Cursor mid-turn admitted, idle refused, and a Claude-shaped or aborted Stop got nothing. Found upstream, not from this release: Claude Code 2.1.278 HOLDS a peer message (`crossSessionInbound`: "Held peer message, from an unidentified session") when the receiving session runs a permissive mode, so a live Continue into such a session reads `live_unverified` and waits for approval at the desk; the Claude path here is unchanged.
|
|
11
|
+
|
|
1
12
|
## 6.50.4
|
|
2
13
|
|
|
3
14
|
- **The ring never moves in a way that stops the only recording.** A copy told to yield stops its recording (that is how a duplicate ends). QA of 6.50.3, with glasses 6.9.507 on the phone: reopening COS on a blank phone page mid-meeting (the 2026-09-18 08:15 symptom) gave the ring to the newer copy, so the meeting ended within 15 s and the new window was not recording. The referee now notes every meeting-chunk POST as it passes: a live chunk, or an iPhone offline chunk replayed; the preview does not count. The router is mounted before the transcribe-stream router and only notes, never answers. It records whose chunk it was (`?clientInstance=`, glasses 6.9.509+) or, for an untagged chunk, which device sent it. Two rules follow. (1) While the owner's OWN chunks arrive (30 s window, `CLIENT_INSTANCE_CAPTURE_LIVE_MS`), a newer copy is told to wait, however quiet the owner's claims. A hidden or locked WebView throttles its timers, but chunk uploads follow the audio. The ring moves on the first claim after the meeting stops. (2) A claimant whose own chunks arrive while the owner's do not takes the ring. That covers the case QA found in the first draft of this release, which held the ring on ANY chunk: COS reopened offline, a meeting started on the new copy, and on reconnect that copy's own chunks switched the hold on for the dead copy the Mac still remembered, so its claim was told to yield and the only recording stopped. (3) A claim now says whether its copy is recording (`recording`, glasses 6.9.510+). A recording claimant takes the ring from an owner that is not recording, and an owner's last `recording: true` holds the ring while it keeps claiming (45 s). Chunk arrival alone lags: after an outage the uploader backs off up to 60 s, and only a visible page resets it, which a hidden recording copy never gets. QA round 3 found that gap: a reopened copy that reclaimed offline took the ring back on reconnect, and the recording copy was then told to stop. Untagged chunks (glasses 6.9.507/6.9.508) count for the owner only while it still claims (45 s), so a dead owner is never pinned by them or by a stale flag. When both copies record, the owner keeps the ring and the claimant stops. The answer's `captureLive` means the owner is recording; glasses 6.9.510 refuse a yield while recording when it is `false`. `lastRecordingChunkAt` (first draft) is gone.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# COS session hook (glasses-server 6.48.0). Installed into ~/.claude/settings.json by
|
|
2
|
+
# COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0). Installed into ~/.claude/settings.json by
|
|
3
3
|
# `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
|
|
4
4
|
# payload on stdin. It spools one JSON file per event into the hook spool and exits 0
|
|
5
5
|
# on every path. Only PermissionRequest ever talks to the server, and only when the
|
|
@@ -23,6 +23,29 @@ T="$SPOOL/.tmp.$$.$TS"; head -c 1048576 > "$T.in" # stdin to
|
|
|
23
23
|
case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
|
|
24
24
|
{ printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
|
|
25
25
|
rm -f "$T.in"
|
|
26
|
+
# 6.51.0, CURSOR ONLY. Cursor runs this hook from ~/.claude/settings.json for its own
|
|
27
|
+
# composers, and submits a Stop hook's {"followup_message":...} as that composer's next
|
|
28
|
+
# message. A turn COS queued for the composer is handed over here, at the end of its turn.
|
|
29
|
+
# Claude Code sets no CURSOR_VERSION and sends no cursor_version, so a Claude Stop takes
|
|
30
|
+
# the line below this block exactly as before and never touches the network.
|
|
31
|
+
if [ "$EVENT" = Stop ] && [ -n "${CURSOR_VERSION:-}" ] && grep -Eq '"cursor_version"[[:space:]]*:[[:space:]]*"' "$T" 2>/dev/null; then
|
|
32
|
+
cp "$T" "$T.req" 2>/dev/null; mv "$T" "$SPOOL/$TS-$$-$EVENT.json"
|
|
33
|
+
# Nothing queued for this composer (almost every Stop): no request at all. The queue
|
|
34
|
+
# folder sits beside the spool. If it is not there, or the id cannot be read here,
|
|
35
|
+
# ask anyway rather than guess; the server reads the id the same way (conversation_id,
|
|
36
|
+
# then session_id).
|
|
37
|
+
QDIR="$(dirname "$SPOOL")/thread-turn-queue"
|
|
38
|
+
CONV=$(grep -Eo '"conversation_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$T.req" 2>/dev/null | grep -Eo '[0-9A-Fa-f-]{36}' | head -1 | tr 'A-F' 'a-f')
|
|
39
|
+
[ -n "$CONV" ] || CONV=$(grep -Eo '"session_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$T.req" 2>/dev/null | grep -Eo '[0-9A-Fa-f-]{36}' | head -1 | tr 'A-F' 'a-f')
|
|
40
|
+
if [ -d "$QDIR" ] && [ -n "$CONV" ] && [ ! -f "$QDIR/cursor-$CONV.json" ]; then rm -f "$T.req"; exit 0; fi
|
|
41
|
+
TOK=$(cat "$GH/hook-token" 2>/dev/null); PORT=$(cat "$GH/hook-port" 2>/dev/null || echo 3141)
|
|
42
|
+
if [ -n "$TOK" ] && [ -f "$T.req" ]; then
|
|
43
|
+
OUT=$(curl -s --connect-timeout 1 --max-time 3 -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
|
|
44
|
+
--data-binary @"$T.req" "http://127.0.0.1:$PORT/hooks/cursor/stop-followup") || OUT=''
|
|
45
|
+
case "$OUT" in '{"followup_message"'*) printf '%s' "$OUT" ;; esac
|
|
46
|
+
fi
|
|
47
|
+
rm -f "$T.req"; exit 0
|
|
48
|
+
fi
|
|
26
49
|
if [ "$EVENT" != PermissionRequest ]; then mv "$T" "$SPOOL/$TS-$$-$EVENT.json"; exit 0; fi # rename = publish
|
|
27
50
|
# A permission request keeps its own copy: the server unlinks the published file within
|
|
28
51
|
# milliseconds of the rename, and the request body below must outlive that.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.51.0",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -34,10 +34,13 @@ import {
|
|
|
34
34
|
import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
|
|
35
35
|
import { targetKey } from './lib/agent-session-binding-store.js'
|
|
36
36
|
import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
|
|
37
|
-
import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
|
|
37
|
+
import { buildOccupancyProbes, realOccupancyDirs, withCodexTurnClock, withCursorComposerTurn, withHookTurnClock } from './lib/occupancy-probes.js'
|
|
38
|
+
import { readCodexTurnEndedAtMs } from './lib/codex-turn-clock.js'
|
|
39
|
+
import { codexLiveQueueEnabled } from './lib/codex-live-queue.js'
|
|
38
40
|
import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
|
|
39
41
|
import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
|
|
40
42
|
import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
|
|
43
|
+
import { makeCodexLiveDeliverer } from './lib/codex-live-queue-deps.js'
|
|
41
44
|
import { forkThread, realForkDeps } from './lib/fork-thread.js'
|
|
42
45
|
import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
|
|
43
46
|
import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
|
|
@@ -131,7 +134,9 @@ import {
|
|
|
131
134
|
|
|
132
135
|
const app = express()
|
|
133
136
|
import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
|
|
134
|
-
import {
|
|
137
|
+
import { createCursorStopFollowupRouter } from './routes/cursor-stop-followup.js'
|
|
138
|
+
import { queuedThreadKeys, readQueue, transcriptTurnEnded, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
|
|
139
|
+
import { readHookToken } from './lib/claude-hooks-installer.js'
|
|
135
140
|
import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
|
|
136
141
|
import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
|
|
137
142
|
import { transcriptPathFor } from './lib/native-head.js'
|
|
@@ -386,12 +391,24 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
|
|
|
386
391
|
* Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
|
|
387
392
|
* RELAXATION before changing this line.
|
|
388
393
|
*/
|
|
389
|
-
const occupancyProbes = sessionHooksEnabled()
|
|
394
|
+
const occupancyProbes = withCursorComposerTurn(withCodexTurnClock(sessionHooksEnabled()
|
|
390
395
|
// 6.48.1, the B6 clause: with the hooks on, a foreign Desktop holder whose newest hook
|
|
391
396
|
// event is a Stop reads idle at once instead of after the 30 s transcript window. The
|
|
392
397
|
// wrapper only adds a probe; the gate's precedence is unchanged. Off is 6.48.0.
|
|
393
398
|
? withHookTurnClock(buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()), signalFor, registryIdleAfterStop)
|
|
394
|
-
: buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
|
|
399
|
+
: buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()),
|
|
400
|
+
// 6.51.0: the Codex counterpart, from the rollout's own turn markers. A queued COS turn
|
|
401
|
+
// on a Codex thread then drains at the engine's `task_complete` instead of 30 s later,
|
|
402
|
+
// and goes out through the app's own queue (`codex-live-queue.ts`).
|
|
403
|
+
// Off with `COS_CODEX_LIVE_QUEUE=0`: the clock only opens the gate because every write
|
|
404
|
+
// into a held thread goes through the app's queue, so without that hop it must not.
|
|
405
|
+
(threadId: string) => (codexLiveQueueEnabled() ? readCodexTurnEndedAtMs(threadId, nativeHeadDeps) : null)),
|
|
406
|
+
// 6.51.0: a Cursor composer its own hooks show mid-turn reads `native_thread_working`,
|
|
407
|
+
// so a turn can queue for its Stop hook. Without the hooks feature there is no signal
|
|
408
|
+
// and every composer keeps the 6.50 verdict.
|
|
409
|
+
(sessionId: string) => (sessionHooksEnabled() ? signalFor(sessionId) : undefined),
|
|
410
|
+
() => Date.now(),
|
|
411
|
+
OPEN_TURN_CEILING_MS)
|
|
395
412
|
|
|
396
413
|
// 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
|
|
397
414
|
// before any router is registered so the first list request already sees the replayed
|
|
@@ -690,6 +707,14 @@ if (threadAttachEnabled()) {
|
|
|
690
707
|
now: () => Date.now(),
|
|
691
708
|
}
|
|
692
709
|
app.use('/api', createThreadTurnQueueRouter(queueDeps))
|
|
710
|
+
// 6.51.0: a queued Cursor turn leaves ONLY through the composer's own Stop hook (the
|
|
711
|
+
// drainer skips Cursor, see `drainThread`). Hook-token auth, outside /api.
|
|
712
|
+
app.use(createCursorStopFollowupRouter({
|
|
713
|
+
hookToken: readHookToken,
|
|
714
|
+
readQueue,
|
|
715
|
+
writeQueue,
|
|
716
|
+
now: () => Date.now(),
|
|
717
|
+
}))
|
|
693
718
|
|
|
694
719
|
// Every 20s. Fast enough that a freed thread drains while the user is still looking
|
|
695
720
|
// at the pending row, slow enough to be nothing: the sweep does no work at all when
|
|
@@ -762,6 +787,10 @@ app.use('/api', createAgentSessionBindingsRouter({
|
|
|
762
787
|
// the resume child second. Reads `COS_CONTINUE_LIVE` per call, so the flag reports
|
|
763
788
|
// on /api/health without a restart and every refusal falls back to the 6.48.2 path.
|
|
764
789
|
deliverLiveTurn: makeLiveTurnDeliverer(nativeHeadDeps),
|
|
790
|
+
// 6.51.0: a Codex thread the Codex app holds goes into that app's own queue
|
|
791
|
+
// (`codex queue`), never into a resume child that cannot open a held thread.
|
|
792
|
+
// `COS_CODEX_LIVE_QUEUE=0` restores the 6.50 path.
|
|
793
|
+
deliverCodexLiveTurn: makeCodexLiveDeliverer(),
|
|
765
794
|
forkThread: forkThreadForRoute,
|
|
766
795
|
// The fork's real spawn directory. Separate from `resolveTarget` above, which
|
|
767
796
|
// deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The real world behind `codex-live-queue.ts`: the binary, the process, the counters.
|
|
2
|
+
//
|
|
3
|
+
// Kept apart from the pure module so the verdict rules there never touch a process.
|
|
4
|
+
// Nothing here writes a file; the only write is the one `codex queue` makes itself.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { resolveProviderBinary } from './provider-binary.js'
|
|
8
|
+
import {
|
|
9
|
+
codexLiveQueueEnabled,
|
|
10
|
+
deliverOverCodexQueue,
|
|
11
|
+
type CodexLiveResult,
|
|
12
|
+
type CodexQueueRun,
|
|
13
|
+
} from './codex-live-queue.js'
|
|
14
|
+
|
|
15
|
+
/** Output past this is not a confirmation line; keep memory bounded if the CLI misbehaves. */
|
|
16
|
+
const MAX_CAPTURE_BYTES = 64 * 1024
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run the CLI with no shell, a closed stdin and a hard timeout. Always resolves.
|
|
20
|
+
*
|
|
21
|
+
* The environment is the server's own, so `CODEX_HOME` (if an operator set one) is the
|
|
22
|
+
* same one occupancy read the writer lock from: the queue row lands in the database the
|
|
23
|
+
* holder is watching.
|
|
24
|
+
*/
|
|
25
|
+
export function runCodexQueueCli(binary: string, args: readonly string[], timeoutMs: number): Promise<CodexQueueRun> {
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
let stdout = ''
|
|
28
|
+
let stderr = ''
|
|
29
|
+
let timedOut = false
|
|
30
|
+
let settled = false
|
|
31
|
+
const finish = (run: CodexQueueRun) => {
|
|
32
|
+
if (settled) return
|
|
33
|
+
settled = true
|
|
34
|
+
resolve(run)
|
|
35
|
+
}
|
|
36
|
+
let child: ReturnType<typeof spawn>
|
|
37
|
+
try {
|
|
38
|
+
// Its own process group, so the ceiling can kill anything it started too: a
|
|
39
|
+
// grandchild holding stdout open would otherwise keep 'close' from ever firing
|
|
40
|
+
// (found by the hang test, which waited out the whole `sleep`).
|
|
41
|
+
child = spawn(binary, [...args], { stdio: ['ignore', 'pipe', 'pipe'], shell: false, detached: true })
|
|
42
|
+
} catch (error) {
|
|
43
|
+
finish({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: error instanceof Error ? error.message : 'spawn threw' })
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
timedOut = true
|
|
48
|
+
try { if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGKILL') } catch { /* group already gone */ }
|
|
49
|
+
try { child.kill('SIGKILL') } catch { /* already gone */ }
|
|
50
|
+
// Answer at the ceiling, not whenever the pipes finally close.
|
|
51
|
+
finish({ code: null, stdout, stderr, timedOut: true, spawnError: null })
|
|
52
|
+
}, Math.max(100, timeoutMs))
|
|
53
|
+
timer.unref?.()
|
|
54
|
+
child.stdout?.on('data', (chunk: Buffer) => { if (stdout.length < MAX_CAPTURE_BYTES) stdout += chunk.toString('utf-8') })
|
|
55
|
+
child.stderr?.on('data', (chunk: Buffer) => { if (stderr.length < MAX_CAPTURE_BYTES) stderr += chunk.toString('utf-8') })
|
|
56
|
+
child.on('error', error => {
|
|
57
|
+
clearTimeout(timer)
|
|
58
|
+
// ENOENT/EACCES arrive here before any byte ran: nothing was queued.
|
|
59
|
+
finish({ code: null, stdout, stderr, timedOut: false, spawnError: error.message })
|
|
60
|
+
})
|
|
61
|
+
child.on('close', code => {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
finish({ code: timedOut ? null : code, stdout, stderr, timedOut, spawnError: null })
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CodexLiveStats {
|
|
69
|
+
attempts: number
|
|
70
|
+
delivered: number
|
|
71
|
+
/** By reason, so the health row says WHY a Codex Continue did not go live. */
|
|
72
|
+
fallbacks: Record<string, number>
|
|
73
|
+
lastReason: string | null
|
|
74
|
+
lastAt: number | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stats: CodexLiveStats = { attempts: 0, delivered: 0, fallbacks: {}, lastReason: null, lastAt: null }
|
|
78
|
+
|
|
79
|
+
/** For `/api/health`. A copy, never the live object. */
|
|
80
|
+
export function codexLiveStats(): CodexLiveStats {
|
|
81
|
+
return { ...stats, fallbacks: { ...stats.fallbacks } }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Tests only. */
|
|
85
|
+
export function __resetCodexLiveStatsForTests(): void {
|
|
86
|
+
stats.attempts = 0
|
|
87
|
+
stats.delivered = 0
|
|
88
|
+
stats.fallbacks = {}
|
|
89
|
+
stats.lastReason = null
|
|
90
|
+
stats.lastAt = null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The route's Codex dep. `disabled` and `no_holder` are not counted as attempts: the
|
|
95
|
+
* first is the operator's choice and the second is the ordinary spawn path, so neither
|
|
96
|
+
* should make the health row look busy.
|
|
97
|
+
*/
|
|
98
|
+
export function makeCodexLiveDeliverer(deps?: {
|
|
99
|
+
run?: typeof runCodexQueueCli
|
|
100
|
+
resolveBinary?: () => string | null
|
|
101
|
+
enabled?: () => boolean
|
|
102
|
+
}) {
|
|
103
|
+
return async (request: { provider: string; sessionId: string; prompt: string; foreignHolder?: boolean }): Promise<CodexLiveResult> => {
|
|
104
|
+
const result = await deliverOverCodexQueue(
|
|
105
|
+
{
|
|
106
|
+
provider: request.provider,
|
|
107
|
+
sessionId: request.sessionId,
|
|
108
|
+
prompt: request.prompt,
|
|
109
|
+
enabled: (deps?.enabled ?? codexLiveQueueEnabled)(),
|
|
110
|
+
foreignHolder: request.foreignHolder === true,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
resolveBinary: deps?.resolveBinary ?? (() => {
|
|
114
|
+
const resolved = resolveProviderBinary('codex')
|
|
115
|
+
return resolved.ok ? resolved.path : null
|
|
116
|
+
}),
|
|
117
|
+
run: deps?.run ?? runCodexQueueCli,
|
|
118
|
+
now: () => Date.now(),
|
|
119
|
+
log: (level, event, detail) => {
|
|
120
|
+
const line = `[${event}] ${JSON.stringify(detail)}`
|
|
121
|
+
if (level === 'warn') console.warn(line)
|
|
122
|
+
else console.log(line)
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
if (result.reason !== 'disabled' && result.reason !== 'no_holder') {
|
|
127
|
+
stats.attempts += 1
|
|
128
|
+
if (result.ok) stats.delivered += 1
|
|
129
|
+
else stats.fallbacks[result.reason] = (stats.fallbacks[result.reason] ?? 0) + 1
|
|
130
|
+
stats.lastReason = result.reason
|
|
131
|
+
stats.lastAt = Date.now()
|
|
132
|
+
}
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Continue into a Codex thread the Codex app already has open, through Codex's own queue.
|
|
2
|
+
//
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// WHY THIS EXISTS (6.51.0)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// The attached path spawns `codex exec resume <id> -`. When ChatGPT.app (Codex Desktop)
|
|
7
|
+
// has the thread open, its app-server holds the thread's writer lock for as long as the
|
|
8
|
+
// thread is LOADED, idle included, and the child dies in about 0.2 s with "thread
|
|
9
|
+
// <id> already has an active writer" -- AFTER the adapter has written the prompt to its
|
|
10
|
+
// stdin, so the turn lands as `ambiguous` and fences the thread until an operator
|
|
11
|
+
// releases it. Production has two such fences on record (August, provider_exit_nonzero,
|
|
12
|
+
// exit 1, ~0.7 s). So for a thread the Codex app holds, today's Continue cannot work.
|
|
13
|
+
//
|
|
14
|
+
// Codex ships the fix itself. `codex queue --thread <id> --message <text>` inserts a row
|
|
15
|
+
// into `$CODEX_HOME/queue_1.sqlite`, and every app-server watches that database for writes
|
|
16
|
+
// from OTHER processes (`PRAGMA data_version` plus the `queued_thread_revisions` table).
|
|
17
|
+
// Canary, 2026-09-19, scratch CODEX_HOME, codex-cli 0.155.0-alpha.9.2, holder =
|
|
18
|
+
// `codex app-server` over stdio exactly as ChatGPT.app runs it:
|
|
19
|
+
//
|
|
20
|
+
// holder mid-turn queued in 0.05 s; the holder started it 10 ms after the running
|
|
21
|
+
// turn's `task_complete`, as an ordinary user turn
|
|
22
|
+
// holder idle the holder picked it up and started it 7.35 s later
|
|
23
|
+
// no holder the row waits in the queue until something opens the thread;
|
|
24
|
+
// no daemon is started
|
|
25
|
+
// writer lock held by the holder the whole time the thread is loaded, idle too
|
|
26
|
+
// exec resume, held exits 1 in 0.2 s: "already has an active writer"
|
|
27
|
+
// --message=-h queued the literal text "-h" (the `=` form is flag-safe)
|
|
28
|
+
// --message - queued a literal "-": the CLI does NOT read stdin
|
|
29
|
+
// unknown thread exit 1, "failed to queue session message: ... no rollout found",
|
|
30
|
+
// and nothing queued
|
|
31
|
+
//
|
|
32
|
+
// So this is the Codex counterpart of the Claude peer inbox (`session-peer-inbox.ts`),
|
|
33
|
+
// and a better one: the message is a real user turn in the app's own queue, visible and
|
|
34
|
+
// editable there, and delivered by the engine at exactly the end of the running turn.
|
|
35
|
+
//
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// THE ONE CONCESSION: THE PROMPT RIDES IN ARGV
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// The attached adapter keeps prompts off argv. `codex queue` has no stdin form (measured
|
|
40
|
+
// above), and the alternative -- a second `codex app-server` on the user's CODEX_HOME to
|
|
41
|
+
// send `thread/queue/add` over JSON-RPC -- starts a whole engine that can run automations
|
|
42
|
+
// and memory jobs on its own. The CLI lives about 50 ms, is spawned without a shell, and
|
|
43
|
+
// macOS shows a process's arguments only to its own user. The prompt never reaches a log
|
|
44
|
+
// line here: only lengths, reasons and the queued id do.
|
|
45
|
+
//
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// WHAT COUNTS AS DELIVERED
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Exit 0 AND the CLI's confirmation line naming OUR thread id. That line is printed from
|
|
50
|
+
// the insert's own RETURNING row, so it is positive evidence the row exists. Anything
|
|
51
|
+
// else that might have inserted -- exit 0 without the line, a timeout, a kill, a nonzero
|
|
52
|
+
// exit we cannot place -- is `unverified`, and the route fences the thread as an
|
|
53
|
+
// ambiguous delivery rather than retrying, because a retried queue write puts the same
|
|
54
|
+
// sentence into the thread twice. A clap usage error (exit 2) queued nothing.
|
|
55
|
+
|
|
56
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
57
|
+
|
|
58
|
+
export type CodexLiveReason =
|
|
59
|
+
/** The row is in the holder's queue. */
|
|
60
|
+
| 'delivered'
|
|
61
|
+
/** `COS_CODEX_LIVE_QUEUE=0`. Nothing sent. */
|
|
62
|
+
| 'disabled'
|
|
63
|
+
/** Not a Codex turn, or not a thread the app holds. Nothing sent; the spawn path is right. */
|
|
64
|
+
| 'no_holder'
|
|
65
|
+
| 'invalid_request'
|
|
66
|
+
/** No usable `codex` binary. Nothing sent. */
|
|
67
|
+
| 'binary_not_found'
|
|
68
|
+
/** The CLI never started. Nothing sent. */
|
|
69
|
+
| 'spawn_failed'
|
|
70
|
+
/** The CLI said it queued nothing ("failed to queue session message"). */
|
|
71
|
+
| 'refused'
|
|
72
|
+
/** Something may be queued. Hold; never retry, never spawn over it. */
|
|
73
|
+
| 'unverified'
|
|
74
|
+
|
|
75
|
+
export interface CodexLiveResult {
|
|
76
|
+
ok: boolean
|
|
77
|
+
reason: CodexLiveReason
|
|
78
|
+
verifiedBy: 'codex-queue' | null
|
|
79
|
+
/** Codex's own id for the queued submission, when delivered. */
|
|
80
|
+
queuedId: string | null
|
|
81
|
+
elapsedMs: number
|
|
82
|
+
/** Always null: the holder is the app-server, not a process this module talks to. */
|
|
83
|
+
pid: null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CodexQueueRun {
|
|
87
|
+
/** Null when the process was killed or never started. */
|
|
88
|
+
code: number | null
|
|
89
|
+
stdout: string
|
|
90
|
+
stderr: string
|
|
91
|
+
timedOut: boolean
|
|
92
|
+
/** Set when the process could not be started at all. */
|
|
93
|
+
spawnError: string | null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CodexLiveDeps {
|
|
97
|
+
/** An absolute, verified `codex` path, or null. */
|
|
98
|
+
resolveBinary: () => string | null
|
|
99
|
+
/** Run the CLI without a shell. MUST resolve, never reject. */
|
|
100
|
+
run: (binary: string, args: readonly string[], timeoutMs: number) => Promise<CodexQueueRun>
|
|
101
|
+
now: () => number
|
|
102
|
+
log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface CodexLiveRequest {
|
|
106
|
+
provider: string
|
|
107
|
+
sessionId: string
|
|
108
|
+
prompt: string
|
|
109
|
+
/** The flag read at the call site, so this module never reads the environment. */
|
|
110
|
+
enabled: boolean
|
|
111
|
+
/**
|
|
112
|
+
* The route's own occupancy verdict: a live process that is not ours holds this
|
|
113
|
+
* thread's writer lock. Without a holder the queue row would wait until somebody opens
|
|
114
|
+
* the thread, which is not a Continue; the spawn path is the right answer there.
|
|
115
|
+
*/
|
|
116
|
+
foreignHolder: boolean
|
|
117
|
+
timeoutMs?: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Measured 50-70 ms. The ceiling sits inside the phone's 10 s turn budget with room for the gate. */
|
|
121
|
+
export const CODEX_QUEUE_TIMEOUT_MS = 5_000
|
|
122
|
+
|
|
123
|
+
/** Codex 0.155's confirmation line, printed from the insert's RETURNING row. */
|
|
124
|
+
const CONFIRMATION = /^Queued message (\S+) for thread (\S+)\.\s*$/m
|
|
125
|
+
|
|
126
|
+
/** The CLI's own "nothing was queued" prefix (the add failed before or at the insert). */
|
|
127
|
+
const NOTHING_QUEUED = /failed to queue session message/i
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* clap's argument errors (exit 2): a `codex` without the `queue` subcommand, or one that
|
|
131
|
+
* renamed a flag. The parser refuses before any command runs, so nothing was queued.
|
|
132
|
+
*/
|
|
133
|
+
const USAGE_ERROR = /^error: (unrecognized subcommand|unexpected argument|invalid value|the following required arguments)/m
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The argv, in the `--flag=value` form on purpose: clap reads `--message -h` as a flag
|
|
137
|
+
* and `--message=-h` as text (measured). The thread id is validated by the caller, and
|
|
138
|
+
* this refuses anything that is not a native thread id anyway, because it is about to
|
|
139
|
+
* become a process argument.
|
|
140
|
+
*/
|
|
141
|
+
export function buildCodexQueueArgs(threadId: string, prompt: string): string[] {
|
|
142
|
+
if (!isValidNativeThreadId(threadId)) throw new Error('buildCodexQueueArgs: invalid thread id')
|
|
143
|
+
if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.includes('\0')) {
|
|
144
|
+
throw new Error('buildCodexQueueArgs: unusable prompt')
|
|
145
|
+
}
|
|
146
|
+
return ['queue', `--thread=${threadId}`, `--message=${prompt}`]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Codex's queued-submission id when the line names OUR thread, else null. */
|
|
150
|
+
export function parseCodexQueueConfirmation(stdout: string, threadId: string): string | null {
|
|
151
|
+
const match = CONFIRMATION.exec(typeof stdout === 'string' ? stdout : '')
|
|
152
|
+
if (!match) return null
|
|
153
|
+
return match[2]!.toLowerCase() === threadId.toLowerCase() ? match[1]! : null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One CLI run to a verdict. Pure, and total: every shape lands on a reason, and every
|
|
158
|
+
* shape that could have inserted a row without saying so lands on `unverified`.
|
|
159
|
+
*/
|
|
160
|
+
export function classifyCodexQueueRun(run: CodexQueueRun, threadId: string): { reason: CodexLiveReason; queuedId: string | null } {
|
|
161
|
+
if (run.spawnError !== null) return { reason: 'spawn_failed', queuedId: null }
|
|
162
|
+
if (run.timedOut || run.code === null) return { reason: 'unverified', queuedId: null }
|
|
163
|
+
if (run.code === 0) {
|
|
164
|
+
const queuedId = parseCodexQueueConfirmation(run.stdout, threadId)
|
|
165
|
+
return queuedId ? { reason: 'delivered', queuedId } : { reason: 'unverified', queuedId: null }
|
|
166
|
+
}
|
|
167
|
+
if (NOTHING_QUEUED.test(run.stderr)) return { reason: 'refused', queuedId: null }
|
|
168
|
+
if (run.code === 2 && USAGE_ERROR.test(run.stderr)) return { reason: 'refused', queuedId: null }
|
|
169
|
+
return { reason: 'unverified', queuedId: null }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Put the turn into the Codex app's own queue for this thread, and say honestly what happened. */
|
|
173
|
+
export async function deliverOverCodexQueue(request: CodexLiveRequest, deps: CodexLiveDeps): Promise<CodexLiveResult> {
|
|
174
|
+
const started = deps.now()
|
|
175
|
+
const done = (reason: CodexLiveReason, queuedId: string | null = null): CodexLiveResult => ({
|
|
176
|
+
ok: reason === 'delivered',
|
|
177
|
+
reason,
|
|
178
|
+
verifiedBy: reason === 'delivered' ? 'codex-queue' : null,
|
|
179
|
+
queuedId,
|
|
180
|
+
elapsedMs: Math.max(0, deps.now() - started),
|
|
181
|
+
pid: null,
|
|
182
|
+
})
|
|
183
|
+
if (!request.enabled) return done('disabled')
|
|
184
|
+
if (request.provider !== 'codex' || request.foreignHolder !== true) return done('no_holder')
|
|
185
|
+
let args: string[]
|
|
186
|
+
try {
|
|
187
|
+
args = buildCodexQueueArgs(request.sessionId, request.prompt)
|
|
188
|
+
} catch {
|
|
189
|
+
return done('invalid_request')
|
|
190
|
+
}
|
|
191
|
+
let binary: string | null
|
|
192
|
+
try {
|
|
193
|
+
binary = deps.resolveBinary()
|
|
194
|
+
} catch {
|
|
195
|
+
binary = null
|
|
196
|
+
}
|
|
197
|
+
if (!binary) return done('binary_not_found')
|
|
198
|
+
|
|
199
|
+
let run: CodexQueueRun
|
|
200
|
+
try {
|
|
201
|
+
run = await deps.run(binary, args, request.timeoutMs ?? CODEX_QUEUE_TIMEOUT_MS)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// `run` must not reject; if it does we cannot say whether the CLI got as far as the
|
|
204
|
+
// insert, so this is the ambiguous answer, never the "nothing sent" one.
|
|
205
|
+
deps.log?.('warn', 'codex-live-queue', { outcome: 'run_threw', detail: error instanceof Error ? error.message.slice(0, 120) : 'unknown' })
|
|
206
|
+
return done('unverified')
|
|
207
|
+
}
|
|
208
|
+
const verdict = classifyCodexQueueRun(run, request.sessionId)
|
|
209
|
+
deps.log?.(verdict.reason === 'delivered' ? 'info' : 'warn', 'codex-live-queue', {
|
|
210
|
+
outcome: verdict.reason,
|
|
211
|
+
code: run.code,
|
|
212
|
+
timedOut: run.timedOut,
|
|
213
|
+
promptChars: request.prompt.length,
|
|
214
|
+
queuedId: verdict.queuedId,
|
|
215
|
+
// The CLI's own first stderr line names the failure; it cannot carry the prompt,
|
|
216
|
+
// which only ever travels in argv. Bounded anyway.
|
|
217
|
+
stderr: verdict.reason === 'delivered' ? undefined : run.stderr.split('\n').find(l => l.trim().length > 0)?.slice(0, 160),
|
|
218
|
+
})
|
|
219
|
+
return done(verdict.reason, verdict.queuedId)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `COS_CODEX_LIVE_QUEUE`. ON unless set to `0`.
|
|
224
|
+
*
|
|
225
|
+
* Default-on, unlike `COS_CONTINUE_LIVE`, because the path it replaces cannot work: for a
|
|
226
|
+
* thread the Codex app holds, the spawn fails every time and leaves a fence. Only a
|
|
227
|
+
* thread with a foreign writer ever reaches this module, so a thread nobody holds keeps
|
|
228
|
+
* the 6.50 path exactly.
|
|
229
|
+
*/
|
|
230
|
+
export function codexLiveQueueEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
231
|
+
return env.COS_CODEX_LIVE_QUEUE !== '0'
|
|
232
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// When did the Codex engine close this thread's turn? (6.51.0)
|
|
2
|
+
//
|
|
3
|
+
// Read from the rollout itself, never from its mtime: the engine writes `task_complete`
|
|
4
|
+
// (or `turn_complete`) as the turn's last record and `task_started` as the next turn's
|
|
5
|
+
// first, so the NEWEST of those markers in the tail is the answer. The tail rule is the
|
|
6
|
+
// drain's own (`transcriptTurnVerdict`), so the gate and the drain cannot disagree about
|
|
7
|
+
// the same file.
|
|
8
|
+
|
|
9
|
+
import { statSync } from 'node:fs'
|
|
10
|
+
import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
|
|
11
|
+
import { transcriptTurnVerdict } from './thread-turn-queue-store.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The rollout's mtime when its newest turn marker closed a turn; null on any doubt (no
|
|
15
|
+
* rollout, an unreadable tail, a tail with no marker, or a turn that is open).
|
|
16
|
+
*/
|
|
17
|
+
export function readCodexTurnEndedAtMs(
|
|
18
|
+
threadId: string,
|
|
19
|
+
nativeHeadDeps: NativeHeadDeps,
|
|
20
|
+
deps: {
|
|
21
|
+
pathFor?: typeof transcriptPathFor
|
|
22
|
+
verdict?: typeof transcriptTurnVerdict
|
|
23
|
+
mtimeMs?: (path: string) => number | null
|
|
24
|
+
} = {},
|
|
25
|
+
): number | null {
|
|
26
|
+
try {
|
|
27
|
+
const path = (deps.pathFor ?? transcriptPathFor)('codex', threadId, nativeHeadDeps)
|
|
28
|
+
if (!path) return null
|
|
29
|
+
const verdict = (deps.verdict ?? transcriptTurnVerdict)('codex', path)
|
|
30
|
+
if (!verdict || verdict.ended !== true || verdict.reason !== 'codex_complete') return null
|
|
31
|
+
const mtime = (deps.mtimeMs ?? ((p: string) => statSync(p).mtimeMs))(path)
|
|
32
|
+
return typeof mtime === 'number' && Number.isFinite(mtime) ? mtime : null
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// A queued turn for a Cursor chat, handed to Cursor by its own Stop hook (6.51.0).
|
|
2
|
+
//
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// WHY THE STOP HOOK, AND ONLY THE STOP HOOK
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Cursor has no `queue --thread` and no socket a process outside it can write into. A
|
|
7
|
+
// Cursor IDE composer cannot be resumed from outside either: Gate 0 (2026-08-20) found
|
|
8
|
+
// `--resume` on an IDE transcript to be a clobber / wrong-store landmine, and that stays
|
|
9
|
+
// refused. But Cursor 3.21 runs a `stop` hook when a composer's turn ends, and when the
|
|
10
|
+
// hook answers `{"followup_message": "..."}` Cursor submits that text into THE SAME
|
|
11
|
+
// composer as the next message (`submitChatMaybeAbortCurrent(composerId, text,
|
|
12
|
+
// {isAutoFollowupFromStopHook: true})`, read from the shipped workbench). That is the
|
|
13
|
+
// exact shape of "deliver when the current turn ends", from inside the composer.
|
|
14
|
+
//
|
|
15
|
+
// And the hook is already there: Cursor reads `~/.claude/settings.json` hooks by default
|
|
16
|
+
// (its third-party extensibility setting), so the COS session hook installed on 9/15 has
|
|
17
|
+
// been running for Cursor composers since then -- 478 Cursor events in the ledger by
|
|
18
|
+
// 9/18, stdin = Cursor's own payload (`conversation_id`, `status`, `loop_count`,
|
|
19
|
+
// `cursor_version`), environment `CURSOR_VERSION`. Nothing new is installed for Cursor;
|
|
20
|
+
// the one script gains one Cursor-only branch.
|
|
21
|
+
//
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// WHAT THIS CANNOT DO, SAID PLAINLY
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// It delivers only at the END OF A TURN. An idle composer fires no Stop, so a turn cannot
|
|
26
|
+
// be put into it from outside; the queue admits a Cursor turn only while the hooks show
|
|
27
|
+
// that composer mid-turn (`native_thread_working`), and a turn that is never claimed
|
|
28
|
+
// expires on the queue's ordinary clock and says so. Cursor also caps consecutive hook
|
|
29
|
+
// follow-ups (its default loop limit, reset by any message typed in Cursor), so a long
|
|
30
|
+
// queue can stop short; the rest waits for the next typed message and its Stop.
|
|
31
|
+
//
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// ONE DELIVERER
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// The queue drainer never delivers a Cursor turn (see `drainThread`): a turn could
|
|
36
|
+
// otherwise be claimed here and delivered there in the same second. Claiming marks the
|
|
37
|
+
// row `delivered` before the text leaves, and a Stop that is not `completed` (aborted,
|
|
38
|
+
// error) claims nothing -- a queued sentence must not follow a turn the user just
|
|
39
|
+
// stopped.
|
|
40
|
+
|
|
41
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
42
|
+
import type { QueuedThreadTurn } from './thread-turn-queue.js'
|
|
43
|
+
|
|
44
|
+
export interface CursorStopFacts {
|
|
45
|
+
conversationId: string
|
|
46
|
+
status: string
|
|
47
|
+
loopCount: number | null
|
|
48
|
+
cursorVersion: string
|
|
49
|
+
/** The envelope's `ts`: when the hook started, epoch ms from the same Mac clock. */
|
|
50
|
+
hookStartedAtMs: number | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The hook's curl gives up this long after it starts (`--max-time 3`). */
|
|
54
|
+
export const CURSOR_HOOK_WAIT_MS = 3_000
|
|
55
|
+
/**
|
|
56
|
+
* A claim is made only this soon after the hook started, so its reply is written with at
|
|
57
|
+
* least a second to spare before the hook stops listening. Past it the turn stays
|
|
58
|
+
* `waiting` for the next Stop.
|
|
59
|
+
*/
|
|
60
|
+
export const CURSOR_STOP_MAX_AGE_MS = 2_000
|
|
61
|
+
/** A stamp this far in the future is a clock that moved, not a hook; no claim. */
|
|
62
|
+
export const CURSOR_STOP_MAX_SKEW_MS = 5_000
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Is the hook that sent this still listening? A socket check cannot answer that (QA,
|
|
66
|
+
* 6.51.0): when the event loop is blocked past the hook's 3 s, curl gives up and closes,
|
|
67
|
+
* but Node reads the request bytes before the close, so the handler sees an open socket,
|
|
68
|
+
* claims the turn, and writes the reply to nobody -- the row reads `delivered` and Cursor
|
|
69
|
+
* never got it. The hook's own start stamp can answer it: everything from this check to
|
|
70
|
+
* the reply is synchronous, so a claim made within 2 s of the hook starting is answered
|
|
71
|
+
* before the hook's 3 s are up.
|
|
72
|
+
*/
|
|
73
|
+
export function cursorStopIsFresh(hookStartedAtMs: number | null, now: number, maxAgeMs = CURSOR_STOP_MAX_AGE_MS): boolean {
|
|
74
|
+
if (hookStartedAtMs === null || !Number.isFinite(hookStartedAtMs) || !Number.isFinite(now)) return false
|
|
75
|
+
const age = now - hookStartedAtMs
|
|
76
|
+
return age <= maxAgeMs && age >= -CURSOR_STOP_MAX_SKEW_MS
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The spool envelope the hook posts (`{"ts","ppid","event","payload"}`), read strictly.
|
|
81
|
+
* Null unless it is a Stop from Cursor naming a valid conversation.
|
|
82
|
+
*/
|
|
83
|
+
export function parseCursorStopEnvelope(body: unknown): CursorStopFacts | null {
|
|
84
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return null
|
|
85
|
+
const envelope = body as Record<string, unknown>
|
|
86
|
+
if (envelope.event !== 'Stop') return null
|
|
87
|
+
const payload = envelope.payload
|
|
88
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
89
|
+
const p = payload as Record<string, unknown>
|
|
90
|
+
// Cursor stamps every hook payload with its version. Claude Code never does, so this
|
|
91
|
+
// is the positive evidence the event came from Cursor and not a Claude Stop.
|
|
92
|
+
if (typeof p.cursor_version !== 'string' || p.cursor_version.trim().length === 0) return null
|
|
93
|
+
const raw = typeof p.conversation_id === 'string' ? p.conversation_id : typeof p.session_id === 'string' ? p.session_id : ''
|
|
94
|
+
const conversationId = raw.trim().toLowerCase()
|
|
95
|
+
if (!isValidNativeThreadId(conversationId)) return null
|
|
96
|
+
const status = typeof p.status === 'string' ? p.status : ''
|
|
97
|
+
const loopCount = typeof p.loop_count === 'number' && Number.isFinite(p.loop_count) ? p.loop_count : null
|
|
98
|
+
const hookStartedAtMs = typeof envelope.ts === 'number' && Number.isFinite(envelope.ts) ? envelope.ts : null
|
|
99
|
+
return { conversationId, status, loopCount, cursorVersion: p.cursor_version.trim(), hookStartedAtMs }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The next waiting turn, claimed. Pure: returns the new queue and the claimed turn, or
|
|
104
|
+
* null when there is nothing to hand over. Queue order, oldest first.
|
|
105
|
+
*/
|
|
106
|
+
export function claimNextCursorTurn(
|
|
107
|
+
queue: readonly QueuedThreadTurn[],
|
|
108
|
+
now: number,
|
|
109
|
+
): { queue: QueuedThreadTurn[]; turn: QueuedThreadTurn } | null {
|
|
110
|
+
const index = queue.findIndex(t => t.status === 'waiting')
|
|
111
|
+
if (index < 0) return null
|
|
112
|
+
const turn: QueuedThreadTurn = {
|
|
113
|
+
...queue[index]!,
|
|
114
|
+
status: 'delivered',
|
|
115
|
+
attempts: queue[index]!.attempts + 1,
|
|
116
|
+
reason: 'cursor_stop_hook',
|
|
117
|
+
settledAt: now,
|
|
118
|
+
}
|
|
119
|
+
const next = queue.slice()
|
|
120
|
+
next[index] = turn
|
|
121
|
+
return { queue: next, turn }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Should this Stop receive a queued turn at all? Only a turn that COMPLETED: Cursor
|
|
126
|
+
* reports `aborted` when the user stops it and `error` when it failed, and neither is a
|
|
127
|
+
* moment to add a sentence. A loop count past the queue's own size means something other
|
|
128
|
+
* than this queue is driving the composer.
|
|
129
|
+
*/
|
|
130
|
+
export function cursorStopAcceptsFollowup(facts: CursorStopFacts, maxLoop: number): boolean {
|
|
131
|
+
if (facts.status !== 'completed') return false
|
|
132
|
+
if (facts.loopCount !== null && facts.loopCount >= maxLoop) return false
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
@@ -834,6 +834,58 @@ export function withHookTurnClock(
|
|
|
834
834
|
}
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
+
/**
|
|
838
|
+
* Add the Codex turn clock (6.51.0) to a probe set, keeping whatever the set already
|
|
839
|
+
* answers for other providers.
|
|
840
|
+
*
|
|
841
|
+
* `codexTurnEndedAtMs` reads the thread's rollout tail and answers a time only when the
|
|
842
|
+
* newest turn marker CLOSED a turn (`task_complete`/`turn_complete`); a `task_started`
|
|
843
|
+
* after it, an unreadable file, or a tail with no marker all answer null, which keeps
|
|
844
|
+
* the 30 s transcript window. Measured on codex-cli 0.155: the app starts a turn queued
|
|
845
|
+
* in its own queue 10 ms after the `task_complete`, and that `task_started` is what the
|
|
846
|
+
* next read sees.
|
|
847
|
+
*/
|
|
848
|
+
export function withCodexTurnClock(
|
|
849
|
+
probes: OccupancyProbes,
|
|
850
|
+
codexTurnEndedAtMs: (threadId: string) => number | null,
|
|
851
|
+
): OccupancyProbes {
|
|
852
|
+
const inner = probes.holderTurnEndedAtMs
|
|
853
|
+
return {
|
|
854
|
+
...probes,
|
|
855
|
+
holderTurnEndedAtMs: (provider, threadId) => {
|
|
856
|
+
if (provider === 'codex') {
|
|
857
|
+
if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
|
|
858
|
+
return codexTurnEndedAtMs(threadId)
|
|
859
|
+
}
|
|
860
|
+
return typeof inner === 'function' ? inner(provider, threadId) : null
|
|
861
|
+
},
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Add the Cursor composer turn signal (6.51.0). `signalFor` is the hook signal store's
|
|
867
|
+
* reader; Cursor's hook events land there under the composer's conversation id. Open
|
|
868
|
+
* means: a turn was opened (prompt or tool) and no Stop has closed it, nothing ended the
|
|
869
|
+
* session, and the newest event is inside the open-turn ceiling -- a composer that died
|
|
870
|
+
* mid-turn must not read working for ever.
|
|
871
|
+
*/
|
|
872
|
+
export function withCursorComposerTurn(
|
|
873
|
+
probes: OccupancyProbes,
|
|
874
|
+
signalFor: (sessionId: string) => { turnOpen: boolean; ended: { at: number; reason: string } | null; lastEventAt: number } | undefined,
|
|
875
|
+
now: () => number,
|
|
876
|
+
ceilingMs: number,
|
|
877
|
+
): OccupancyProbes {
|
|
878
|
+
return {
|
|
879
|
+
...probes,
|
|
880
|
+
cursorComposerTurnOpen: (threadId: string) => {
|
|
881
|
+
if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
|
|
882
|
+
const signal = signalFor(threadId)
|
|
883
|
+
if (!signal) return null
|
|
884
|
+
return signal.turnOpen === true && signal.ended === null && now() - signal.lastEventAt <= ceilingMs
|
|
885
|
+
},
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
837
889
|
/** The facts the hook turn clock reads off a session signal. */
|
|
838
890
|
export interface HookTurnSignal {
|
|
839
891
|
lastEvent: string
|
|
@@ -627,7 +627,9 @@ function draftsFromCodexRecord(record: Record<string, unknown>): SessionStreamDr
|
|
|
627
627
|
function draftsFromCodexEvent(payload: Record<string, unknown>): SessionStreamDraft[] {
|
|
628
628
|
const kind = typeof payload.type === 'string' ? payload.type : ''
|
|
629
629
|
if (kind === 'task_started') return [{ kind: 'status', state: 'working' }]
|
|
630
|
-
|
|
630
|
+
// 6.51.0: an interrupted turn is over too (`turn_aborted`). Without it a follow-up parked
|
|
631
|
+
// behind an aborted Codex turn waited out the 30 min open-turn ceiling.
|
|
632
|
+
if (kind === 'task_complete' || kind === 'turn_complete' || kind === 'turn_aborted') return [{ kind: 'status', state: 'done' }]
|
|
631
633
|
if (kind === 'agent_message') {
|
|
632
634
|
const prose = proseDraft(payload.message ?? payload.text)
|
|
633
635
|
return prose ? [prose] : []
|
|
@@ -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
|
|
@@ -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),
|
|
@@ -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 })
|