@gotcos/glasses-server 6.61.5 → 6.61.7

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,17 @@
1
+ ## 6.61.7 — 2026-10-04 — Agent activity and compaction visibility
2
+
3
+ - Add display-only child activity to native Sessions list/detail. Codex uses read-only spawn relationships plus each child's lifecycle, with bounded reads, freshness, deduplication and explicit incomplete/unknown counts.
4
+ - Observe Claude subagent identities and compaction hooks. Refresh hooks after upgrading. Native Cursor observers cover subagent start/stop, context compaction and resumed output; installation preserves other hooks. Observers retain no prompts and never trigger queue drains or permission decisions.
5
+ - Stream past oversized top-level Codex context replacement records in bounded chunks instead of declaring completion and disconnecting. Other oversized records keep their existing fallback.
6
+ - Paired with glasses 6.10.590. No model routing or Cursor model-catalog changes. Automated fixture canaries pass; physical device and live-provider acceptance are recorded separately.
7
+
8
+ ## 6.61.6 — 2026-10-03
9
+
10
+ - Keep Codex-owned waiting messages visible in Sessions queue counts and queue reads after the COS handoff. Unavailable native reads report unknown, not empty.
11
+ - 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.
12
+ - 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.
13
+
14
+
1
15
  ## 6.61.5
2
16
 
3
17
  Local dictation-choice candidate, based on the 6.61.4 hardening candidate.
@@ -7,6 +21,7 @@ Local dictation-choice candidate, based on the 6.61.4 hardening candidate.
7
21
  - 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
22
  - 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
23
 
24
+
10
25
  ## 6.61.4
11
26
 
12
27
  Local hardening candidate; stable promotion remains gated on native UI and G2 acceptance.
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // Read-only native Cursor lifecycle observer. No network, credentials, commands or prompts.
3
+ const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
4
+ const event=process.argv[2],spool=process.argv[3];
5
+ const names={subagentStart:'SubagentStart',subagentStop:'SubagentStop',preCompact:'PreCompact',postToolUse:'PostToolUse',afterAgentResponse:'PostCompact',stop:'PostCompact',sessionEnd:'PostCompact'};
6
+ let input='',oversize=false;
7
+ process.stdin.setEncoding('utf8');
8
+ process.stdin.on('data',chunk=>{if(input.length+chunk.length>1048576){oversize=true;input=''}else if(!oversize)input+=chunk});
9
+ process.stdin.on('error',()=>{});
10
+ process.stdin.on('end',()=>{
11
+ try {
12
+ if(!oversize && names[event] && spool){
13
+ const p=JSON.parse(input),id=p.parent_conversation_id||p.conversation_id||p.session_id;
14
+ if(typeof id==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)){
15
+ fs.mkdirSync(spool,{recursive:true,mode:0o700});
16
+ if(fs.readdirSync(spool).length<2000){
17
+ const ts=Date.now(),payload={session_id:id.toLowerCase(),display_only:true};
18
+ const agent=p.subagent_id||p.agent_id;
19
+ if(typeof agent==='string'&&/^[a-zA-Z0-9_-]{1,160}$/.test(agent))payload.agent_id=agent;
20
+ const name=ts+'-'+process.pid+'-'+crypto.randomBytes(6).toString('hex')+'-'+names[event]+'.json';
21
+ const temp=path.join(spool,'.'+name);
22
+ fs.writeFileSync(temp,JSON.stringify({ts,ppid:null,event:names[event],payload}),{mode:0o600,flag:'wx'});
23
+ fs.renameSync(temp,path.join(spool,name));
24
+ }
25
+ }
26
+ }
27
+ } catch {} // An observational failure never blocks a user action.
28
+ process.stdout.write(event==='subagentStart'?'{"permission":"allow"}\n':'{}\n');
29
+ });
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.7",
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": {
@@ -73,7 +73,8 @@
73
73
  "docs/work-handoff-requests.md",
74
74
  "work-task-runtime",
75
75
  "docs/work-task-runtime.md",
76
- "!server/scripts/build-work-task-runtime.py"
76
+ "!server/scripts/build-work-task-runtime.py",
77
+ "bin/hooks/cos-cursor-observer.cjs"
77
78
  ],
78
79
  "author": "COS Contributors",
79
80
  "license": "MIT",
@@ -90,6 +90,7 @@ export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
90
90
  { event: 'Notification', matcher: 'permission_prompt|idle_prompt|elicitation_dialog|agent_needs_input', async: true, timeout: 10 },
91
91
  { event: 'SubagentStart', async: true, timeout: 10 },
92
92
  { event: 'SubagentStop', async: true, timeout: 10 },
93
+ { event: 'PreCompact', async: true, timeout: 10 },
93
94
  { event: 'PostCompact', async: true, timeout: 10 },
94
95
  { event: 'PostModelSwitch', async: true, timeout: 10 },
95
96
  ]
@@ -307,6 +308,7 @@ export interface HookStatus {
307
308
  * has not run Install hooks since. Questions are then held at most 120 s, as before.
308
309
  */
309
310
  priorWaitOnly: boolean
311
+ observerOnlyDrift?: boolean
310
312
  /** 6.60.0: our PermissionRequest hook's `timeout` in the settings file, or null (`permissionHookTimeoutS`). */
311
313
  permissionHookTimeoutS: number | null
312
314
  tokenPresent: boolean
@@ -362,7 +364,8 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
362
364
  else state = 'installed'
363
365
  const priorWaitOnly = state === 'drift' && events.missing.length === 0 && events.drifted.length > 0
364
366
  && events.drifted.every(event => events.priorWait.includes(event))
365
- return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
367
+ const observerOnlyDrift = state === 'drift' && events.missing.length > 0 && events.missing.every(event => event === 'PreCompact') && events.drifted.every(event => events.priorWait.includes(event))
368
+ return { observerOnlyDrift, state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
366
369
  }
367
370
 
368
371
  /**
@@ -397,11 +400,11 @@ export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
397
400
  * check, so updating the server never turns cancel from the lens off. Any other drift
398
401
  * still is not halt-ready.
399
402
  */
400
- export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly' | 'packageScriptSha'>>): boolean {
403
+ export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly' | 'observerOnlyDrift' | 'packageScriptSha'>>): boolean {
401
404
  if (status.installed === true) return true
402
405
  const priorScript = typeof status.scriptSha === 'string' && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
403
406
  if (status.state === 'script_outdated') return priorScript
404
- if (status.state === 'drift' && status.priorWaitOnly === true) {
407
+ if (status.state === 'drift' && (status.priorWaitOnly === true || status.observerOnlyDrift === true)) {
405
408
  return priorScript || (typeof status.scriptSha === 'string' && status.scriptSha === status.packageScriptSha)
406
409
  }
407
410
  return false
@@ -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
+ }
@@ -0,0 +1,119 @@
1
+ // Read-only display evidence. Never imported by attachment, queue or write gates.
2
+ // Open spawn edges describe relationships, not work: completed children remain open.
3
+ import { execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { open, realpath, readdir, stat } from 'node:fs/promises'
6
+ import { dirname, join, relative } from 'node:path'
7
+
8
+ const exec = promisify(execFile)
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
10
+ export const SUBAGENT_FRESH_MS = 5 * 60_000
11
+ const MAX_ROWS = 512
12
+ const MAX_TAIL = 4 * 1024 * 1024
13
+ const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
14
+ export interface SubagentActivity {
15
+ active: number
16
+ completed: number
17
+ unknown: number
18
+ total: number
19
+ checked_at: number
20
+ partial: boolean
21
+ }
22
+ export type ChildState = 'active' | 'completed' | 'unknown'
23
+
24
+ /** Latest explicit turn lifecycle wins; ordinary output alone never starts a turn. */
25
+ interface ChildEvidence { lifecycle: 'open' | 'completed' | 'unknown'; latest: number }
26
+ function parseChildEvidence(text: string, now: number, startsMidFile = false): ChildEvidence {
27
+ const lines = text.split('\n')
28
+ if (startsMidFile) lines.shift()
29
+ let latest = 0
30
+ for (let i = lines.length - 1; i >= 0; i--) {
31
+ let row: any
32
+ try { row = JSON.parse(lines[i]) } catch { continue }
33
+ const at = Date.parse(row?.timestamp)
34
+ if (Number.isFinite(at) && at <= now + 5000 && at > 0) latest = Math.max(latest, at)
35
+ if (row?.type !== 'event_msg') continue
36
+ const kind = row.payload?.type
37
+ if (['task_complete', 'task_failed', 'turn_aborted', 'turn_cancelled'].includes(kind)) return { lifecycle: Number.isFinite(at) && at > 0 && at <= now + 5000 ? 'completed' : 'unknown', latest }
38
+ if (kind === 'task_started') {
39
+ // A crashed/interrupted writer with no terminal event must age into uncertainty.
40
+ return { lifecycle: Number.isFinite(at) && at > 0 && at <= now + 5000 ? 'open' : 'unknown', latest }
41
+ }
42
+ }
43
+ return { lifecycle: 'unknown', latest }
44
+ }
45
+ function stateOf(evidence: ChildEvidence, now: number): ChildState {
46
+ if (evidence.lifecycle === 'completed') return 'completed'
47
+ return evidence.lifecycle === 'open' && evidence.latest <= now + 5000 && now - evidence.latest <= SUBAGENT_FRESH_MS ? 'active' : 'unknown'
48
+ }
49
+ export function childStateFromTail(text: string, now: number, startsMidFile = false): ChildState {
50
+ return stateOf(parseChildEvidence(text, now, startsMidFile), now)
51
+ }
52
+
53
+ const fileMemo = new Map<string, { mtime: number; size: number; evidence: ChildEvidence }>()
54
+ async function childState(file: string, root: string, now: number, budget: { bytes: number }): Promise<ChildState> {
55
+ try {
56
+ const canonical = await realpath(file)
57
+ const rel = relative(await realpath(root), canonical)
58
+ if (!rel || rel.startsWith('..') || rel.startsWith('/') || !canonical.endsWith('.jsonl')) return 'unknown'
59
+ const st = await stat(canonical)
60
+ if (!st.isFile()) return 'unknown'
61
+ const hit = fileMemo.get(canonical)
62
+ if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) return stateOf(hit.evidence, now)
63
+ const handle = await open(canonical, 'r')
64
+ let evidence: ChildEvidence = { lifecycle: 'unknown', latest: 0 }
65
+ try {
66
+ for (let limit = 256 * 1024; ; limit *= 4) {
67
+ if (budget.bytes <= 0) return 'unknown'
68
+ const length = Math.min(st.size, limit, budget.bytes), start = st.size - length
69
+ budget.bytes -= length
70
+ const buffer = Buffer.alloc(length)
71
+ const read = await handle.read(buffer, 0, length, start)
72
+ evidence = parseChildEvidence(buffer.subarray(0, read.bytesRead).toString('utf8'), now, start > 0)
73
+ if (evidence.lifecycle !== 'unknown' || start === 0 || limit >= MAX_TAIL) break
74
+ }
75
+ } finally { await handle.close() }
76
+ // Cache only lifecycle metadata, never transcript text. Freshness is recomputed.
77
+ if (fileMemo.size >= MAX_ROWS) fileMemo.delete(fileMemo.keys().next().value!)
78
+ fileMemo.set(canonical, { mtime: st.mtimeMs, size: st.size, evidence })
79
+ return stateOf(evidence, now)
80
+ } catch { return 'unknown' }
81
+ }
82
+
83
+ /** One bounded read-only SQLite snapshot per list/detail read. */
84
+ export async function readCodexSubagents(
85
+ parents: readonly string[], sessionsRoot: string, now = Date.now(),
86
+ ): Promise<Map<string, SubagentActivity>> {
87
+ const ids = [...new Set(parents.filter(id => UUID.test(id)).map(id => id.toLowerCase()))].slice(0, 80)
88
+ const out = new Map<string, SubagentActivity>()
89
+ if (!ids.length) return out
90
+ try {
91
+ const home = dirname(sessionsRoot)
92
+ const files = (await readdir(home)).filter(n => /^state_\d+\.sqlite$/.test(n)).sort((a,b) => Number(b.match(/\d+/)![0])-Number(a.match(/\d+/)![0]))
93
+ if (!files.length) return out // older installations do not advertise this capability
94
+ // UUID validation above is the SQL parameter boundary; no prompt/path reaches SQL.
95
+ const values = ids.map(id => `'${id}'`).join(',')
96
+ const query = `WITH RECURSIVE children(root,id,depth,trail) AS (
97
+ SELECT parent_thread_id,child_thread_id,1,','||parent_thread_id||','||child_thread_id||',' FROM thread_spawn_edges WHERE status='open' AND parent_thread_id IN (${values})
98
+ UNION ALL SELECT c.root,e.child_thread_id,c.depth+1,c.trail||e.child_thread_id||',' FROM children c JOIN thread_spawn_edges e ON e.parent_thread_id=c.id WHERE e.status='open' AND c.depth<16 AND instr(c.trail,','||e.child_thread_id||',')=0
99
+ ) SELECT DISTINCT c.root,c.id,t.rollout_path,t.archived,c.depth FROM children c LEFT JOIN threads t ON t.id=c.id LIMIT ${MAX_ROWS+1}`
100
+ const { stdout } = await exec('/usr/bin/sqlite3', ['-readonly', '-json', join(home,files[0]), query], { timeout: 2000, maxBuffer: 2*1024*1024 })
101
+ const rows = JSON.parse(stdout || '[]') as Array<{root:string;id:string;rollout_path?:string;archived?:number;depth:number}>
102
+ for (const id of ids) out.set(id, {active:0,completed:0,unknown:0,total:0,checked_at:now,partial:rows.length>MAX_ROWS})
103
+ const seen = new Set<string>()
104
+ const budget = { bytes: MAX_SNAPSHOT_BYTES }
105
+ // Sequential bounded reads avoid descriptor storms; most children are memo hits.
106
+ for (const row of rows.slice(0, MAX_ROWS)) {
107
+ const key = row.root+':'+row.id, summary = out.get(row.root)
108
+ if (!summary || seen.has(key)) continue
109
+ seen.add(key); summary.total++
110
+ if (row.depth >= 16) summary.partial = true
111
+ const state = row.archived === 1 ? 'completed' : typeof row.rollout_path === 'string' ? await childState(row.rollout_path, sessionsRoot, now, budget) : 'unknown'
112
+ summary[state === 'active' ? 'active' : state === 'completed' ? 'completed' : 'unknown']++
113
+ }
114
+ } catch {
115
+ // Do not convert a schema/read failure into a fabricated zero-agent count.
116
+ for (const id of ids) out.set(id, {active:0,completed:0,unknown:0,total:0,checked_at:now,partial:true})
117
+ }
118
+ return out
119
+ }
@@ -0,0 +1,38 @@
1
+ import { readFileSync, existsSync, lstatSync, mkdirSync, copyFileSync, chmodSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { atomicWriteFileSync } from './atomic-fs.js'
6
+ import { cosGlassesHome, hookSpoolDir } from './claude-hooks-installer.js'
7
+ export const CURSOR_OBSERVER_EVENTS=['subagentStart','subagentStop','preCompact','postToolUse','afterAgentResponse','stop','sessionEnd'] as const
8
+ const quote=(s:string)=>"'"+s.replace(/'/g,"'\\''")+"'"
9
+ export function installCursorObserver(options:{settingsPath?:string;scriptPath?:string;spool?:string;dryRun?:boolean;uninstall?:boolean}={}) {
10
+ const settings=options.settingsPath??join(process.env.CURSOR_CONFIG_DIR??join(homedir(),'.cursor'),'hooks.json')
11
+ const script=options.scriptPath??join(cosGlassesHome(),'bin','cos-cursor-observer.cjs')
12
+ const spool=options.spool??hookSpoolDir()
13
+ try {
14
+ if(existsSync(settings)&&lstatSync(settings).isSymbolicLink())throw new Error('symlinked Cursor settings')
15
+ const old=existsSync(settings)?readFileSync(settings,'utf8'):'{}', config=JSON.parse(old)
16
+ if(!config||typeof config!=='object'||Array.isArray(config)||(config.version!==undefined&&config.version!==1))throw new Error('unsupported Cursor config')
17
+ if(config.hooks!==undefined&&(!config.hooks||typeof config.hooks!=='object'||Array.isArray(config.hooks)))throw new Error('invalid Cursor hooks')
18
+ const hooks={...config.hooks}
19
+ for(const event of CURSOR_OBSERVER_EVENTS){
20
+ if(hooks[event]!==undefined&&!Array.isArray(hooks[event]))throw new Error('invalid Cursor hook list')
21
+ const ours=(h:any)=>typeof h?.command==='string'&&h.command.startsWith(quote(process.execPath)+' '+quote(script)+' ')
22
+ hooks[event]=(hooks[event]??[]).filter((h:any)=>!ours(h))
23
+ if(!options.uninstall)hooks[event].push({command:[process.execPath,script,event,spool].map(quote).join(' '),timeout:5})
24
+ if(!hooks[event].length)delete hooks[event]
25
+ }
26
+ const next=JSON.stringify({...config,version:1,hooks},null,2)+'\n'
27
+ if(!options.dryRun){
28
+ if(!options.uninstall){
29
+ mkdirSync(dirname(script),{recursive:true,mode:0o700})
30
+ const source=resolve(dirname(fileURLToPath(import.meta.url)),'../../bin/hooks/cos-cursor-observer.cjs')
31
+ atomicWriteFileSync(script,readFileSync(source),{mode:0o755});chmodSync(script,0o755)
32
+ }
33
+ mkdirSync(dirname(settings),{recursive:true})
34
+ if(next!==old){if(existsSync(settings))copyFileSync(settings,settings+'.cos-observer-backup');atomicWriteFileSync(settings,next,{mode:0o600})}
35
+ }
36
+ return {ok:true,changed:next!==old,events:[...CURSOR_OBSERVER_EVENTS],dryRun:!!options.dryRun}
37
+ }catch(error){return {ok:false,changed:false,reason:error instanceof Error?error.message:String(error)}}
38
+ }
@@ -30,6 +30,7 @@ export const HOOK_EVENT_NAMES = [
30
30
  'Notification',
31
31
  'SubagentStart',
32
32
  'SubagentStop',
33
+ 'PreCompact',
33
34
  'PostCompact',
34
35
  'PostModelSwitch',
35
36
  ] as const
@@ -154,6 +155,7 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
154
155
  const str = (k: string) => (typeof payload[k] === 'string' ? payload[k] as string : undefined)
155
156
  const base: Record<string, unknown> = {
156
157
  session_id: str('session_id'),
158
+ ...(payload.display_only === true ? { display_only: true } : {}),
157
159
  ...(str('transcript_path') ? { transcript_path: str('transcript_path') } : {}),
158
160
  ...(str('cwd') ? { cwd: str('cwd') } : {}),
159
161
  ...(str('permission_mode') ? { permission_mode: str('permission_mode') } : {}),
@@ -199,6 +201,7 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
199
201
  return { ...base, agent_id: str('agent_id'), agent_type: str('agent_type') }
200
202
  case 'PostModelSwitch':
201
203
  return { ...base, from_model: str('from_model'), to_model: str('to_model') }
204
+ case 'PreCompact':
202
205
  case 'PostCompact':
203
206
  return base
204
207
  }
@@ -404,7 +404,14 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
404
404
  /** Signal for a row id that may be the registry's eight-character form. */
405
405
  export function signalFor(sessionId: string): SessionSignal | undefined {
406
406
  if (!sessionHooksEnabled()) return undefined
407
- return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
407
+ const signal = sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
408
+ return signal?.observationOnly ? undefined : signal
409
+ }
410
+
411
+ /** Display observers do not create hook occupancy or permission evidence. */
412
+ export function workObservationFor(sessionId: string) {
413
+ if (!sessionHooksEnabled()) return undefined
414
+ return (sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId))?.workObservation
408
415
  }
409
416
 
410
417
  /** 6.60.0: told when a row derives `ended`, so the broker stops saying the Mac is waiting on it. */
@@ -1,3 +1,4 @@
1
+ import { observeWork, type WorkObservation } from './session-work-observation.js'
1
2
  // The signal store: what the hooks have said about each Claude session, reduced to
2
3
  // one record per full session id.
3
4
  //
@@ -48,6 +49,8 @@ export interface FailureSignal {
48
49
  }
49
50
 
50
51
  export interface SessionSignal {
52
+ workObservation?: WorkObservation
53
+ observationOnly?: boolean
51
54
  sessionId: string
52
55
  firstSeenAt: number
53
56
  lastEventAt: number
@@ -179,7 +182,10 @@ function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName:
179
182
  export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
180
183
  const base = prev ? { ...prev } : fresh(env)
181
184
  const p = env.payload
185
+ if (p.display_only === true) return { ...base, ...(prev?.observationOnly !== false ? { lastEventAt: Math.max(base.lastEventAt, env.ts) } : {}), observationOnly: prev?.observationOnly ?? !prev, workObservation: observeWork(base.workObservation, env) }
182
186
  const common = {
187
+ observationOnly: false,
188
+ workObservation: observeWork(base.workObservation, env),
183
189
  cwd: str(p.cwd) ?? base.cwd,
184
190
  transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
185
191
  permissionMode: str(p.permission_mode) ?? base.permissionMode,
@@ -285,6 +291,8 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
285
291
  return { ...next, subagentsOpen: next.subagentsOpen + 1 }
286
292
  case 'SubagentStop':
287
293
  return { ...next, subagentsOpen: Math.max(0, next.subagentsOpen - 1) }
294
+ case 'PreCompact':
295
+ return next
288
296
  case 'PostCompact':
289
297
  return { ...next, compactions: next.compactions + 1 }
290
298
  case 'PostModelSwitch':
@@ -313,6 +321,7 @@ export class SessionSignalStore {
313
321
  apply(env: HookEnvelope, child?: boolean): SessionSignal {
314
322
  const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
315
323
  this.signals.set(env.sessionId, next)
324
+ if (env.payload.display_only === true) return next // no drain, permission or halt listeners
316
325
  const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
317
326
  for (const listener of this.listeners) {
318
327
  try { listener(next, env, isChild) } catch (error) {
@@ -243,6 +243,7 @@ export function createTranscriptTailer(options: TranscriptTailerOptions): Transc
243
243
  let state: 'working' | 'idle' | 'done' | null = null
244
244
  let lastActivity = now()
245
245
  let degraded = false
246
+ let skippingCompaction = false
246
247
 
247
248
  const emit = (draft: SessionStreamDraft) => {
248
249
  try {
@@ -275,17 +276,40 @@ export function createTranscriptTailer(options: TranscriptTailerOptions): Transc
275
276
  // this design exists to avoid, so we rejoin at the new end and let the poll
276
277
  // fallback carry whatever the rotation took with it.
277
278
  cursor = { offset: size }
279
+ skippingCompaction = false
278
280
  return
279
281
  }
280
282
 
281
283
  if (size > cursor.offset) {
282
284
  const pending = size - cursor.offset
283
285
  const length = Math.min(pending, maxRead)
284
- const chunk = await readRangeAt(options.path, cursor.offset, length)
286
+ let chunk = await readRangeAt(options.path, cursor.offset, length)
285
287
  if (chunk !== null && chunk.length > 0) {
288
+ if (skippingCompaction) {
289
+ const end = chunk.indexOf(10)
290
+ if (end < 0) { cursor.offset += chunk.length; lastActivity = now(); return }
291
+ cursor.offset += end + 1
292
+ chunk = chunk.subarray(end + 1)
293
+ skippingCompaction = false
294
+ lastActivity = now()
295
+ if (!suppressed(options.key)) emit({ kind: 'status', state: 'working' })
296
+ if (!chunk.length) return
297
+ }
286
298
  const result = consumeTranscriptChunk(cursor, chunk, chunk.length >= maxRead)
287
299
  cursor = result.cursor
288
300
  if (result.tooLarge) {
301
+ // A Codex context replacement is internal bookkeeping, not a reply. Its
302
+ // replacement_history can exceed 4 MiB. Discard just this recognized
303
+ // top-level record incrementally, retaining heartbeats and later output.
304
+ const head = chunk.subarray(0, 512).toString('utf8')
305
+ if (options.provider === 'codex' && /^\s*\{\s*(?:"timestamp"\s*:\s*"[^"\r\n]*"\s*,\s*)?"type"\s*:\s*"compacted"\s*[,}]/.test(head)) {
306
+ skippingCompaction = true
307
+ cursor.offset += chunk.length
308
+ lastActivity = now()
309
+ state = 'working'
310
+ if (!suppressed(options.key)) emit({ kind: 'status', state: 'working', reasoning: 'Updating context' } as SessionStreamDraft)
311
+ return
312
+ }
289
313
  // HAND OFF, do not skip. `done` is the same terminal status a finished turn
290
314
  // sends, so the client already knows to close the stream and resume its
291
315
  // 5s/15s/60s poll -- no new client vocabulary, no frozen screen.
@@ -0,0 +1,46 @@
1
+ // Display-only evidence. Never decides whether a thread can be written or dequeued.
2
+ import type { HookEnvelope } from './session-hook-events.js'
3
+ import type { SubagentActivity } from './codex-subagent-activity.js'
4
+ export interface WorkObservation {
5
+ agents: Array<{id:string;at:number;state:'active'|'completed'}>
6
+ partial: boolean
7
+ compactingAt: number | null
8
+ phaseAt?: number
9
+ }
10
+ export function observeWork(previous: WorkObservation | undefined, env: HookEnvelope): WorkObservation {
11
+ const prev=previous ?? {agents:[],partial:false,compactingAt:null}
12
+ if (!Number.isFinite(env.ts) || env.ts <= 0 || env.ts > Date.now()+5000) return prev
13
+ const p=env.payload, id=typeof p.agent_id==='string' && /^[a-zA-Z0-9_-]{1,160}$/.test(p.agent_id) ? p.agent_id : null
14
+ if(env.event==='SubagentStart'||env.event==='SubagentStop') {
15
+ if(!id)return {...prev,partial:true}
16
+ const existing=prev.agents.find(a=>a.id===id)
17
+ if(existing && (existing.at>env.ts || (existing.at===env.ts && (existing.state==='completed' || env.event==='SubagentStart'))))return prev
18
+ if(!existing && prev.agents.length>=512)return {...prev,partial:true}
19
+ const row={id,at:env.ts,state:env.event==='SubagentStart'?'active' as const:'completed' as const}
20
+ return {...prev,agents:[...prev.agents.filter(a=>a.id!==id),row]}
21
+ }
22
+ if(id && ['PreToolUse','PostToolUse','PostToolUseFailure'].includes(env.event)) {
23
+ const existing=prev.agents.find(a=>a.id===id)
24
+ // Tool output may arrive after Stop; it must never reopen a completed child.
25
+ if(existing?.state==='active' && env.ts>existing.at)return {...prev,agents:prev.agents.map(a=>a.id===id?{...a,at:env.ts}:a)}
26
+ return prev
27
+ }
28
+ if(id)return prev // Child compaction must not label the parent as compacting.
29
+ if(env.ts<(prev.phaseAt??0))return prev
30
+ if(env.event==='PreCompact')return {...prev,compactingAt:env.ts,phaseAt:env.ts}
31
+ const resumes=['PostCompact','UserPromptSubmit','Stop','StopFailure','SessionEnd','PostToolUse','PostToolUseFailure'].includes(env.event)
32
+ || (env.event==='SessionStart' && p.source==='compact')
33
+ if(resumes)return {...prev,compactingAt:null,phaseAt:env.ts}
34
+ return prev
35
+ }
36
+ export function workObservationFields(observation: WorkObservation | undefined, now=Date.now()): {subagent_activity?:SubagentActivity;compaction?:{state:'compacting'|'unknown';started_at:number;checked_at:number}} {
37
+ if(!observation)return {}
38
+ const fields:ReturnType<typeof workObservationFields>={}
39
+ if(observation.agents.length||observation.partial){
40
+ const summary:SubagentActivity={active:0,completed:0,unknown:0,total:observation.agents.length,checked_at:now,partial:observation.partial}
41
+ for(const a of observation.agents)summary[a.state==='completed'?'completed':now-a.at<=300_000&&a.at<=now+5000?'active':'unknown']++
42
+ fields.subagent_activity=summary
43
+ }
44
+ if(observation.compactingAt!==null)fields.compaction={state:now-observation.compactingAt<=600_000?'compacting':'unknown',started_at:observation.compactingAt,checked_at:now}
45
+ return fields
46
+ }
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import { workObservationFields } from '../lib/session-work-observation.js'
2
+ import { readCodexSubagents } from '../lib/codex-subagent-activity.js'
1
3
  // GET /api/agent-sessions
2
4
  // GET /api/agent-sessions/search?q=
3
5
  // GET /api/agent-sessions/:provider/:sessionId
@@ -41,9 +43,9 @@ import {
41
43
  import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
42
44
  import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
43
45
  import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
44
- import { deriveForRow, signalFor } from '../lib/session-hooks-runtime.js'
46
+ import { deriveForRow, signalFor, workObservationFor } from '../lib/session-hooks-runtime.js'
45
47
  import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
46
- import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
48
+ import { queuedTurnsFields, allQueuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
47
49
  import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
48
50
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
49
51
  import {
@@ -83,7 +85,7 @@ function asSort(value: unknown): AgentSessionSort {
83
85
  return String(value ?? '').toLowerCase() === 'opened' ? 'opened' : 'updated'
84
86
  }
85
87
 
86
- function toSearchHit(row: AgentSessionSearchHit, queuedTurns = 0, derived?: DerivedSessionState) {
88
+ function toSearchHit(row: AgentSessionSearchHit, queuedTurns: number | null = 0, derived?: DerivedSessionState) {
87
89
  return {
88
90
  ...toEntry(row, undefined, derived, queuedTurns),
89
91
  snippet: row.snippet,
@@ -269,7 +271,7 @@ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: nu
269
271
  }
270
272
  }
271
273
 
272
- function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState, queuedTurns = 0) {
274
+ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState, queuedTurns: number | null = 0) {
273
275
  return {
274
276
  session_id: row.session_id,
275
277
  provider: row.provider,
@@ -397,9 +399,11 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
397
399
  if (row.provider !== 'cursor' || !signalFor(row.session_id)) continue
398
400
  derivedById.set(row.session_id, deriveForRow({ sessionId: row.session_id, now, remember: false }))
399
401
  }
400
- const queuedOf = queuedWaitingLookup(now)
402
+ const queuedOf = await allQueuedWaitingLookup(now)
403
+ const subagents = await readCodexSubagents(sessions.filter(row => row.provider === 'codex').map(row => row.session_id), roots.codexSessions, now)
401
404
  res.json({
402
- sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running)),
405
+ sessions: sessions.map((row, index) => ({ ...withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running),
406
+ ...(row.provider === 'codex' ? (subagents.has(row.session_id) ? { subagent_activity: subagents.get(row.session_id) } : {}) : workObservationFields(workObservationFor(row.session_id), now)) })),
403
407
  total: sessions.length,
404
408
  windowHours: AGENT_SESSION_WINDOW_HOURS,
405
409
  sort,
@@ -428,7 +432,7 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
428
432
  try {
429
433
  const limit = boundedInteger(req.query.limit, 20, 1, 50)
430
434
  const result = await searchAgentSessions({ query, limit })
431
- const queuedOf = queuedWaitingLookup(Date.now())
435
+ const queuedOf = await allQueuedWaitingLookup(Date.now())
432
436
  // 6.48.1: search hits carry the same derived state as list rows (no list/search shape
433
437
  // drift), from the registry and the hook signal; a hit has no transcript walk.
434
438
  const peers = await liveClaudePeerRecords()
@@ -511,6 +515,8 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
511
515
  const named = names.get(parsed.session_id) || names.get(sessionId)
512
516
  if (named) parsed.display_label = named
513
517
  }
518
+ const observed = provider === 'codex' ? {} : workObservationFields(workObservationFor(parsed.session_id))
519
+ const subagents = provider === 'codex' ? (await readCodexSubagents([sessionId], agentSessionRoots().codexSessions)).get(sessionId.toLowerCase()) : observed.subagent_activity
514
520
  const modified = st.mtime.toISOString()
515
521
  const activity = await readSessionActivity(provider, found)
516
522
  const running = runningForThread(provider, parsed.session_id, activityClockMs(st.mtimeMs, activity))
@@ -532,7 +538,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
532
538
  res.json({
533
539
  ...withRunning({ session_id: parsed.session_id }, running),
534
540
  ...derivedRowFields(derived),
535
- ...queuedTurnsFields(queuedWaitingLookup(Date.now())(provider, parsed.session_id)),
541
+ ...queuedTurnsFields((await allQueuedWaitingLookup(Date.now()))(provider, parsed.session_id)),
536
542
  // The client must be able to tell "this server stamped nothing" from "this
537
543
  // server stamped false", because the two demand opposite behaviour: an old
538
544
  // server's silence means keep using the hint borrowed from the list row, and
@@ -573,7 +579,9 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
573
579
  tools_used: {},
574
580
  files_touched: [],
575
581
  git_branch: parsed.git_branch || 'unknown',
576
- has_subagents: false,
582
+ has_subagents: (subagents?.total ?? 0) > 0,
583
+ ...observed,
584
+ ...(subagents ? { subagent_activity: subagents } : {}),
577
585
  total_input_tokens: 0,
578
586
  total_output_tokens: 0,
579
587
  file_size_bytes: parsed.file_size_bytes,
@@ -1,3 +1,4 @@
1
+ import { installCursorObserver } from '../lib/cursor-observer-installer.js'
1
2
  // Session hooks: status, install, uninstall, and today's runs.
2
3
  //
3
4
  // `GET /api/session-hooks/status` is what Control's banner keys off. Five outcomes on
@@ -32,7 +33,9 @@ export function createSessionHooksRouter(options: { port: number }): Router {
32
33
  res.status(409).json({ ok: false, reason: result.reason ?? 'install_failed', status: result.status })
33
34
  return
34
35
  }
35
- res.json({ ok: true, changed: result.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
36
+ const cursorObserver = installCursorObserver({ dryRun })
37
+ if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_install_failed', cursorObserver, status: result.status }); return }
38
+ res.json({ ok: true, cursorObserver, changed: result.changed || cursorObserver.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
36
39
  })
37
40
 
38
41
  router.post('/session-hooks/uninstall', (req, res) => {
@@ -43,7 +46,9 @@ export function createSessionHooksRouter(options: { port: number }): Router {
43
46
  res.status(409).json({ ok: false, reason: result.reason ?? 'uninstall_failed', status: result.status })
44
47
  return
45
48
  }
46
- res.json({ ok: true, changed: result.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
49
+ const cursorObserver = installCursorObserver({ dryRun, uninstall: true })
50
+ if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_uninstall_failed', cursorObserver, status: result.status }); return }
51
+ res.json({ ok: true, cursorObserver, changed: result.changed || cursorObserver.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
47
52
  })
48
53
 
49
54
  // Sessions the hooks saw start and end, for Control's scheduled-job ledger. A run
@@ -57,6 +62,7 @@ export function createSessionHooksRouter(options: { port: number }): Router {
57
62
  const all = req.query.all === '1'
58
63
  const runs: Array<Record<string, unknown>> = []
59
64
  for (const signal of sessionSignalStore.snapshot()) {
65
+ if (signal.observationOnly) continue
60
66
  if (signal.firstSeenAt < sinceMs && !(signal.ended && signal.ended.at >= sinceMs)) continue
61
67
  if (!all && isInteractiveEntrypoint(signal.entrypoint)) continue
62
68
  runs.push({
@@ -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)
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env tsx
2
+ import { installCursorObserver } from '../lib/cursor-observer-installer.js'
2
3
  // Install, inspect or remove the COS session hook in ~/.claude/settings.json.
3
4
  //
4
5
  // npx --yes @gotcos/glasses-server@latest --hooks install [--dry-run] [--port 3141]
@@ -40,13 +41,15 @@ if (action === 'status') {
40
41
  if (action === 'install') {
41
42
  if (!Number.isFinite(port) || port <= 0) { console.error('Bad --port'); process.exit(64) }
42
43
  const result = installClaudeHooks({ port, deskIdleSeconds: Number.isFinite(deskIdleSeconds) ? deskIdleSeconds : 90, dryRun })
43
- print({ action, dryRun, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...result })
44
- process.exit(result.ok ? 0 : 2)
44
+ const cursorObserver = result.ok ? installCursorObserver({ dryRun }) : undefined
45
+ print({ action, dryRun, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...result, cursorObserver })
46
+ process.exit(result.ok && cursorObserver?.ok ? 0 : 2)
45
47
  }
46
48
  if (action === 'uninstall') {
47
49
  const result = uninstallClaudeHooks({ dryRun })
48
- print({ action, dryRun, ...result })
49
- process.exit(result.ok ? 0 : 2)
50
+ const cursorObserver = result.ok ? installCursorObserver({ dryRun, uninstall: true }) : undefined
51
+ print({ action, dryRun, ...result, cursorObserver })
52
+ process.exit(result.ok && cursorObserver?.ok ? 0 : 2)
50
53
  }
51
54
  console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N] [--desk-idle-s N]')
52
55
  process.exit(64)