@gotcos/glasses-server 7.0.7 → 7.0.27
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/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions a COS job started in the last 7 days (Miles, 2026-11-10 23:16, via the lead: "Yes, keep for 7 days").
|
|
3
|
+
*
|
|
4
|
+
* The 0b spike found that a session started from Work or Ask drops out of GET /api/agent-sessions within hours on a
|
|
5
|
+
* busy Mac (the per-agent cap of 20 showed only the newest 4.2 hours of Claude sessions). The list keeps these, like a
|
|
6
|
+
* COS pin, inside the same bounded keep ceiling (agent-session-store.ts KEPT_LIST_MAX).
|
|
7
|
+
*
|
|
8
|
+
* Read from the query-job journal (data/query-jobs/<YYYY-MM-DD>.jsonl): each job's `running` or `completed` record
|
|
9
|
+
* names its provider and native session (`cliSessionId` for Claude and Cursor, `codexThreadId` for Codex). Only ids,
|
|
10
|
+
* times and the request's kind are read, never prompts or answers.
|
|
11
|
+
*
|
|
12
|
+
* Only jobs a PERSON started count (Q1, 2026-11-10 23:3x). Every job records a session id (54 of 54 in the 8 days
|
|
13
|
+
* measured), so the job's `accepted` record decides. It is an ALLOWLIST (Q1's nit, lead 23:44), so a new kind of
|
|
14
|
+
* background job fails safe: a job counts only when its request has no `origin` (Ask from phone, lens or web, Work
|
|
15
|
+
* starts, Continue) or `origin.kind === 'task'` (a dispatched task), and no `maintenanceWorkflow`. That leaves out
|
|
16
|
+
* the scheduler's routines (the morning brief: 6 of 54), meeting consolidation (2 of 54) and any kind added later.
|
|
17
|
+
* A session id whose job's request record is not in the window is left out too (only a job that ran for more than
|
|
18
|
+
* a day; failing safe). Each day file is memoized on mtime and size, and a file that keeps
|
|
19
|
+
* changing (a job writing its deltas) is re-read at most every 30 seconds, so the 5-second list poll stays cheap.
|
|
20
|
+
*/
|
|
21
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { dataPath } from './data-dir.js'
|
|
24
|
+
|
|
25
|
+
export const COS_STARTED_WINDOW_MS = 7 * 24 * 3600 * 1000
|
|
26
|
+
export const COS_STARTED_REREAD_MS = 30_000
|
|
27
|
+
type Provider = 'claude' | 'codex' | 'cursor'
|
|
28
|
+
interface Seen { provider: Provider; id: string; at: number; jobId: string }
|
|
29
|
+
interface DayMemo { mtimeMs: number; size: number; readAt: number; seen: Seen[]; person: string[] }
|
|
30
|
+
|
|
31
|
+
const memo = new Map<string, DayMemo>()
|
|
32
|
+
export const cosStartedStats = { reads: 0 }
|
|
33
|
+
export function __resetCosStartedForTests(): void { memo.clear(); cosStartedStats.reads = 0 }
|
|
34
|
+
|
|
35
|
+
const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
|
|
36
|
+
const SAFE_ID = /^[A-Za-z0-9-]{8,64}$/
|
|
37
|
+
|
|
38
|
+
/** True only for a request a person started: no origin, or a dispatched task; never a maintenance workflow. */
|
|
39
|
+
export function isPersonJobRequest(request: unknown): boolean {
|
|
40
|
+
if (!request || typeof request !== 'object') return false
|
|
41
|
+
const r = request as { origin?: unknown; maintenanceWorkflow?: unknown }
|
|
42
|
+
if (r.maintenanceWorkflow != null) return false
|
|
43
|
+
if (r.origin == null) return true
|
|
44
|
+
return typeof r.origin === 'object' && (r.origin as { kind?: unknown }).kind === 'task'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function idsIn(text: string): { seen: Seen[]; person: string[] } {
|
|
48
|
+
const out: Seen[] = []
|
|
49
|
+
const person: string[] = []
|
|
50
|
+
for (const line of text.split('\n')) {
|
|
51
|
+
const hasRequest = line.includes('"request"')
|
|
52
|
+
if (!hasRequest && !line.includes('SessionId') && !line.includes('ThreadId')) continue
|
|
53
|
+
let record: any
|
|
54
|
+
try { record = JSON.parse(line) } catch { continue }
|
|
55
|
+
const jobId = typeof record?.jobId === 'string' ? record.jobId : ''
|
|
56
|
+
if (hasRequest && jobId && isPersonJobRequest(record?.request)) person.push(jobId)
|
|
57
|
+
const patch = record?.patch
|
|
58
|
+
if (!patch || typeof patch !== 'object') continue
|
|
59
|
+
const provider = patch.provider
|
|
60
|
+
const at = Date.parse(record.persistedAt ?? '')
|
|
61
|
+
const id = provider === 'codex' ? patch.codexThreadId : patch.cliSessionId
|
|
62
|
+
if ((provider === 'claude' || provider === 'codex' || provider === 'cursor') && typeof id === 'string' && SAFE_ID.test(id)) {
|
|
63
|
+
out.push({ provider, id: id.toLowerCase(), at: Number.isFinite(at) ? at : 0, jobId })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { seen: out, person }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Native session ids per provider that a COS job started within `windowMs` of `now`. Never throws. */
|
|
70
|
+
export async function cosStartedSessionIds(now = Date.now(), dir = dataPath('query-jobs'), windowMs = COS_STARTED_WINDOW_MS): Promise<Record<Provider, Set<string>>> {
|
|
71
|
+
const result: Record<Provider, Set<string>> = { claude: new Set(), codex: new Set(), cursor: new Set() }
|
|
72
|
+
let names: string[]
|
|
73
|
+
try { names = await readdir(dir) } catch { return result }
|
|
74
|
+
// One day of slack: a job's day file is its own partition day, and the window is measured from its record time.
|
|
75
|
+
const oldestDay = new Date(now - windowMs - 24 * 3600 * 1000).toISOString().slice(0, 10)
|
|
76
|
+
const memos: DayMemo[] = []
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const day = DAY_FILE.exec(name)?.[1]
|
|
79
|
+
if (!day || day < oldestDay) continue
|
|
80
|
+
const file = join(dir, name)
|
|
81
|
+
try {
|
|
82
|
+
const st = await stat(file)
|
|
83
|
+
if (!st.isFile()) continue
|
|
84
|
+
let m = memo.get(file)
|
|
85
|
+
const changed = !m || m.mtimeMs !== st.mtimeMs || m.size !== st.size
|
|
86
|
+
if (changed && (!m || now - m.readAt >= COS_STARTED_REREAD_MS)) {
|
|
87
|
+
cosStartedStats.reads++
|
|
88
|
+
m = { mtimeMs: st.mtimeMs, size: st.size, readAt: now, ...idsIn(await readFile(file, 'utf8')) }
|
|
89
|
+
memo.set(file, m)
|
|
90
|
+
}
|
|
91
|
+
if (m) memos.push(m)
|
|
92
|
+
} catch { /* a day file that cannot be read adds nothing */ }
|
|
93
|
+
}
|
|
94
|
+
// A job's request and its session id can sit in different day files, so person-started jobs are gathered first.
|
|
95
|
+
const person = new Set(memos.flatMap(m => m.person))
|
|
96
|
+
for (const m of memos) {
|
|
97
|
+
for (const s of m.seen) if (now - s.at <= windowMs && person.has(s.jobId)) result[s.provider].add(s.id)
|
|
98
|
+
}
|
|
99
|
+
for (const key of memo.keys()) if (!names.includes(key.split('/').pop() || '')) memo.delete(key)
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in per-path disk I/O meter (S1, 2026-11-09). COS_DISK_IO_METER=1 turns it on; default off.
|
|
3
|
+
*
|
|
4
|
+
* Why: macOS filed 12 "disk writes" resource reports against the server's node process
|
|
5
|
+
* (2.1 to 8.6 GB over 0.5 to 6 h) with JavaScript frames unsymbolicated, so no report could
|
|
6
|
+
* name a file. Measured from outside on 11/09 (proc_pid_rusage, idle, no meeting): 111.7 KB/s
|
|
7
|
+
* written and about 1.8 to 5 MB/s read. The largest writer (work-reviews/reviews.json) is fixed;
|
|
8
|
+
* the rest could not be attributed without root. With the meter on, the server logs once an
|
|
9
|
+
* hour which paths took the bytes:
|
|
10
|
+
*
|
|
11
|
+
* [disk-io] 60 min: wrote 152.3 MB in 1,204 writes, read 3.1 GB in 9,880 reads; top writes: ...
|
|
12
|
+
*
|
|
13
|
+
* How: wraps the synchronous and promise write/read entry points of node:fs and
|
|
14
|
+
* node:fs/promises, then calls syncBuiltinESMExports() so modules that already imported
|
|
15
|
+
* named bindings see the wrappers. Each wrapper calls the original and only counts bytes;
|
|
16
|
+
* errors and return values pass through unchanged. Streams (createWriteStream) are not
|
|
17
|
+
* counted. Paths are folded so ids and temp suffixes group: "~/.cos-glasses/data/active-sessions/*.json".
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import fsPromises from 'node:fs/promises'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { syncBuiltinESMExports } from 'node:module'
|
|
23
|
+
|
|
24
|
+
interface Tally { bytes: number; ops: number }
|
|
25
|
+
interface Window { startedAt: number; writes: Map<string, Tally>; reads: Map<string, Tally> }
|
|
26
|
+
|
|
27
|
+
let windowState: Window | null = null
|
|
28
|
+
let restore: (() => void) | null = null
|
|
29
|
+
let reportTimer: ReturnType<typeof setInterval> | null = null
|
|
30
|
+
const fdPaths = new Map<number, string>()
|
|
31
|
+
/** Node's own fs functions call each other (appendFileSync calls writeFileSync, which calls
|
|
32
|
+
* openSync and writeSync). Only the outermost wrapped call counts. */
|
|
33
|
+
let depth = 0
|
|
34
|
+
function nested<T>(run: () => T): T {
|
|
35
|
+
depth++
|
|
36
|
+
try { return run() } finally { depth-- }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HOME = homedir()
|
|
40
|
+
|
|
41
|
+
/** Group a path: home as ~, temp suffixes dropped, id-like segments as *. */
|
|
42
|
+
export function foldPath(path: string): string {
|
|
43
|
+
let p = path.startsWith(HOME) ? `~${path.slice(HOME.length)}` : path
|
|
44
|
+
p = p.replace(/(\.\d+\.[0-9a-f]+)?\.tmp$/i, '') // atomic-write temp names
|
|
45
|
+
p = p.replace(/\.corrupt-\d+$/, '.corrupt-*')
|
|
46
|
+
p = p.replace(/(^|[/._-])[0-9a-f]{8,}(?=$|[/._-])/gi, '$1*') // hashes, uuids, hex ids
|
|
47
|
+
p = p.replace(/(^|[/._-])\d{6,}(?=$|[/._-])/g, '$1*') // epoch ms, pids in names
|
|
48
|
+
p = p.replace(/meeting_\*_[a-z0-9]+/gi, 'meeting_*')
|
|
49
|
+
return p
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function byteLength(data: unknown, encoding?: unknown): number {
|
|
53
|
+
if (typeof data === 'string') return Buffer.byteLength(data, typeof encoding === 'string' ? encoding as BufferEncoding : 'utf8')
|
|
54
|
+
if (data && typeof (data as ArrayBufferView).byteLength === 'number') return (data as ArrayBufferView).byteLength
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function keyFor(target: unknown): string {
|
|
59
|
+
if (typeof target === 'number') return fdPaths.get(target) ?? `fd:${target}`
|
|
60
|
+
if (typeof target === 'string') return foldPath(target)
|
|
61
|
+
if (target instanceof URL) return foldPath(target.pathname)
|
|
62
|
+
if (Buffer.isBuffer(target)) return foldPath(target.toString())
|
|
63
|
+
if (target && typeof (target as { fd?: unknown }).fd === 'number') return fdPaths.get((target as { fd: number }).fd) ?? 'filehandle'
|
|
64
|
+
return 'unknown'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function count(kind: 'writes' | 'reads', target: unknown, bytes: number, outer: boolean): void {
|
|
68
|
+
if (!windowState || bytes <= 0 || !outer) return
|
|
69
|
+
const map = windowState[kind]
|
|
70
|
+
const key = keyFor(target)
|
|
71
|
+
const tally = map.get(key)
|
|
72
|
+
if (tally) { tally.bytes += bytes; tally.ops++ } else map.set(key, { bytes, ops: 1 })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DiskIoReport {
|
|
76
|
+
windowMs: number
|
|
77
|
+
wroteBytes: number; writeOps: number
|
|
78
|
+
readBytes: number; readOps: number
|
|
79
|
+
topWrites: Array<{ path: string; bytes: number; ops: number }>
|
|
80
|
+
topReads: Array<{ path: string; bytes: number; ops: number }>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The current window's totals (diagnostics, tests). Null when the meter is off. */
|
|
84
|
+
export function diskIoSnapshot(top = 10): DiskIoReport | null {
|
|
85
|
+
if (!windowState) return null
|
|
86
|
+
const sum = (m: Map<string, Tally>) => [...m.values()].reduce((a, t) => ({ bytes: a.bytes + t.bytes, ops: a.ops + t.ops }), { bytes: 0, ops: 0 })
|
|
87
|
+
const rank = (m: Map<string, Tally>) => [...m.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, top)
|
|
88
|
+
.map(([path, t]) => ({ path, bytes: t.bytes, ops: t.ops }))
|
|
89
|
+
const w = sum(windowState.writes), r = sum(windowState.reads)
|
|
90
|
+
return {
|
|
91
|
+
windowMs: Date.now() - windowState.startedAt,
|
|
92
|
+
wroteBytes: w.bytes, writeOps: w.ops, readBytes: r.bytes, readOps: r.ops,
|
|
93
|
+
topWrites: rank(windowState.writes), topReads: rank(windowState.reads),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mb(bytes: number): string {
|
|
98
|
+
return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${(bytes / 1e6).toFixed(1)} MB`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One log line for the window, then start a new window. */
|
|
102
|
+
export function reportDiskIo(log: (line: string) => void = line => console.log(line)): DiskIoReport | null {
|
|
103
|
+
const report = diskIoSnapshot(8)
|
|
104
|
+
if (!report || !windowState) return null
|
|
105
|
+
const fmt = (rows: DiskIoReport['topWrites']) => rows.map(r => `${r.path} ${mb(r.bytes)} (${r.ops})`).join('; ') || 'none'
|
|
106
|
+
log(`[disk-io] ${Math.round(report.windowMs / 60_000)} min: wrote ${mb(report.wroteBytes)} in ${report.writeOps} writes, `
|
|
107
|
+
+ `read ${mb(report.readBytes)} in ${report.readOps} reads; top writes: ${fmt(report.topWrites)}; top reads: ${fmt(report.topReads)}`)
|
|
108
|
+
windowState = { startedAt: Date.now(), writes: new Map(), reads: new Map() }
|
|
109
|
+
return report
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function diskIoMeterRequested(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
113
|
+
return env.COS_DISK_IO_METER === '1'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Install the wrappers. Idempotent. Returns false when already installed. */
|
|
117
|
+
export function installDiskIoMeter(options: { reportEveryMs?: number } = {}): boolean {
|
|
118
|
+
if (restore) return false
|
|
119
|
+
windowState = { startedAt: Date.now(), writes: new Map(), reads: new Map() }
|
|
120
|
+
const f = fs as unknown as Record<string, any>
|
|
121
|
+
const p = fsPromises as unknown as Record<string, any>
|
|
122
|
+
const saved: Array<[Record<string, any>, string, unknown]> = []
|
|
123
|
+
const wrap = (obj: Record<string, any>, name: string, make: (orig: any) => any) => {
|
|
124
|
+
const orig = obj[name]
|
|
125
|
+
if (typeof orig !== 'function') return
|
|
126
|
+
saved.push([obj, name, orig])
|
|
127
|
+
obj[name] = make(orig)
|
|
128
|
+
}
|
|
129
|
+
wrap(f, 'openSync', orig => function (this: unknown, path: unknown, ...rest: unknown[]) {
|
|
130
|
+
const fd = nested(() => orig.call(this, path, ...rest))
|
|
131
|
+
if (typeof fd === 'number' && (typeof path === 'string' || path instanceof URL)) fdPaths.set(fd, keyFor(path))
|
|
132
|
+
return fd
|
|
133
|
+
})
|
|
134
|
+
wrap(f, 'closeSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
|
|
135
|
+
fdPaths.delete(fd)
|
|
136
|
+
return orig.call(this, fd, ...rest)
|
|
137
|
+
})
|
|
138
|
+
wrap(f, 'writeFileSync', orig => function (this: unknown, target: unknown, data: unknown, options?: unknown) {
|
|
139
|
+
const outer = depth === 0
|
|
140
|
+
const result = nested(() => orig.call(this, target, data, options))
|
|
141
|
+
count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), outer)
|
|
142
|
+
return result
|
|
143
|
+
})
|
|
144
|
+
wrap(f, 'appendFileSync', orig => function (this: unknown, target: unknown, data: unknown, options?: unknown) {
|
|
145
|
+
const outer = depth === 0
|
|
146
|
+
const result = nested(() => orig.call(this, target, data, options))
|
|
147
|
+
count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), outer)
|
|
148
|
+
return result
|
|
149
|
+
})
|
|
150
|
+
wrap(f, 'writeSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
|
|
151
|
+
const outer = depth === 0
|
|
152
|
+
const written = nested(() => orig.call(this, fd, ...rest))
|
|
153
|
+
count('writes', fd, typeof written === 'number' ? written : 0, outer)
|
|
154
|
+
return written
|
|
155
|
+
})
|
|
156
|
+
wrap(f, 'readFileSync', orig => function (this: unknown, target: unknown, ...rest: unknown[]) {
|
|
157
|
+
const outer = depth === 0
|
|
158
|
+
const result = nested(() => orig.call(this, target, ...rest))
|
|
159
|
+
count('reads', target, byteLength(result), outer)
|
|
160
|
+
return result
|
|
161
|
+
})
|
|
162
|
+
wrap(f, 'readSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
|
|
163
|
+
const outer = depth === 0
|
|
164
|
+
const read = nested(() => orig.call(this, fd, ...rest))
|
|
165
|
+
count('reads', fd, typeof read === 'number' ? read : 0, outer)
|
|
166
|
+
return read
|
|
167
|
+
})
|
|
168
|
+
for (const name of ['writeFile', 'appendFile'] as const) {
|
|
169
|
+
wrap(p, name, orig => async function (this: unknown, target: unknown, data: unknown, options?: unknown) {
|
|
170
|
+
const result = await orig.call(this, target, data, options)
|
|
171
|
+
count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), true)
|
|
172
|
+
return result
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
wrap(p, 'readFile', orig => async function (this: unknown, target: unknown, ...rest: unknown[]) {
|
|
176
|
+
const result = await orig.call(this, target, ...rest)
|
|
177
|
+
count('reads', target, byteLength(result), true)
|
|
178
|
+
return result
|
|
179
|
+
})
|
|
180
|
+
syncBuiltinESMExports()
|
|
181
|
+
restore = () => {
|
|
182
|
+
for (const [obj, name, orig] of saved) obj[name] = orig
|
|
183
|
+
syncBuiltinESMExports()
|
|
184
|
+
}
|
|
185
|
+
const every = options.reportEveryMs ?? 60 * 60_000
|
|
186
|
+
if (every > 0) {
|
|
187
|
+
reportTimer = setInterval(() => { reportDiskIo() }, every)
|
|
188
|
+
reportTimer.unref?.()
|
|
189
|
+
}
|
|
190
|
+
console.log(`[disk-io] meter on (COS_DISK_IO_METER=1): ${every > 0 ? `one line every ${Math.round(every / 60_000)} min` : 'reports on request only'}`)
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Remove the wrappers (tests, and a clean shutdown). */
|
|
195
|
+
export function uninstallDiskIoMeter(): void {
|
|
196
|
+
if (reportTimer) clearInterval(reportTimer)
|
|
197
|
+
reportTimer = null
|
|
198
|
+
restore?.()
|
|
199
|
+
restore = null
|
|
200
|
+
windowState = null
|
|
201
|
+
fdPaths.clear()
|
|
202
|
+
}
|
|
@@ -97,7 +97,21 @@ const filesLine = (n: number): string => n === 1 ? 'The file stays here in the C
|
|
|
97
97
|
const nextAsk = (drop: DropRecord): DropNextAsk =>
|
|
98
98
|
({ kind: 'ask', prompt: drop.text, ...(drop.attachments?.length ? { attachmentIds: drop.attachments.map(a => a.id) } : {}) })
|
|
99
99
|
/** One item that waits on an answer: a live question, or a live item marked needsReply (6.99.10). */
|
|
100
|
-
|
|
100
|
+
/**
|
|
101
|
+
* 7.0.9 (Miles 11/08, "Expire after 7 days"): an item that asks for a reply stops waiting on you this long after it
|
|
102
|
+
* was asked (its own createdAt, never when it was read). It is not answered or closed: it moves to the chat's older
|
|
103
|
+
* questions (GET /api/inbox/archive), where it can still be read and answered, and answering it there clears it.
|
|
104
|
+
* The one rule for every count: `asked`, the chat part of `waiting`, `archived`, and the web's split.
|
|
105
|
+
*/
|
|
106
|
+
export const WAITING_EXPIRY_DAYS = 7
|
|
107
|
+
export const WAITING_EXPIRY_MS = WAITING_EXPIRY_DAYS * 86_400_000
|
|
108
|
+
const asksForReply = (i: FeedItem): boolean => i.state === 'live' && (i.kind === 'question' || i.needsReply === true)
|
|
109
|
+
/** How long ago it was asked, in ms; an unreadable time reads as just asked (it keeps waiting rather than vanish). */
|
|
110
|
+
const askedAgo = (i: FeedItem, nowMs: number): number => { const t = Date.parse(i.createdAt); return Number.isFinite(t) ? nowMs - t : 0 }
|
|
111
|
+
export const isWaiting = (i: FeedItem, nowMs: number = Date.now()): boolean => asksForReply(i) && askedAgo(i, nowMs) < WAITING_EXPIRY_MS
|
|
112
|
+
/** Asks for a reply, but was asked WAITING_EXPIRY_DAYS or more ago: shown under older questions, never counted. */
|
|
113
|
+
export const isArchivedQuestion = (i: FeedItem, nowMs: number = Date.now()): boolean => asksForReply(i) && askedAgo(i, nowMs) >= WAITING_EXPIRY_MS
|
|
114
|
+
export const ARCHIVE_LIST_MAX = 200
|
|
101
115
|
const A = {
|
|
102
116
|
undo: { id: 'undo', label: 'Undo' }, move: { id: 'move', label: 'Move' },
|
|
103
117
|
openCard: { id: 'open_card', label: 'Open card' }, openThread: { id: 'open_thread', label: 'Open thread' },
|
|
@@ -559,14 +573,24 @@ export class DropService {
|
|
|
559
573
|
* waits on an answer from you, and clears only when it is answered, done or dismissed: every live question COS asked
|
|
560
574
|
* (a router ask), every live item marked `needsReply` (a follow-up that needs a decision, a ping that asks), plus
|
|
561
575
|
* `yourMove`, the Work items in Your move, when the caller could count them. Always a whole number, never negative.
|
|
576
|
+
*
|
|
577
|
+
* 7.0.9 (Miles 11/08: the web rail said 99+ and the pinned row "286 waiting" for things he had already seen): two
|
|
578
|
+
* more fields, so a client can say what each number is. `asked` is the chat's own part of `waiting`, the live
|
|
579
|
+
* questions and needsReply items, which truly wait on a reply in this chat. `yourMove` is the Work part (null when
|
|
580
|
+
* it could not be counted). `waiting` keeps its 6.99.10 meaning for COS Control, which reads it by that name.
|
|
562
581
|
*/
|
|
563
|
-
summary(extra: { yourMove?: number | null } = {}): { unread: number; waiting: number; lastLine: string; lastAt: string | null; version: number } {
|
|
582
|
+
summary(extra: { yourMove?: number | null } = {}): { unread: number; waiting: number; asked: number; archived: number; waitingExpiryDays: number; yourMove: number | null; lastLine: string; lastAt: string | null; version: number } {
|
|
564
583
|
const items = this.deps.feed.list()
|
|
584
|
+
const nowMs = this.now().getTime()
|
|
565
585
|
const unread = items.filter(i => i.readAt === null && (i.kind === 'ping' || i.kind === 'followup' || i.kind === 'question')).length
|
|
566
|
-
|
|
567
|
-
|
|
586
|
+
// 7.0.9: a question asked WAITING_EXPIRY_DAYS ago or more is in older questions, out of `asked` and so out of the
|
|
587
|
+
// chat part of `waiting`. Work's part of `waiting` (yourMove) is untouched.
|
|
588
|
+
const asked = items.filter(i => isWaiting(i, nowMs)).length
|
|
589
|
+
const archived = items.filter(i => isArchivedQuestion(i, nowMs)).length
|
|
590
|
+
const counted = typeof extra.yourMove === 'number' && Number.isSafeInteger(extra.yourMove) && extra.yourMove >= 0
|
|
591
|
+
const work = counted && (extra.yourMove as number) > 0 ? extra.yourMove as number : 0
|
|
568
592
|
const last = items[items.length - 1]
|
|
569
|
-
return { unread, waiting: asked + work, lastLine: last ? short(pinLine(last), 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
|
|
593
|
+
return { unread, waiting: asked + work, asked, archived, waitingExpiryDays: WAITING_EXPIRY_DAYS, yourMove: counted ? extra.yourMove as number : null, lastLine: last ? short(pinLine(last), 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
|
|
570
594
|
}
|
|
571
595
|
|
|
572
596
|
/**
|
|
@@ -574,7 +598,7 @@ export class DropService {
|
|
|
574
598
|
* its buttons. Only an item marked `needsReply` can be dismissed; a question is answered by one of its choices
|
|
575
599
|
* (Just a note among them). Idempotent: a second dismiss answers the same.
|
|
576
600
|
*/
|
|
577
|
-
dismiss(itemId: string): { unread: number; waiting: number; version: number } {
|
|
601
|
+
dismiss(itemId: string): { unread: number; waiting: number; asked: number; version: number } {
|
|
578
602
|
const item = this.deps.feed.get(itemId)
|
|
579
603
|
if (!item) throw new DropError('feed_item_not_found', 404)
|
|
580
604
|
if (!item.needsReply) throw new DropError('not_dismissable', 409, 'Only something that waits on you can be dismissed.')
|
|
@@ -583,11 +607,21 @@ export class DropService {
|
|
|
583
607
|
this.deps.feed.update(new Set([item.id]), i => ({ ...i, state: 'done', actions: [], readAt: i.readAt ?? at }))
|
|
584
608
|
}
|
|
585
609
|
const s = this.summary()
|
|
586
|
-
return { unread: s.unread, waiting: s.waiting, version: s.version }
|
|
610
|
+
return { unread: s.unread, waiting: s.waiting, asked: s.asked, version: s.version }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* 7.0.9: the older questions, newest first: what still asks for a reply but was asked WAITING_EXPIRY_DAYS or more
|
|
615
|
+
* ago. Each answers exactly as in the chat (POST /api/inbox/action), and answering it takes it off this list.
|
|
616
|
+
*/
|
|
617
|
+
archive(): { items: Array<FeedItem & { archived: true }>; total: number; waitingExpiryDays: number; version: number } {
|
|
618
|
+
const nowMs = this.now().getTime()
|
|
619
|
+
const all = this.deps.feed.list().filter(i => isArchivedQuestion(i, nowMs)).reverse()
|
|
620
|
+
return { items: all.slice(0, ARCHIVE_LIST_MAX).map(i => ({ ...i, archived: true as const })), total: all.length, waitingExpiryDays: WAITING_EXPIRY_DAYS, version: this.deps.feed.version() }
|
|
587
621
|
}
|
|
588
622
|
|
|
589
|
-
/** The chat, oldest first. `after` is an item id: only items after it. */
|
|
590
|
-
feed(options: { after?: string; limit?: number } = {}): { items: FeedItem
|
|
623
|
+
/** The chat, oldest first. `after` is an item id: only items after it. 7.0.9: an older question carries `archived: true`. */
|
|
624
|
+
feed(options: { after?: string; limit?: number } = {}): { items: Array<FeedItem & { archived?: true }>; version: number; more: boolean } {
|
|
591
625
|
const items = this.deps.feed.list()
|
|
592
626
|
let start = 0
|
|
593
627
|
if (options.after) {
|
|
@@ -599,7 +633,8 @@ export class DropService {
|
|
|
599
633
|
const rest = items.slice(start)
|
|
600
634
|
// Without `after` the newest page; with it, the next page forward.
|
|
601
635
|
const page = options.after ? rest.slice(0, limit) : rest.slice(-limit)
|
|
602
|
-
|
|
636
|
+
const nowMs = this.now().getTime()
|
|
637
|
+
return { items: page.map(i => isArchivedQuestion(i, nowMs) ? { ...i, archived: true as const } : i), version: this.deps.feed.version(), more: options.after ? rest.length > limit : rest.length > limit }
|
|
603
638
|
}
|
|
604
639
|
|
|
605
640
|
/** The drop a chat item belongs to. */
|
|
@@ -609,7 +644,7 @@ export class DropService {
|
|
|
609
644
|
return item.threadId
|
|
610
645
|
}
|
|
611
646
|
|
|
612
|
-
markRead(through: string): { unread: number; waiting: number; version: number } {
|
|
647
|
+
markRead(through: string): { unread: number; waiting: number; asked: number; version: number } {
|
|
613
648
|
const items = this.deps.feed.list()
|
|
614
649
|
const index = items.findIndex(i => i.id === through)
|
|
615
650
|
if (index < 0) throw new DropError('feed_item_not_found', 404)
|
|
@@ -618,7 +653,7 @@ export class DropService {
|
|
|
618
653
|
if (ids.size) this.deps.feed.update(ids, item => ({ ...item, readAt: at }))
|
|
619
654
|
const s = this.summary()
|
|
620
655
|
// Opening the chat clears unread only: what waits on you stays until it is answered, done or dismissed.
|
|
621
|
-
return { unread: s.unread, waiting: s.waiting, version: s.version }
|
|
656
|
+
return { unread: s.unread, waiting: s.waiting, asked: s.asked, version: s.version }
|
|
622
657
|
}
|
|
623
658
|
|
|
624
659
|
/** History (plan section 6), P1: words over drop text and link labels, with kind and status filters, newest first. */
|
package/server/lib/drop-store.ts
CHANGED
|
@@ -387,6 +387,13 @@ export class InboxFeedStore {
|
|
|
387
387
|
private items: FeedItem[] = []
|
|
388
388
|
private quarantined: unknown[] = []
|
|
389
389
|
private versionNo = 0
|
|
390
|
+
/**
|
|
391
|
+
* 7.0.9 (Miles 11/08, "99 plus messages, despite the fact that I've already seen all of those"): the moment this
|
|
392
|
+
* feed started counting unread as net new. Every item still unread when the first build with it opened the file is
|
|
393
|
+
* marked read once, at that open, so the rail never counts what was already on screen. A file this build wrote
|
|
394
|
+
* carries it; a file without it (an older build wrote it last) is migrated on open.
|
|
395
|
+
*/
|
|
396
|
+
private readFloorAt: string
|
|
390
397
|
private readonly path: string
|
|
391
398
|
private readonly lock: ServerInstanceLock
|
|
392
399
|
private closed = false
|
|
@@ -400,6 +407,19 @@ export class InboxFeedStore {
|
|
|
400
407
|
this.versionNo = Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : this.items.length
|
|
401
408
|
} catch (e) { this.close(); throw e }
|
|
402
409
|
if (this.quarantined.length) console.warn(`[inbox] ${this.quarantined.length} unreadable feed item(s) set aside`)
|
|
410
|
+
const floor = opened.journal?.readFloorAt
|
|
411
|
+
this.readFloorAt = this.now().toISOString()
|
|
412
|
+
if (typeof floor === 'string' && Number.isFinite(Date.parse(floor))) this.readFloorAt = floor
|
|
413
|
+
else if (opened.journal) this.migrateReadFloor()
|
|
414
|
+
}
|
|
415
|
+
/** When unread started meaning net new on this feed (see readFloorAt). */
|
|
416
|
+
readFloor(): string { return this.readFloorAt }
|
|
417
|
+
/** Once per file: what was unread before this build opened it had already been shown, so it reads as read now. */
|
|
418
|
+
private migrateReadFloor(): void {
|
|
419
|
+
const at = this.readFloorAt
|
|
420
|
+
const next = this.items.map(i => i.readAt === null ? { ...i, readAt: at } : i)
|
|
421
|
+
// A file that cannot be written now still opens; the next open tries again. Unread never blocks the chat.
|
|
422
|
+
try { this.write(next, 1) } catch (e) { console.warn(`[inbox] read floor not written: ${(e as Error).message}`) }
|
|
403
423
|
}
|
|
404
424
|
/** Oldest first, as a chat reads. */
|
|
405
425
|
list(): FeedItem[] { return structuredClone(this.items) }
|
|
@@ -455,7 +475,7 @@ export class InboxFeedStore {
|
|
|
455
475
|
if (kept.length > DROP_LIMITS.feed) throw new DropStoreError('inbox_store_full', 503)
|
|
456
476
|
const version = this.versionNo + bump
|
|
457
477
|
// Never write a file the reader would refuse: the oldest COS lines (receipts, answers, pings) go first.
|
|
458
|
-
const serialize = () => JSON.stringify({ version: 1, feedVersion: version, items: kept, quarantined: this.quarantined }) + '\n'
|
|
478
|
+
const serialize = () => JSON.stringify({ version: 1, feedVersion: version, readFloorAt: this.readFloorAt, items: kept, quarantined: this.quarantined }) + '\n'
|
|
459
479
|
let body = serialize()
|
|
460
480
|
while (Buffer.byteLength(body) > this.maxBytes) {
|
|
461
481
|
const oldest = kept.findIndex(i => i.kind !== 'drop')
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-loop stall detector (S1, 2026-11-09). Logs only when the server's event loop was blocked.
|
|
3
|
+
*
|
|
4
|
+
* Why: COS Control's dictation asks GET /api/transcribe/capabilities with a 5 s timeout. Six
|
|
5
|
+
* dictations (11/03 13:01 CST, 11/04 15:32, and four between 18:12 and 18:32 on 11/04) failed at
|
|
6
|
+
* 5,052 to 5,072 ms with no answer, logged by Control as "refused:unknown". That route is a
|
|
7
|
+
* constant JSON object, so a 5 s silence means the process did not get to it: a blocked event
|
|
8
|
+
* loop, a process mid-restart, or a Mac under memory pressure. Nothing in the server logs could
|
|
9
|
+
* tell those apart. This line can:
|
|
10
|
+
*
|
|
11
|
+
* [event-loop] stalled 6230 ms (rss 1840 MB, heap 910 MB; 1 stall(s) over 2000 ms since boot)
|
|
12
|
+
*
|
|
13
|
+
* A 500 ms unref'd timer measures its own lateness. A late tick above the threshold is one stall.
|
|
14
|
+
* At most one line per 10 s; stalls in between are counted into the next line.
|
|
15
|
+
*/
|
|
16
|
+
export const STALL_SAMPLE_MS = 500
|
|
17
|
+
export const STALL_THRESHOLD_MS = 2_000
|
|
18
|
+
const LOG_EVERY_MS = 10_000
|
|
19
|
+
|
|
20
|
+
export interface StallMonitor {
|
|
21
|
+
stop(): void
|
|
22
|
+
stats(): { stalls: number; worstMs: number }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function startEventLoopStallMonitor(options: {
|
|
26
|
+
sampleMs?: number
|
|
27
|
+
thresholdMs?: number
|
|
28
|
+
now?: () => number
|
|
29
|
+
log?: (line: string) => void
|
|
30
|
+
memory?: () => { rss: number; heapUsed: number }
|
|
31
|
+
} = {}): StallMonitor {
|
|
32
|
+
const sampleMs = options.sampleMs ?? STALL_SAMPLE_MS
|
|
33
|
+
const thresholdMs = options.thresholdMs ?? STALL_THRESHOLD_MS
|
|
34
|
+
const now = options.now ?? (() => performance.now())
|
|
35
|
+
const log = options.log ?? (line => console.warn(line))
|
|
36
|
+
const memory = options.memory ?? (() => process.memoryUsage())
|
|
37
|
+
let last = now()
|
|
38
|
+
let stalls = 0
|
|
39
|
+
let worstMs = 0
|
|
40
|
+
let lastLogAt = -Infinity
|
|
41
|
+
let unlogged = 0
|
|
42
|
+
const timer = setInterval(() => {
|
|
43
|
+
const at = now()
|
|
44
|
+
const lateMs = at - last - sampleMs
|
|
45
|
+
last = at
|
|
46
|
+
if (lateMs < thresholdMs) return
|
|
47
|
+
stalls++
|
|
48
|
+
worstMs = Math.max(worstMs, lateMs)
|
|
49
|
+
if (at - lastLogAt < LOG_EVERY_MS) { unlogged++; return }
|
|
50
|
+
lastLogAt = at
|
|
51
|
+
const m = memory()
|
|
52
|
+
const extra = unlogged ? `; ${unlogged} more since the last line` : ''
|
|
53
|
+
unlogged = 0
|
|
54
|
+
log(`[event-loop] stalled ${Math.round(lateMs)} ms (rss ${Math.round(m.rss / 1e6)} MB, heap ${Math.round(m.heapUsed / 1e6)} MB; `
|
|
55
|
+
+ `${stalls} stall(s) over ${thresholdMs} ms since boot${extra})`)
|
|
56
|
+
}, sampleMs)
|
|
57
|
+
timer.unref?.()
|
|
58
|
+
return {
|
|
59
|
+
stop: () => clearInterval(timer),
|
|
60
|
+
stats: () => ({ stalls, worstMs: Math.round(worstMs) }),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// X1 (Q1 review O2, 2026-11-09): WHICH engine asked, read from the process that ran the hook, never
|
|
2
|
+
// from the session id alone.
|
|
3
|
+
//
|
|
4
|
+
// The hook script posts its own `$PPID`, which is the Claude Code process that ran it (measured: a
|
|
5
|
+
// CLI 2.1.322 run and the Desktop engine 2.1.317 both, `~/.claude/sessions/<ppid>.json` names the
|
|
6
|
+
// same session). Reading the record for the session id instead lets a second process that shares the
|
|
7
|
+
// id (an older CLI, a `claude -p --resume <id>`) inherit a tab's version (Q1 O2).
|
|
8
|
+
//
|
|
9
|
+
// The entrypoint is not enough either. `claude -p` takes `CLAUDE_CODE_ENTRYPOINT` from whatever
|
|
10
|
+
// started it: a print run launched from a Desktop tab's shell registers as `claude-desktop`, and its
|
|
11
|
+
// `kind` reads `interactive` (measured 22:05). A print run has no dialog, and it does reach the
|
|
12
|
+
// PermissionRequest hook for an approval and waits on it (measured: a 20 s hold blocked it 20 s), so a
|
|
13
|
+
// full hold there would stall a headless job for up to an hour. What does tell them apart is where
|
|
14
|
+
// the process runs (census of 25 live sessions, 22:06):
|
|
15
|
+
// - a Desktop tab's engine is a child of Claude.app's own `Contents/Helpers/disclaimer`;
|
|
16
|
+
// - an interactive CLI has a controlling terminal (`ttys000`) and none of the print or SDK flags;
|
|
17
|
+
// - a print run started from a shell or a tool has neither.
|
|
18
|
+
// Q1 C3 (Codex second opinion), measured 22:42: a terminal alone is not enough. A `claude -p` run under
|
|
19
|
+
// a terminal (typed there, or started by a CLI session's own Bash tool, whose children inherit its
|
|
20
|
+
// terminal) has a tty too, and `-p` in its arguments. So a terminal engine must also carry none of
|
|
21
|
+
// `-p`, `--print`, `--input-format`, `--output-format` (an SDK host passes the last two). Miles's two
|
|
22
|
+
// interactive CLI sessions carried none of them.
|
|
23
|
+
// Anything that cannot be read is `other`, and `other` is never proven.
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process'
|
|
26
|
+
import { readFileSync } from 'node:fs'
|
|
27
|
+
import { homedir } from 'node:os'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
import type { EngineFacts, EngineHost } from './full-hold.js'
|
|
30
|
+
|
|
31
|
+
export function claudeSessionsRegistryDir(): string {
|
|
32
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ? process.env.CLAUDE_CONFIG_DIR : join(homedir(), '.claude')
|
|
33
|
+
return join(configDir, 'sessions')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `~/.claude/sessions/<pid>.json` parsed, only when it names `sessionId` (and, if it says, that pid). Bounded; null on anything else. */
|
|
37
|
+
function readRecord(pid: number, sessionId: string, dir: string): Record<string, unknown> | null {
|
|
38
|
+
if (!Number.isInteger(pid) || pid <= 1) return null
|
|
39
|
+
try {
|
|
40
|
+
const text = readFileSync(join(dir, `${pid}.json`), 'utf-8')
|
|
41
|
+
if (text.length > 256 * 1024) return null
|
|
42
|
+
const raw = JSON.parse(text) as Record<string, unknown>
|
|
43
|
+
if (typeof raw.sessionId !== 'string' || raw.sessionId.toLowerCase() !== sessionId.toLowerCase()) return null
|
|
44
|
+
if (typeof raw.pid === 'number' && raw.pid !== pid) return null
|
|
45
|
+
return raw
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The engine's entrypoint and version from its own record. */
|
|
52
|
+
export function registryRecordForPid(pid: number, sessionId: string, dir = claudeSessionsRegistryDir()): { entrypoint: string | null; version: string | null } | null {
|
|
53
|
+
const raw = readRecord(pid, sessionId, dir)
|
|
54
|
+
if (!raw) return null
|
|
55
|
+
return {
|
|
56
|
+
entrypoint: typeof raw.entrypoint === 'string' && raw.entrypoint ? raw.entrypoint : null,
|
|
57
|
+
version: typeof raw.version === 'string' && raw.version ? raw.version.slice(0, 32) : null,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type PsRun = (args: string[]) => Promise<string | null>
|
|
62
|
+
|
|
63
|
+
/** A flag only a print run or an SDK host passes (Q1 C3). */
|
|
64
|
+
const PRINT_OR_SDK_FLAG = /^(?:-p|--print|--input-format(?:=.*)?|--output-format(?:=.*)?)$/
|
|
65
|
+
|
|
66
|
+
const runPs: PsRun = args => new Promise(resolve => {
|
|
67
|
+
try {
|
|
68
|
+
execFile('/bin/ps', args, { timeout: 1_000, maxBuffer: 64 * 1024 }, (error, stdout) => resolve(error ? null : String(stdout)))
|
|
69
|
+
} catch {
|
|
70
|
+
resolve(null)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** Where a Claude Code process runs: Claude.app's helper, a terminal, or neither. */
|
|
75
|
+
export async function processHost(pid: number, ps: PsRun = runPs): Promise<EngineHost> {
|
|
76
|
+
if (!Number.isInteger(pid) || pid <= 1) return 'other'
|
|
77
|
+
const own = await ps(['-o', 'ppid=,tty=', '-p', String(pid)])
|
|
78
|
+
if (!own) return 'other'
|
|
79
|
+
const [ppidRaw, tty] = own.trim().split(/\s+/)
|
|
80
|
+
const ppid = Number(ppidRaw)
|
|
81
|
+
if (tty && tty !== '??' && tty !== '-' && /^tty/.test(tty)) {
|
|
82
|
+
const args = await ps(['-o', 'args=', '-p', String(pid)])
|
|
83
|
+
if (args === null) return 'other'
|
|
84
|
+
return args.trim().split(/\s+/).some(token => PRINT_OR_SDK_FLAG.test(token)) ? 'other' : 'terminal'
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isInteger(ppid) || ppid <= 1) return 'other'
|
|
87
|
+
const parent = await ps(['-o', 'comm=', '-p', String(ppid)])
|
|
88
|
+
const path = parent ? parent.trim() : ''
|
|
89
|
+
return /\/Claude\.app\/Contents\/Helpers\/disclaimer$/.test(path) ? 'desktop-app' : 'other'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* X1 round 3: the engine's own status now (`waiting` while its dialog is up), from the same record, or
|
|
94
|
+
* null when the record is gone (the process ended) or names another session (the pid was reused).
|
|
95
|
+
* Measured 22:39 on both engines: `waiting` is written 0 to 5 ms before the PermissionRequest hook
|
|
96
|
+
* starts and leaves `waiting` within milliseconds of an answer at the desk (`busy`, or `idle` on Esc);
|
|
97
|
+
* the record is removed when the process exits.
|
|
98
|
+
*/
|
|
99
|
+
export function registryStatusForPid(pid: number, sessionId: string, dir = claudeSessionsRegistryDir()): { status: string | null; statusUpdatedAt: number | null } | null {
|
|
100
|
+
const raw = readRecord(pid, sessionId, dir)
|
|
101
|
+
if (!raw) return null
|
|
102
|
+
return {
|
|
103
|
+
status: typeof raw.status === 'string' ? raw.status : null,
|
|
104
|
+
statusUpdatedAt: typeof raw.statusUpdatedAt === 'number' && Number.isFinite(raw.statusUpdatedAt) ? raw.statusUpdatedAt : null,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The engine facts the full hold reads, for the process that ran the hook. Null when its record does not name the session. */
|
|
109
|
+
export async function engineForHookProcess(sessionId: string, ppid: number | null, deps: { dir?: string; ps?: PsRun } = {}): Promise<EngineFacts | null> {
|
|
110
|
+
if (ppid === null) return null
|
|
111
|
+
const record = registryRecordForPid(ppid, sessionId, deps.dir)
|
|
112
|
+
if (!record) return null
|
|
113
|
+
return { ...record, host: await processHost(ppid, deps.ps) }
|
|
114
|
+
}
|