@gotcos/glasses-server 6.61.5 → 6.61.6

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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## 6.61.6 — 2026-10-03
2
+
3
+ - Keep Codex-owned waiting messages visible in Sessions queue counts and queue reads after the COS handoff. Unavailable native reads report unknown, not empty.
4
+ - Edit existing COS and Codex queue slots in place. Return full text for authenticated queue reads; never cancel and resend as an edit. Native messages with attachments or structured spans remain manageable in Codex.
5
+ - Preserve concurrent enqueue, cancel and edit changes while the drainer awaits delivery. Native management uses local Codex queue protocol calls with automation and memory jobs disabled; no model turn is started.
6
+
7
+
1
8
  ## 6.61.5
2
9
 
3
10
  Local dictation-choice candidate, based on the 6.61.4 hardening candidate.
@@ -7,6 +14,7 @@ Local dictation-choice candidate, based on the 6.61.4 hardening candidate.
7
14
  - Run cleanup in a temporary workspace with no MCP. Claude tools/hooks are disabled; Cursor ask mode retains its read tools and uses an isolated subscription profile. Abort/timeout terminates the process tree; failures preserve the glossary-corrected transcript. Model circuit breakers are independent.
8
15
  - Local verification: model routing, legacy/default behavior, failure recovery, isolated configuration, cancellation, and real signed-in CLI smoke calls. Hardware acceptance and stable promotion remain outstanding.
9
16
 
17
+
10
18
  ## 6.61.4
11
19
 
12
20
  Local hardening candidate; stable promotion remains gated on native UI and G2 acceptance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.61.5",
3
+ "version": "6.61.6",
4
4
  "description": "COS Glasses: 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": {
@@ -0,0 +1,100 @@
1
+ // Queue management only. Never loads/starts a thread or starts a model turn.
2
+ // Protocol verified from the installed Codex generate-ts schema (2026-10-03).
3
+ import { spawn, execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { existsSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { resolveProviderBinary } from './provider-binary.js'
9
+
10
+ export interface NativeQueuedSubmission { id: string; input: Array<{ type: string; text?: string; text_elements?: unknown[] }>; clientUserMessageId: string }
11
+ export interface NativeQueueControl {
12
+ list(threadId: string): Promise<NativeQueuedSubmission[]>
13
+ update(threadId: string, id: string, prompt: string): Promise<void>
14
+ cancel(threadId: string, id: string): Promise<boolean>
15
+ }
16
+ const METHODS = new Set(['thread/queue/list', 'thread/queue/update', 'thread/queue/delete'])
17
+ export function codexQueueRpc(method: string, params: Record<string, unknown>, binary?: string): Promise<any> {
18
+ if (!METHODS.has(method)) return Promise.reject(new Error('queue_method_not_allowed'))
19
+ const resolved = binary ? { ok: true as const, path: binary } : resolveProviderBinary('codex')
20
+ if (!resolved.ok) return Promise.reject(new Error('codex_queue_unavailable'))
21
+ return new Promise((resolve, reject) => {
22
+ // A separate, short-lived protocol client: automations and memories explicitly
23
+ // disabled. No thread/start, turn/start or queue/start call is allowed here.
24
+ const child = spawn(resolved.path, ['app-server', '--disable', 'in_app_local_automation', '--disable', 'memories'], { stdio: ['pipe', 'pipe', 'ignore'], shell: false })
25
+ let buffer = '', done = false, initialized = false
26
+ const finish = (error: Error | null, value?: unknown) => {
27
+ if (done) return
28
+ done = true; clearTimeout(timer); child.kill(); error ? reject(error) : resolve(value)
29
+ }
30
+ const timer = setTimeout(() => finish(new Error('codex_queue_timeout')), 8000)
31
+ const send = (payload: unknown) => child.stdin.write(JSON.stringify(payload) + '\n')
32
+ child.on('error', () => finish(new Error('codex_queue_unavailable')))
33
+ child.stdin.on('error', () => finish(new Error('codex_queue_unavailable')))
34
+ child.on('exit', () => finish(new Error('codex_queue_closed')))
35
+ child.stdout.on('data', data => {
36
+ buffer += data.toString()
37
+ if (buffer.length > 4 * 1024 * 1024) { finish(new Error('codex_queue_response_too_large')); return }
38
+ let end: number
39
+ while ((end = buffer.indexOf('\n')) >= 0) {
40
+ const line = buffer.slice(0, end); buffer = buffer.slice(end + 1)
41
+ let row: any
42
+ try { row = JSON.parse(line) } catch { continue }
43
+ if (row.id !== 1 && row.id !== 2) continue
44
+ if (row.error) { finish(new Error('codex_queue_request_refused')); return }
45
+ if (row.id === 1 && !initialized) {
46
+ initialized = true
47
+ send({ method: 'initialized' })
48
+ send({ id: 2, method, params })
49
+ } else if (row.id === 2) { finish(null, row.result); return }
50
+ }
51
+ })
52
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'cos-queue-control', version: '1' }, capabilities: { experimentalApi: true, explicitGatewayOauth: true } } })
53
+ })
54
+ }
55
+
56
+ export function plainQueueText(row: NativeQueuedSubmission): string | null {
57
+ // Never turn a message containing files, mentions or structured text into plain text.
58
+ return Array.isArray(row.input) && row.input.length === 1 && row.input[0]?.type === 'text'
59
+ && typeof row.input[0].text === 'string' && !row.input[0].text_elements?.length ? row.input[0].text : null
60
+ }
61
+ export const nativeQueueControl: NativeQueueControl = {
62
+ async list(threadId) {
63
+ const rows: NativeQueuedSubmission[] = []
64
+ let cursor: string | null = null
65
+ const seen = new Set<string>()
66
+ do {
67
+ const result = await codexQueueRpc('thread/queue/list', { threadId, cursor, limit: 100 })
68
+ if (!Array.isArray(result?.data) || result.data.some((r: any) => typeof r?.id !== 'string' || !Array.isArray(r.input))) throw new Error('codex_queue_bad_response')
69
+ rows.push(...result.data)
70
+ cursor = result.nextCursor ?? null
71
+ if (cursor && (seen.has(cursor) || rows.length >= 1000)) throw new Error('codex_queue_incomplete')
72
+ if (cursor) seen.add(cursor)
73
+ } while (cursor)
74
+ return rows
75
+ },
76
+ async update(threadId, id, prompt) {
77
+ const current = (await this.list(threadId)).find(r => r.id === id)
78
+ if (!current || plainQueueText(current) === null) throw new Error('queue_not_editable')
79
+ const result = await codexQueueRpc('thread/queue/update', { threadId, queuedSubmissionId: id, input: [{ type: 'text', text: prompt, text_elements: [] }] })
80
+ if (result?.queuedSubmission?.id !== id) throw new Error('codex_queue_bad_response')
81
+ },
82
+ async cancel(threadId, id) {
83
+ const result = await codexQueueRpc('thread/queue/delete', { threadId, queuedSubmissionId: id })
84
+ if (typeof result?.deleted !== 'boolean') throw new Error('codex_queue_bad_response')
85
+ return result.deleted
86
+ },
87
+ }
88
+
89
+ // One read-only aggregate per session-list request, not 80 protocol processes.
90
+ // No queue file is ever edited via SQL; mutations use Codex's own protocol above.
91
+ export async function nativeQueuedCounts(): Promise<Map<string, number> | null> {
92
+ const db = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'queue_1.sqlite')
93
+ if (!existsSync(db)) return new Map()
94
+ try {
95
+ const { stdout } = await promisify(execFile)('/usr/bin/sqlite3', ['-readonly', '-json', db, 'SELECT thread_id, count(*) AS n FROM queued_items GROUP BY thread_id'], { timeout: 2000, maxBuffer: 1024 * 1024 })
96
+ const rows = JSON.parse(stdout || '[]')
97
+ if (!Array.isArray(rows) || rows.some(r => typeof r.thread_id !== 'string' || !Number.isInteger(r.n))) return null
98
+ return new Map(rows.map(r => [r.thread_id, r.n]))
99
+ } catch { return null }
100
+ }
@@ -1,3 +1,4 @@
1
+ import { nativeQueuedCounts } from './codex-queue-control.js'
1
2
  // Durable storage for queued thread turns, and the terminal-record probe.
2
3
  //
3
4
  // SEPARATE FROM THE DECISIONS. `thread-turn-queue.ts` is pure and holds every rule;
@@ -104,8 +105,8 @@ export function queuedWaitingLookup(now: number): (provider: string, sessionId:
104
105
  return (provider, sessionId) => queuedWaitingForSession(queues, provider, sessionId)
105
106
  }
106
107
 
107
- export function queuedTurnsFields(count: number): { queued_turns?: number } {
108
- return count > 0 ? { queued_turns: count } : {}
108
+ export function queuedTurnsFields(count: number | null): { queued_turns?: number; queue_unknown?: boolean } {
109
+ return count === null ? { queue_unknown: true } : count > 0 ? { queued_turns: count } : {}
109
110
  }
110
111
 
111
112
  /** Every thread with a queue file, for the drain sweep. */
@@ -182,3 +183,11 @@ export function readTranscriptTailLines(path: string | null, maxBytes: number):
182
183
  return null
183
184
  }
184
185
  }
186
+
187
+ /** Native queued submissions remain waiting after COS has handed them over. */
188
+ export async function allQueuedWaitingLookup(now: number): Promise<(provider: string, id: string) => number | null> {
189
+ const cos = queuedWaitingLookup(now)
190
+ const native = await nativeQueuedCounts()
191
+ return (provider, id) => provider !== 'codex' ? cos(provider, id)
192
+ : native === null ? null : cos(provider, id) + (native.get(id) ?? 0)
193
+ }
@@ -43,7 +43,7 @@ import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, re
43
43
  import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
44
44
  import { deriveForRow, signalFor } from '../lib/session-hooks-runtime.js'
45
45
  import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
46
- import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
46
+ import { queuedTurnsFields, allQueuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
47
47
  import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
48
48
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
49
49
  import {
@@ -83,7 +83,7 @@ function asSort(value: unknown): AgentSessionSort {
83
83
  return String(value ?? '').toLowerCase() === 'opened' ? 'opened' : 'updated'
84
84
  }
85
85
 
86
- function toSearchHit(row: AgentSessionSearchHit, queuedTurns = 0, derived?: DerivedSessionState) {
86
+ function toSearchHit(row: AgentSessionSearchHit, queuedTurns: number | null = 0, derived?: DerivedSessionState) {
87
87
  return {
88
88
  ...toEntry(row, undefined, derived, queuedTurns),
89
89
  snippet: row.snippet,
@@ -269,7 +269,7 @@ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: nu
269
269
  }
270
270
  }
271
271
 
272
- function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState, queuedTurns = 0) {
272
+ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState, queuedTurns: number | null = 0) {
273
273
  return {
274
274
  session_id: row.session_id,
275
275
  provider: row.provider,
@@ -397,7 +397,7 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
397
397
  if (row.provider !== 'cursor' || !signalFor(row.session_id)) continue
398
398
  derivedById.set(row.session_id, deriveForRow({ sessionId: row.session_id, now, remember: false }))
399
399
  }
400
- const queuedOf = queuedWaitingLookup(now)
400
+ const queuedOf = await allQueuedWaitingLookup(now)
401
401
  res.json({
402
402
  sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running)),
403
403
  total: sessions.length,
@@ -428,7 +428,7 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
428
428
  try {
429
429
  const limit = boundedInteger(req.query.limit, 20, 1, 50)
430
430
  const result = await searchAgentSessions({ query, limit })
431
- const queuedOf = queuedWaitingLookup(Date.now())
431
+ const queuedOf = await allQueuedWaitingLookup(Date.now())
432
432
  // 6.48.1: search hits carry the same derived state as list rows (no list/search shape
433
433
  // drift), from the registry and the hook signal; a hit has no transcript walk.
434
434
  const peers = await liveClaudePeerRecords()
@@ -532,7 +532,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
532
532
  res.json({
533
533
  ...withRunning({ session_id: parsed.session_id }, running),
534
534
  ...derivedRowFields(derived),
535
- ...queuedTurnsFields(queuedWaitingLookup(Date.now())(provider, parsed.session_id)),
535
+ ...queuedTurnsFields((await allQueuedWaitingLookup(Date.now()))(provider, parsed.session_id)),
536
536
  // The client must be able to tell "this server stamped nothing" from "this
537
537
  // server stamped false", because the two demand opposite behaviour: an old
538
538
  // server's silence means keep using the hint borrowed from the list row, and
@@ -16,7 +16,8 @@
16
16
  // EVERY DEPENDENCY IS INJECTED so the whole path is testable without a live server.
17
17
 
18
18
  import { Router, type Request, type Response } from 'express'
19
- import { COS_SESSION_ID_RE } from './agent-session-bindings.js'
19
+ import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
20
+ import { COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
20
21
  import {
21
22
  admitToQueue, drainDecision, queueableRefusal, queuePosition,
22
23
  MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
@@ -47,6 +48,7 @@ export interface ThreadTurnQueueDeps {
47
48
  /** The turn route's OWN `retryable` judgement, when it sent one. Authoritative. */
48
49
  serverRetryable?: boolean
49
50
  }>
51
+ nativeQueue?: NativeQueueControl
50
52
  now: () => number
51
53
  }
52
54
 
@@ -58,6 +60,9 @@ function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unk
58
60
  queuedAt: turn.queuedAt,
59
61
  attempts: turn.attempts,
60
62
  position: turn.status === 'waiting' ? position : -1,
63
+ prompt: turn.prompt,
64
+ editable: turn.status === 'waiting',
65
+ owner: 'cos',
61
66
  preview: turn.prompt.length > 80 ? `${turn.prompt.slice(0, 79)}…` : turn.prompt,
62
67
  ...(turn.reason ? { reason: turn.reason } : {}),
63
68
  ...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
@@ -116,10 +121,16 @@ export async function drainThread(
116
121
  if (queue.length === 0) return { delivered: 0, held: 0, retired: 0 }
117
122
 
118
123
  let delivered = 0, held = 0, retired = 0
119
- let dirty = false
124
+ const persist = (turn: QueuedThreadTurn) => {
125
+ const latest = readQueue(provider, threadId, deps.now())
126
+ const index = latest.findIndex(t => t.clientTurnId === turn.clientTurnId)
127
+ if (index >= 0) { latest[index] = turn; writeQueue(provider, threadId, latest) }
128
+ }
120
129
  let refusedGate: ReturnType<ThreadTurnQueueDeps['occupancy']> | null = null
121
130
 
122
- for (const turn of queue) {
131
+ for (const snapshot of queue) {
132
+ const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
133
+ if (!turn) continue
123
134
  if (turn.status !== 'waiting') continue
124
135
 
125
136
  // Reuse only refusal within this synchronous pass. Rechecking a busy thread
@@ -150,7 +161,7 @@ export async function drainThread(
150
161
  turn.status = decision === 'expire' ? 'expired' : 'refused'
151
162
  turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
152
163
  turn.settledAt = deps.now()
153
- retired += 1; dirty = true
164
+ retired += 1; persist(turn)
154
165
  continue
155
166
  }
156
167
 
@@ -159,8 +170,7 @@ export async function drainThread(
159
170
  // forever; the ceiling only bounds anything if it survives the crash it is bounding.
160
171
  turn.attempts += 1
161
172
  turn.status = 'delivering'
162
- writeQueue(provider, threadId, queue)
163
- dirty = true
173
+ persist(turn)
164
174
 
165
175
  let outcome: { ok: boolean; reason?: string; serverRetryable?: boolean }
166
176
  refusedGate = null
@@ -209,9 +219,8 @@ export async function drainThread(
209
219
  turn.reason = outcome.reason
210
220
  held += 1
211
221
  }
222
+ persist(turn)
212
223
  }
213
-
214
- if (dirty) writeQueue(provider, threadId, queue)
215
224
  return { delivered, held, retired }
216
225
  }
217
226
 
@@ -286,20 +295,59 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
286
295
  })
287
296
 
288
297
  // GET — what is waiting, for the pending row on the lens.
289
- router.get('/agent-sessions/:provider/:threadId/queued-turns', (req: Request, res: Response) => {
298
+ router.get('/agent-sessions/:provider/:threadId/queued-turns', async (req: Request, res: Response) => {
290
299
  res.set('Cache-Control', 'private, no-store')
291
300
  const provider = String(req.params.provider ?? '')
292
301
  const threadId = String(req.params.threadId ?? '')
293
302
  const queue = readQueue(provider, threadId, deps.now())
294
- return res.json({ turns: queue.map(t => publicRow(t, queuePosition(queue, t.clientTurnId))) })
303
+ let native: Record<string, unknown>[] = []
304
+ if (provider === 'codex') {
305
+ try {
306
+ native = (await (deps.nativeQueue ?? nativeQueueControl).list(threadId)).map((row, position) => {
307
+ const text = plainQueueText(row)
308
+ return { clientTurnId: `codex-native:${row.id}`, status: 'waiting', position, queuedAt: 0,
309
+ attempts: 0, preview: text?.slice(0, 80) ?? 'Message with attachments · manage in Codex',
310
+ ...(text !== null ? { prompt: text } : {}), editable: text !== null, owner: 'codex' }
311
+ })
312
+ } catch { return res.status(503).json({ error: 'native_queue_unavailable' }) }
313
+ }
314
+ // Re-read after the native await: delivery may have changed COS ownership.
315
+ const current = readQueue(provider, threadId, deps.now())
316
+ return res.json({ turns: [...native, ...current.map(t => publicRow(t, queuePosition(current, t.clientTurnId) + native.length))] })
317
+ })
318
+
319
+ // Atomic replacement: no cancel-and-requeue window, and the slot is retained.
320
+ router.patch('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId', async (req: Request, res: Response) => {
321
+ res.set('Cache-Control', 'private, no-store')
322
+ const provider = String(req.params.provider), threadId = String(req.params.threadId), id = String(req.params.clientTurnId)
323
+ const prompt = req.body?.prompt
324
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > DEFAULT_MAX_PROMPT_CHARS) return res.status(400).json({ error: 'invalid_prompt' })
325
+ if (provider === 'codex' && id.startsWith('codex-native:')) {
326
+ try {
327
+ await (deps.nativeQueue ?? nativeQueueControl).update(threadId, id.slice(13), prompt)
328
+ return res.json({ updated: true })
329
+ } catch { return res.status(409).json({ error: 'queue_edit_unconfirmed' }) }
330
+ }
331
+ const queue = readQueue(provider, threadId, deps.now())
332
+ const row = queue.find(t => t.clientTurnId === id)
333
+ if (!row || row.status !== 'waiting') return res.status(409).json({ error: 'already_delivering' })
334
+ row.prompt = prompt
335
+ writeQueue(provider, threadId, queue)
336
+ return res.json({ updated: true })
295
337
  })
296
338
 
297
339
  // DELETE — the cancel control, the same affordance the desktop queue offers.
298
- router.delete('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId', (req: Request, res: Response) => {
340
+ router.delete('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId', async (req: Request, res: Response) => {
299
341
  res.set('Cache-Control', 'private, no-store')
300
342
  const provider = String(req.params.provider ?? '')
301
343
  const threadId = String(req.params.threadId ?? '')
302
344
  const clientTurnId = String(req.params.clientTurnId ?? '')
345
+ if (provider === 'codex' && clientTurnId.startsWith('codex-native:')) {
346
+ try {
347
+ const cancelled = await (deps.nativeQueue ?? nativeQueueControl).cancel(threadId, clientTurnId.slice(13))
348
+ return res.status(cancelled ? 200 : 409).json(cancelled ? { cancelled: true, clientTurnId, status: 'cancelled' } : { error: 'already_delivering' })
349
+ } catch { return res.status(503).json({ error: 'native_queue_unavailable' }) }
350
+ }
303
351
  const now = deps.now()
304
352
  const queue = readQueue(provider, threadId, now)
305
353
  const row = queue.find(t => t.clientTurnId === clientTurnId)