@gotcos/glasses-server 6.50.3 → 6.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/bin/hooks/cos-session-hook +24 -1
- package/package.json +1 -1
- package/server/index.ts +34 -4
- package/server/lib/client-instance-claim.ts +90 -3
- package/server/lib/codex-live-queue-deps.ts +135 -0
- package/server/lib/codex-live-queue.ts +232 -0
- package/server/lib/codex-turn-clock.ts +36 -0
- package/server/lib/cursor-stop-followup.ts +134 -0
- package/server/lib/occupancy-probes.ts +52 -0
- package/server/lib/session-stream-events.ts +3 -1
- package/server/lib/thread-occupancy.ts +30 -7
- package/server/routes/agent-session-bindings.ts +98 -6
- package/server/routes/agent-sessions.ts +12 -1
- package/server/routes/client-instance.ts +57 -9
- package/server/routes/cursor-stop-followup.ts +67 -0
- package/server/routes/health.ts +14 -0
- package/server/routes/thread-turn-queue.ts +15 -5
|
@@ -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
|
}
|