@gotcos/glasses-server 6.50.4 → 6.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +16 -0
- package/CHANGELOG.md +78 -0
- package/README.md +56 -3
- package/bin/hooks/cos-session-hook +24 -1
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +83 -5
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/codex-live-queue-deps.ts +135 -0
- package/server/lib/codex-live-queue.ts +232 -0
- package/server/lib/codex-turn-clock.ts +36 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/cursor-stop-followup.ts +134 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/occupancy-probes.ts +52 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +600 -15
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/lib/thread-occupancy.ts +30 -7
- package/server/routes/agent-session-bindings.ts +98 -6
- package/server/routes/agent-sessions.ts +24 -2
- package/server/routes/cursor-stop-followup.ts +74 -0
- package/server/routes/health.ts +34 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
- package/server/routes/thread-turn-queue.ts +15 -5
|
@@ -11,7 +11,7 @@ import { homedir } from 'node:os'
|
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import { createInterface } from 'node:readline'
|
|
13
13
|
import { promisify } from 'node:util'
|
|
14
|
-
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
14
|
+
import { codexUserAsk, unwrapPeerMessage } from './session-stream-events.js'
|
|
15
15
|
|
|
16
16
|
const execFileAsync = promisify(execFile)
|
|
17
17
|
|
|
@@ -484,6 +484,30 @@ export function payloadText(payload: Record<string, unknown>): string | null {
|
|
|
484
484
|
return joined || null
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
/**
|
|
488
|
+
* What the person typed, out of one Codex `response_item/message` user row, or null
|
|
489
|
+
* (6.52.0).
|
|
490
|
+
*
|
|
491
|
+
* `payloadText` joins every block, so the AGENTS.md block Codex writes as a user message
|
|
492
|
+
* ("# AGENTS.md instructions for /Users/…", the FIRST user row in 469 rollouts on this
|
|
493
|
+
* Mac) became the session's title, first prompt and opening DISCUSSION line, and typed
|
|
494
|
+
* words after an `<image>` block were dropped with the wrapper. `codexUserAsk` reads the
|
|
495
|
+
* row block by block; the stream's `prompt` line uses the same function, so the two
|
|
496
|
+
* cannot disagree about what was asked.
|
|
497
|
+
*/
|
|
498
|
+
export function codexUserText(payload: Record<string, unknown>): string | null {
|
|
499
|
+
const content = payload.content
|
|
500
|
+
if (typeof content === 'string') return codexUserAsk([content])
|
|
501
|
+
if (!Array.isArray(content)) return null
|
|
502
|
+
return codexUserAsk(content.map(block => {
|
|
503
|
+
if (!block || typeof block !== 'object') return null
|
|
504
|
+
const type = String((block as { type?: unknown }).type ?? '')
|
|
505
|
+
if (type !== 'text' && type !== 'input_text') return null
|
|
506
|
+
const text = (block as { text?: unknown }).text
|
|
507
|
+
return typeof text === 'string' ? text : null
|
|
508
|
+
}))
|
|
509
|
+
}
|
|
510
|
+
|
|
487
511
|
export function isoFromMtime(mtimeMs: number): string {
|
|
488
512
|
return new Date(mtimeMs).toISOString()
|
|
489
513
|
}
|
|
@@ -912,7 +936,7 @@ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: st
|
|
|
912
936
|
if (!title && obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
|
|
913
937
|
const payload = obj.payload as Record<string, unknown>
|
|
914
938
|
if (payload.type === 'message' && payload.role === 'user') {
|
|
915
|
-
const textBody =
|
|
939
|
+
const textBody = codexUserText(payload)
|
|
916
940
|
if (textBody && !isWrapperPrompt(textBody)) title = firstLineTitle(textBody)
|
|
917
941
|
}
|
|
918
942
|
}
|
|
@@ -1624,12 +1648,15 @@ export interface AgentSessionDetail {
|
|
|
1624
1648
|
export async function parseAgentSession(
|
|
1625
1649
|
provider: AgentProvider,
|
|
1626
1650
|
path: string,
|
|
1627
|
-
opts: { maxBytes?: number; headBytes?: number; tailBytes?: number } = {},
|
|
1651
|
+
opts: { maxBytes?: number; headBytes?: number; tailBytes?: number; codexThreadName?: string } = {},
|
|
1628
1652
|
): Promise<AgentSessionDetail> {
|
|
1629
1653
|
const st = await stat(path)
|
|
1630
1654
|
const maxBytes = opts.maxBytes ?? AGENT_SESSION_MAX_FILE_BYTES
|
|
1631
1655
|
const truncated = st.size > maxBytes
|
|
1632
|
-
|
|
1656
|
+
// 6.52.0: a Codex thread's own name (`session_index.jsonl`, what the Codex app shows)
|
|
1657
|
+
// titles the detail the way it already titles the list row. The first ask is still
|
|
1658
|
+
// read below and published as `first_prompt`.
|
|
1659
|
+
let title = provider === 'codex' ? (opts.codexThreadName ?? '').trim() : ''
|
|
1633
1660
|
let project = ''
|
|
1634
1661
|
let gitBranch = ''
|
|
1635
1662
|
let sessionId = path.split('/').pop()?.replace(/\.jsonl$/, '') || ''
|
|
@@ -1771,7 +1798,10 @@ export async function parseAgentSession(
|
|
|
1771
1798
|
continue
|
|
1772
1799
|
}
|
|
1773
1800
|
if (kind !== 'message' || payload.role === 'developer') continue
|
|
1774
|
-
|
|
1801
|
+
// 6.52.0: a user row is read block by block (`codexUserText`), so the AGENTS.md
|
|
1802
|
+
// block Codex writes as a user message is not the title, the first prompt or a
|
|
1803
|
+
// DISCUSSION turn. The assistant path is unchanged.
|
|
1804
|
+
const text = payload.role === 'assistant' ? payloadText(payload) : codexUserText(payload)
|
|
1775
1805
|
if (!text || isWrapperPrompt(text)) continue
|
|
1776
1806
|
if (payload.role === 'assistant') {
|
|
1777
1807
|
assistantCount += 1
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { open, stat } from 'node:fs/promises'
|
|
28
28
|
import {
|
|
29
|
+
codexUserText,
|
|
29
30
|
latestAssistantReply,
|
|
30
31
|
isWrapperPrompt,
|
|
31
32
|
parseJsonLine,
|
|
@@ -112,7 +113,9 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
|
|
|
112
113
|
const payload = obj.payload as Record<string, unknown>
|
|
113
114
|
if (String(payload.type ?? '') !== 'message') return null
|
|
114
115
|
if (payload.role !== 'user' && payload.role !== 'assistant') return null
|
|
115
|
-
|
|
116
|
+
// 6.52.0: a user row block by block, so typed words after an `<image>` block are kept
|
|
117
|
+
// and an attached-file message reads as the request that was typed.
|
|
118
|
+
const text = payload.role === 'user' ? codexUserText(payload) : payloadText(payload)
|
|
116
119
|
if (!text) return null
|
|
117
120
|
return shaped(payload.role, text, iso(obj.timestamp))
|
|
118
121
|
}
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
60
60
|
import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
|
|
61
61
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
62
|
+
import { teeJobTrail, type JobTrailDraft } from './job-trail.js'
|
|
62
63
|
|
|
63
64
|
// Inactivity = no stdout data for this long → kill (catches stalls)
|
|
64
65
|
const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
|
|
@@ -336,6 +337,9 @@ export interface StreamCallbacks {
|
|
|
336
337
|
onActivityLine?: (line: ActivityPreviewLine) => void
|
|
337
338
|
onStart?: (model: ModelPreference, sessionId: string, cliSessionId?: string, metadata?: ModelRunMetadata) => void
|
|
338
339
|
onProviderProcess?: (metadata: ProviderProcessMetadata) => boolean | void | Promise<boolean | void>
|
|
340
|
+
/** 6.52.0: readable steps of a durable Messages job (lib/job-trail.ts). Optional, and
|
|
341
|
+
* absent on every path that is not a durable job with the trail on. */
|
|
342
|
+
onTrail?: (draft: JobTrailDraft) => void
|
|
339
343
|
}
|
|
340
344
|
|
|
341
345
|
/** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
|
|
@@ -964,6 +968,11 @@ export async function callClaudeStreaming(
|
|
|
964
968
|
}
|
|
965
969
|
})
|
|
966
970
|
|
|
971
|
+
// 6.52.0: the Messages trail, a SECOND stdout reader registered after the one above, so
|
|
972
|
+
// that handler sees every chunk first and nothing it decides can change. Without an
|
|
973
|
+
// `onTrail` (legacy /api/query, openai-compat, COS_MESSAGES_TRAIL=0) this attaches nothing.
|
|
974
|
+
teeJobTrail(proc.stdout, 'claude', callbacks.onTrail)
|
|
975
|
+
|
|
967
976
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
968
977
|
// stderr activity also counts — Claude CLI logs progress there
|
|
969
978
|
resetInactivity()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// When a client that can ANSWER a session question last asked for them (6.52.0).
|
|
2
|
+
//
|
|
3
|
+
// THE SIGNAL, AND WHY THIS ONE. The permission broker holds a session question or a tool
|
|
4
|
+
// approval only while a lens or phone client could show it and send the answer back. The
|
|
5
|
+
// only proof of that is a client asking for the questions: an authenticated
|
|
6
|
+
// `GET /api/session-questions?client=glasses|phone` (X-Cos-Token, never the hook token)
|
|
7
|
+
// within the last 30 s, checked at admission and again while anything is held.
|
|
8
|
+
//
|
|
9
|
+
// Rejected, and why:
|
|
10
|
+
// - the client-instance claim (`POST /api/client-instance/claim`): every COS Glasses copy
|
|
11
|
+
// since 6.9.505 posts it every 15 s, including the ones with no question UI. Counting
|
|
12
|
+
// it made a 6.9.511 app on the phone read as "someone can answer", so the Mac held
|
|
13
|
+
// questions and approvals nobody could see for up to 110 s (coordinator review,
|
|
14
|
+
// 2026-09-19). A claim says a copy is alive, not that it can answer.
|
|
15
|
+
// - the display stream: an open socket is not alive (a half-open connection, a
|
|
16
|
+
// `probe=1` connect, COS Control and curl all read the same).
|
|
17
|
+
// - client diagnostics and meeting chunks: lossy, or only while recording.
|
|
18
|
+
//
|
|
19
|
+
// So an app that has never heard of this feature never calls the route, and the broker
|
|
20
|
+
// answers `{}` (the Mac's own dialog) for it, exactly as before. The poll also bounds
|
|
21
|
+
// delivery: a copy whose timers are frozen (a locked phone) cannot poll, and could not
|
|
22
|
+
// show the question either.
|
|
23
|
+
//
|
|
24
|
+
// In memory: a restart forgets it, and the next poll restores it. Until then nothing is
|
|
25
|
+
// held, which is the native dialog, the safe side.
|
|
26
|
+
|
|
27
|
+
let lastPollAt: number | null = null
|
|
28
|
+
|
|
29
|
+
/** A stored stamp this far ahead of a new poll means the clock stepped back. */
|
|
30
|
+
const CLOCK_STEP_BACK_MS = 5_000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A counting questions poll answered at `at` (server clock). Never moves backwards for a
|
|
34
|
+
* slower request answered later, but a clock that stepped back is followed: a stamp left in
|
|
35
|
+
* the future would read stale to the broker, and ignoring every poll until the clock caught
|
|
36
|
+
* up would leave no live client for that long.
|
|
37
|
+
*/
|
|
38
|
+
export function noteQuestionsPoll(at: number): void {
|
|
39
|
+
if (!Number.isFinite(at)) return
|
|
40
|
+
if (lastPollAt === null || at > lastPollAt || lastPollAt - at > CLOCK_STEP_BACK_MS) lastPollAt = at
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** When the newest authenticated questions poll arrived, or null when none has since boot. */
|
|
44
|
+
export function lastQuestionsPollAt(): number | null {
|
|
45
|
+
return lastPollAt
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function __resetClientLivenessForTests(): void {
|
|
49
|
+
lastPollAt = null
|
|
50
|
+
}
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
} from '../../shared/media-attachment.js'
|
|
85
85
|
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
86
86
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
87
|
+
import { teeJobTrail } from './job-trail.js'
|
|
87
88
|
|
|
88
89
|
const INACTIVITY_MS = 180_000
|
|
89
90
|
const WALL_MAX_MS = 900_000
|
|
@@ -833,6 +834,11 @@ export async function callCodexStreaming(
|
|
|
833
834
|
}
|
|
834
835
|
})
|
|
835
836
|
|
|
837
|
+
// 6.52.0: the Messages trail, a SECOND stdout reader registered after the one above, so
|
|
838
|
+
// that handler sees every chunk first and nothing it decides can change. Without an
|
|
839
|
+
// `onTrail` (legacy /api/query, openai-compat, COS_MESSAGES_TRAIL=0) this attaches nothing.
|
|
840
|
+
teeJobTrail(proc.stdout, 'codex', callbacks.onTrail)
|
|
841
|
+
|
|
836
842
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
837
843
|
resetInactivity()
|
|
838
844
|
stderr += chunk.toString()
|
|
@@ -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
|
+
}
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
type MediaAttachmentRef,
|
|
64
64
|
} from '../../shared/media-attachment.js'
|
|
65
65
|
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
66
|
+
import { teeJobTrail } from './job-trail.js'
|
|
66
67
|
|
|
67
68
|
const INACTIVITY_MS = 180_000
|
|
68
69
|
const WALL_MAX_MS = 900_000
|
|
@@ -666,6 +667,11 @@ export async function callCursorStreaming(
|
|
|
666
667
|
}
|
|
667
668
|
})
|
|
668
669
|
|
|
670
|
+
// 6.52.0: the Messages trail, a SECOND stdout reader registered after the one above, so
|
|
671
|
+
// that handler sees every chunk first and nothing it decides can change. Without an
|
|
672
|
+
// `onTrail` (legacy /api/query, openai-compat, COS_MESSAGES_TRAIL=0) this attaches nothing.
|
|
673
|
+
teeJobTrail(proc.stdout, 'cursor', callbacks.onTrail)
|
|
674
|
+
|
|
669
675
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
670
676
|
resetInactivity()
|
|
671
677
|
stderr += chunk.toString()
|