@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 +7 -0
- package/bin/hooks/cos-cursor-observer.cjs +29 -0
- package/package.json +3 -2
- package/server/lib/claude-hooks-installer.ts +6 -3
- package/server/lib/codex-subagent-activity.ts +119 -0
- package/server/lib/cursor-observer-installer.ts +38 -0
- package/server/lib/session-hook-events.ts +3 -0
- package/server/lib/session-hooks-runtime.ts +8 -1
- package/server/lib/session-signal-store.ts +9 -0
- package/server/lib/session-transcript-watcher.ts +25 -1
- package/server/lib/session-work-observation.ts +46 -0
- package/server/routes/agent-sessions.ts +11 -3
- package/server/routes/session-hooks.ts +8 -2
- package/server/scripts/hooks-cli.ts +7 -4
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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)
|