@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 +15 -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-queue-control.ts +100 -0
- 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/lib/thread-turn-queue-store.ts +11 -2
- package/server/routes/agent-sessions.ts +17 -9
- package/server/routes/session-hooks.ts +8 -2
- package/server/routes/thread-turn-queue.ts +59 -11
- package/server/scripts/hooks-cli.ts +7 -4
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.
|
|
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,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
|
-
|
|
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,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,
|
|
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 =
|
|
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 =
|
|
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(
|
|
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:
|
|
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({
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|