@gotcos/glasses-server 6.61.6 → 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,10 @@
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
+
1
8
  ## 6.61.6 — 2026-10-03
2
9
 
3
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.
@@ -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.6",
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,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,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,7 +43,7 @@ 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
48
  import { queuedTurnsFields, allQueuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
47
49
  import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
@@ -398,8 +400,10 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
398
400
  derivedById.set(row.session_id, deriveForRow({ sessionId: row.session_id, now, remember: false }))
399
401
  }
400
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,
@@ -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))
@@ -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({
@@ -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)