@gotcos/glasses-server 6.82.0 → 6.83.0

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 6.83.0 (M2 planner input, canary C2): a bounded, deterministic "where it stopped, what is next" note from the tail of
3
+ * a session's log. No model call. The note goes only into the planner input; it is redacted (secrets and customer
4
+ * identifiers) before it leaves this module, and never longer than PICKUP_MAX_CHARS.
5
+ */
6
+ import { readTail, turnsFrom, type LogProvider, type Turn } from './session-log.js'
7
+ import { redactNote } from './redact.js'
8
+ import { statusLines } from './tags.js'
9
+
10
+ export const PICKUP_MAX_CHARS = 1500
11
+
12
+ /** User turns the harness writes, not the person: task notifications, compaction summaries, interrupts. */
13
+ const HARNESS = ['Perform any necessary follow-up actions', 'Briefly inform the user about the task result', '<task-notification>',
14
+ 'This session is being continued from a previous conversation', '[Request interrupted', 'The following is the Codex agent history']
15
+
16
+ export interface LiveOverlay { busy?: boolean; waitingOnUser?: boolean }
17
+
18
+ const clip = (s: string, n: number) => {
19
+ const t = (s || '').replace(/\s+/g, ' ').trim()
20
+ return t.length <= n ? t : t.slice(0, n - 1).trimEnd() + '…'
21
+ }
22
+
23
+ export function pendingTodos(turns: Turn[]): string[] {
24
+ for (let i = turns.length - 1; i >= 0; i--) {
25
+ const t = turns[i]!
26
+ if (t.role !== 'todo') continue
27
+ let items: unknown
28
+ try { items = JSON.parse(t.text) } catch { items = undefined }
29
+ if (typeof items === 'string') { try { items = JSON.parse(items) } catch { items = undefined } }
30
+ if (items && typeof items === 'object' && !Array.isArray(items)) items = (items as any).plan ?? (items as any).todos
31
+ if (!Array.isArray(items)) return []
32
+ return items.filter(it => it && typeof it === 'object' && !['completed', 'done'].includes(String((it as any).status ?? '').toLowerCase()))
33
+ .map(it => `${String((it as any).status || 'open')}: ${String((it as any).content ?? (it as any).step ?? (it as any).activeForm ?? '')}`)
34
+ }
35
+ return []
36
+ }
37
+
38
+ export function questionsIn(text: string): string[] {
39
+ const out: string[] = []
40
+ for (const piece of text.split(/(?<=[.?!])\s+|\n/)) {
41
+ const l = piece.trim().replace(/^[-*#> ]+/, '').replace(/[-*#> ]+$/, '')
42
+ if (l.endsWith('?') && l.length > 12 && l.length < 300 && !/^(https?:|what i |why )/i.test(l)) out.push(l)
43
+ }
44
+ return out.slice(-4)
45
+ }
46
+
47
+ export interface PickupNote { note: string; lastActivity: string | null; state: 'running' | 'mid_turn' | 'after_tools' | 'finished' | 'empty' }
48
+
49
+ /** The note from turns already read. `mtimeMs` stands in for Cursor's missing timestamps. */
50
+ export function pickupFromTurns(turns: Turn[], options: { live?: LiveOverlay; mtimeMs?: number; home?: string } = {}): PickupNote {
51
+ const r = (s: string) => redactNote(s, options.home)
52
+ const human = turns.filter(t => t.role === 'user' && t.text.trim() && !/^\s*[<{]/.test(t.text) && !HARNESS.some(h => t.text.trimStart().startsWith(h)))
53
+ const replies = turns.filter(t => t.role === 'assistant' && t.text.trim())
54
+ if (!human.length && !replies.length) return { note: 'No readable turns in the log tail.', lastActivity: null, state: 'empty' }
55
+ const lastUser = human[human.length - 1]
56
+ const lastReply = replies[replies.length - 1]
57
+ const iUser = turns.lastIndexOf(lastUser!), iReply = turns.lastIndexOf(lastReply!)
58
+ const toolsAfter = iReply >= 0 ? turns.slice(iReply + 1).filter(t => t.role === 'tool').length : 0
59
+ let state: PickupNote['state'] = iUser > iReply ? 'mid_turn' : toolsAfter ? 'after_tools' : 'finished'
60
+ let stateLine = state === 'mid_turn' ? 'Stopped mid-turn: the last ask has no reply yet.'
61
+ : state === 'after_tools' ? `Stopped after ${toolsAfter} tool call(s) with no closing reply.` : 'Stopped after a reply (turn finished).'
62
+ if (options.live?.busy) { state = 'running'; stateLine = 'Still running: a turn is in flight now, so this is a moving snapshot.' }
63
+ else if (options.live?.waitingOnUser) stateLine += ' The session list says it is waiting on the user.'
64
+ const stamp = lastReply?.at ?? lastUser?.at ?? null
65
+ const lastActivity = stamp ?? (options.mtimeMs ? new Date(options.mtimeMs).toISOString() : null)
66
+ const when = stamp ? `${stamp.slice(0, 16).replace('T', ' ')} UTC` : options.mtimeMs ? `${new Date(options.mtimeMs).toISOString().slice(0, 16).replace('T', ' ')} UTC (file time)` : 'unknown'
67
+ const parts = [`State: ${stateLine} Last activity ${when}.`]
68
+ parts.push('Last ask: ' + clip(r(lastUser?.text ?? ''), 300))
69
+ parts.push('Last result: ' + clip(r(lastReply?.text ?? ''), 520))
70
+ const status = lastReply ? statusLines(lastReply.text)[0] : undefined
71
+ if (status) parts.push('Status line: ' + clip(r(`${status.status}: ${status.evidence}`), 160))
72
+ const todos = pendingTodos(turns)
73
+ if (todos.length) parts.push('Open todos: ' + clip(r(todos.join('; ')), 220))
74
+ const qs = lastReply ? questionsIn(lastReply.text) : []
75
+ parts.push('Open questions: ' + (qs.length ? clip(r(qs.join(' | ')), 260) : 'none found in the last reply.'))
76
+ let note = parts.join('\n')
77
+ if (note.length > PICKUP_MAX_CHARS) note = note.slice(0, PICKUP_MAX_CHARS - 1) + '…'
78
+ return { note, lastActivity, state }
79
+ }
80
+
81
+ export async function pickupNote(path: string, provider: LogProvider, sessionID: string, live?: LiveOverlay, home?: string): Promise<PickupNote> {
82
+ const tail = await readTail(path)
83
+ const turns = turnsFrom(tail.text, provider, provider === 'claude' ? sessionID : undefined)
84
+ return pickupFromTurns(turns, { live, mtimeMs: tail.mtimeMs, home })
85
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 6.83.0: every Work chat plan, from any client, leaves one JSONL row and one server log line: the sessions sent with
3
+ * their availability, what was left out and why, the usage snapshot's age per provider, the assignments (mode and
4
+ * target, never prompt text), the planner, the outcome and the time taken. Bounded: past 2 MB the file keeps its newest
5
+ * half. Ids and enums only; no question, prompt, summary or pick-up text is written.
6
+ */
7
+ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs'
8
+ import { dirname } from 'node:path'
9
+ import { atomicWriteFileSync } from '../atomic-fs.js'
10
+
11
+ export const PLAN_LOG_MAX_BYTES = 2 * 1024 * 1024
12
+
13
+ export interface PlanLogRow {
14
+ at: string; workID: string; mode: string; planner: string; effort: string; orchestrator: boolean
15
+ sessions: Array<{ id: string; automatic: boolean; available?: boolean; reason?: string; discoveredBy?: string[] }>
16
+ leftOut: Array<{ id: string; reason: string }>
17
+ usageAgeSec: Record<string, number | null>
18
+ usageState: Record<string, string>
19
+ assignments: Array<{ mode: string; target: string }>
20
+ outcome: 'planned' | 'invalid_answer' | 'unavailable' | 'aborted'
21
+ ms: number
22
+ }
23
+
24
+ export function appendPlanLog(path: string, row: PlanLogRow): void {
25
+ try {
26
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
27
+ let size = 0
28
+ try { size = statSync(path).size } catch { /* new file */ }
29
+ if (size > PLAN_LOG_MAX_BYTES) {
30
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean)
31
+ atomicWriteFileSync(path, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n')
32
+ }
33
+ appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
34
+ } catch (e) { console.warn(`[work-orchestrator] plan log not written: ${(e as Error).message}`) }
35
+ const sent = row.sessions.map(s => `${s.id.slice(0, 15)}${s.automatic ? '' : '(no-continue)'}${s.reason ? `[${s.reason}]` : ''}`).join(',') || 'none'
36
+ const usage = Object.entries(row.usageState).map(([p, s]) => `${p}=${s}/${row.usageAgeSec[p] ?? '-'}s`).join(' ') || 'none'
37
+ console.log(`[work-orchestrator] plan work=${row.workID} outcome=${row.outcome} planner=${row.planner}/${row.effort} orchestrator=${row.orchestrator} sessions=${sent} leftOut=${row.leftOut.length} usage=${usage} assignments=${row.assignments.map(a => `${a.mode}:${a.target.slice(0, 15)}`).join(',') || 'none'} ms=${row.ms}`)
38
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 6.83.0: what a pick-up note may carry. Session logs hold secrets and customer identifiers; a note goes to the planner
3
+ * (and from there into a prompt), so both are removed here, before the note leaves the orchestrator.
4
+ *
5
+ * Secrets: the server's shared `redactSecrets` patterns, plus `name=value` secrets and credential file paths (canary C2).
6
+ * Customer identifiers (canary C2, N1 named a merchant's proposal slug with its fees): email addresses, phone numbers,
7
+ * the path and query of any URL (the host stays, so "bottlepos.com" still says where), money amounts, and long numeric
8
+ * ids (order, account, ticket and HubSpot ids). The replacements say what was there, never what it was.
9
+ */
10
+ import { homedir } from 'node:os'
11
+ import { redactSecrets } from '../lens-gist-engines.js'
12
+
13
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
14
+
15
+ function homeRes(home: string): Array<[RegExp, string]> {
16
+ const h = `(?:~|${escape(home)})`
17
+ return [
18
+ [new RegExp(`${h}/\\.(?:codex/auth\\.json|ssh/[\\w.\\-]+|aws/credentials|npmrc|netrc|config/gh/hosts\\.yml)`, 'g'), '[credential path]'],
19
+ [new RegExp(`${h}/\\.cos-glasses/[\\w.\\-/]*(?:token|key|secret)[\\w.\\-]*`, 'gi'), '[credential path]'],
20
+ // A path to an env file, or a bare `.env` token. `process.env` is code, not a file, and stays.
21
+ [new RegExp(`(?<![\\w])${h}?(?:/[\\w\\-.]+)*/\\.env(?:\\.[\\w-]+)?\\b|(?<![\\w.])\\.env(?:\\.[\\w-]+)?\\b`, 'g'), '[env file]'],
22
+ ]
23
+ }
24
+
25
+ const SECRET_ASSIGN = /\b(token|api[_-]?key|secret|password|passwd|x-cos-token|client[_-]?secret)\b(["']?\s*[:=]\s*["']?)[A-Za-z0-9_\-./+=]{12,}/gi
26
+ const CUSTOMER: Array<[RegExp, string]> = [
27
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[email]'],
28
+ // A URL keeps its scheme and host; its path, query and fragment can name a customer (slugs, ids, proposals).
29
+ [/\b(https?:\/\/[A-Za-z0-9.-]+(?::\d+)?)(?:[/?#][^\s)\]"'<>]*)/g, '$1/[path]'],
30
+ [/(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?!\d)/g, '[phone]'],
31
+ [/(?:[$£€]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:k|m|million|thousand))?|\b\d[\d,]*(?:\.\d+)?\s?(?:USD|dollars)\b)/gi, '[amount]'],
32
+ [/\b\d{6,}\b/g, '[id]'],
33
+ ]
34
+
35
+ export function redactNote(text: string, home = homedir()): string {
36
+ let out = redactSecrets(text)
37
+ out = out.replace(SECRET_ASSIGN, '$1=[secret]')
38
+ for (const [re, to] of homeRes(home)) out = out.replace(re, to)
39
+ for (const [re, to] of CUSTOMER) out = out.replace(re, to)
40
+ return home.length > 1 ? out.split(home).join('~') : out
41
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 6.83.0: the orchestrator's process-wide state, created on first use (importing this module starts nothing).
3
+ * Files live under `data/work-orchestrator/`.
4
+ */
5
+ import { stat } from 'node:fs/promises'
6
+ import { dataPath } from '../data-dir.js'
7
+ import { agentSessionRoots, findAgentSessionFile, type AgentProvider } from '../agent-session-store.js'
8
+ import { setUsageEventSink } from './usage-events.js'
9
+ import { SnapshotStore, type ControlSnapshot } from './snapshot.js'
10
+ import { TagIndex } from './tag-index.js'
11
+ import { UsageMonitor, type UsageProvider } from './usage.js'
12
+ import { buildOrchestratorInput, type OrchestratorInput } from './input.js'
13
+
14
+ let snapshots: SnapshotStore | null = null
15
+ let index: TagIndex | null = null
16
+ let usage: UsageMonitor | null = null
17
+
18
+ export function orchestratorSnapshots(): SnapshotStore { return snapshots ??= new SnapshotStore(dataPath('work-orchestrator', 'control-snapshot.json')) }
19
+ export function orchestratorTagIndex(): TagIndex { return index ??= new TagIndex({ roots: agentSessionRoots(), path: dataPath('work-orchestrator', 'tag-index.json') }) }
20
+ export function orchestratorUsage(): UsageMonitor {
21
+ if (!usage) {
22
+ // Inside a test the real probes are never reachable: any test that reads usage through this monitor gets an
23
+ // error reading, never a spawned claude or codex CLI (the boot guard below covers the poll; this covers on demand).
24
+ const inTest = !!(process.env.VITEST || process.env.NODE_ENV === 'test')
25
+ const noProbe = async (): Promise<never> => { throw new Error('not probed inside a test') }
26
+ const monitor = usage = new UsageMonitor({ path: dataPath('work-orchestrator', 'usage.json'), ...(inTest ? { probes: { claude: noProbe, codex: noProbe } } : {}) })
27
+ // Jobs report limits and rate-limit frames through the leaf module; this monitor is where they land.
28
+ setUsageEventSink({ limit: (p, message, source) => monitor.noteLimit(p as UsageProvider, message, source), claudeFrame: frame => monitor.noteClaudeRateLimitEvent(frame) })
29
+ }
30
+ return usage
31
+ }
32
+ export const PLAN_LOG_PATH = () => dataPath('work-orchestrator', 'plans.jsonl')
33
+
34
+ /**
35
+ * Called once by the server at boot: registers the usage-event sink and schedules a first log scan. Usage is read on
36
+ * demand (a plan input or GET /usage); the 10-minute background poll starts only with `COS_WORK_USAGE_POLL=1`.
37
+ */
38
+ export function startWorkOrchestrator(env: NodeJS.ProcessEnv = process.env): void {
39
+ // A test that boots the server must never spawn the real claude or codex CLI, nor scan this Mac's session logs.
40
+ orchestratorUsage() // registers the usage-event sink, so job limits and rate-limit frames are kept from boot
41
+ if (env.VITEST || env.NODE_ENV === 'test') return
42
+ orchestratorUsage().start(env) // opt-in only: a no-op unless COS_WORK_USAGE_POLL=1
43
+ setTimeout(() => { void orchestratorTagIndex().refresh().catch(() => {}) }, 45_000).unref?.()
44
+ }
45
+
46
+ /** How long a plan waits for the log scan before it answers with what the index already holds. */
47
+ export const INPUT_SCAN_WAIT_MS = 20_000
48
+
49
+ /** What one plan input reads. The server passes the process-wide stores; a test passes its own. */
50
+ export interface OrchestratorParts { tags: TagIndex; usage: UsageMonitor; snapshot: () => ControlSnapshot | null }
51
+
52
+ export async function orchestratorInputFor(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts): Promise<OrchestratorInput> {
53
+ const tags = parts?.tags ?? orchestratorTagIndex()
54
+ const monitor = parts?.usage ?? orchestratorUsage()
55
+ const scan = tags.refresh().catch(() => null)
56
+ const scanned = await Promise.race([scan, new Promise<null>(r => setTimeout(() => r(null), INPUT_SCAN_WAIT_MS).unref?.())])
57
+ // On demand: the plan this input feeds may fork or start a session, so a reading older than 2 minutes is re-read.
58
+ const usageViews = await monitor.forPlan()
59
+ const roots = agentSessionRoots()
60
+ return buildOrchestratorInput(workID, meetingWorkIDs, {
61
+ snapshot: parts ? parts.snapshot() : orchestratorSnapshots().get(),
62
+ hits: tags.hits(),
63
+ usage: usageViews,
64
+ skipped: tags.skippedSessions(),
65
+ logScanComplete: !!scanned?.complete,
66
+ pathOf: async sid => {
67
+ const known = tags.pathOf(sid)
68
+ if (known) return known
69
+ const [provider, id] = sid.split(':') as [AgentProvider, string]
70
+ return findAgentSessionFile(provider, id, roots)
71
+ },
72
+ mtimeOf: async path => (await stat(path)).mtimeMs,
73
+ })
74
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 6.83.0: read the END of a session log without reading the whole file, and turn its rows into human-visible turns.
3
+ * Codex rollouts reach 1.4 GB (canary C2), so the reader seeks from the end: at most `TAIL_BYTES`, starting at the first
4
+ * whole line. Read-only; never writes beside the log.
5
+ *
6
+ * Row shapes (measured in canary C2 on 2026-10-20):
7
+ * Claude {type:'user'|'assistant', sessionId, timestamp, isSidechain, isMeta, message:{role, content}}
8
+ * Cursor {role, message:{content:[{type:'text', text:'<user_query>…</user_query>'}]}} (no timestamps: use mtime)
9
+ * Codex {timestamp, type:'response_item', payload:{type:'message', role, content:[{text}]}} and function calls
10
+ */
11
+ import { open, stat } from 'node:fs/promises'
12
+
13
+ export const TAIL_BYTES = 6_000_000
14
+ export type LogProvider = 'claude' | 'codex' | 'cursor'
15
+ export type TurnRole = 'user' | 'assistant' | 'tool' | 'todo' | 'ask'
16
+ export interface Turn { role: TurnRole; text: string; at: string | null }
17
+
18
+ /** Harness text that is not the person's ask nor the agent's answer. */
19
+ const NOISE = ['<environment_context>', '<user_instructions>', '# AGENTS.md', '<permissions instructions>', '<system-reminder>',
20
+ '<command-name>', '<local-command', 'Caveat: The messages below', '<external_codex_apps_open_page>', '<turn_aborted>']
21
+ const noisy = (text: string) => { const head = text.slice(0, 300); return NOISE.some(n => head.includes(n)) }
22
+
23
+ export async function readTail(path: string, maxBytes = TAIL_BYTES): Promise<{ text: string; size: number; mtimeMs: number; truncated: boolean }> {
24
+ const info = await stat(path)
25
+ const start = Math.max(0, info.size - maxBytes)
26
+ const handle = await open(path, 'r')
27
+ try {
28
+ const length = info.size - start
29
+ const buffer = Buffer.alloc(length)
30
+ let read = 0
31
+ while (read < length) {
32
+ const { bytesRead } = await handle.read(buffer, read, length - read, start + read)
33
+ if (bytesRead === 0) break
34
+ read += bytesRead
35
+ }
36
+ let text = buffer.subarray(0, read).toString('utf8')
37
+ // Seeking lands mid-line: drop the partial first line so every row parses whole.
38
+ if (start > 0) { const nl = text.indexOf('\n'); text = nl < 0 ? '' : text.slice(nl + 1) }
39
+ return { text, size: info.size, mtimeMs: info.mtimeMs, truncated: start > 0 }
40
+ } finally { await handle.close() }
41
+ }
42
+
43
+ function record(line: string): Record<string, any> | null {
44
+ try { const v = JSON.parse(line); return v && typeof v === 'object' && !Array.isArray(v) ? v : null } catch { return null }
45
+ }
46
+
47
+ /**
48
+ * Turns from JSONL text. `onlySession`: a Claude file can replay rows of the session it was resumed from; only rows whose
49
+ * own `sessionId` is this one count (canary C3 precision fix 4). Rows without a sessionId are kept.
50
+ */
51
+ export function turnsFrom(text: string, provider: LogProvider, onlySession?: string): Turn[] {
52
+ const out: Turn[] = []
53
+ const want = onlySession?.toLowerCase()
54
+ for (const line of text.split('\n')) {
55
+ if (!line.trim()) continue
56
+ const o = record(line)
57
+ if (!o) continue
58
+ const at = typeof o.timestamp === 'string' ? o.timestamp : null
59
+ if (provider === 'claude' || provider === 'cursor') {
60
+ if (provider === 'claude') {
61
+ if (o.type !== 'user' && o.type !== 'assistant') continue
62
+ if (want && typeof o.sessionId === 'string' && o.sessionId.toLowerCase() !== want) continue
63
+ }
64
+ if (o.isSidechain || o.isMeta) continue
65
+ const m = o.message && typeof o.message === 'object' ? o.message : {}
66
+ const role = (m.role || o.role || o.type) as string
67
+ const content = m.content
68
+ if (typeof content === 'string') { if (!noisy(content)) out.push({ role: role === 'assistant' ? 'assistant' : 'user', text: content, at }); continue }
69
+ if (!Array.isArray(content)) continue
70
+ for (const b of content) {
71
+ if (!b || typeof b !== 'object') continue
72
+ if (b.type === 'text' && typeof b.text === 'string') {
73
+ let tx: string = b.text
74
+ if (provider === 'cursor') {
75
+ tx = tx.replace(/<timestamp>[\s\S]*?<\/timestamp>\s*/g, '')
76
+ const q = /<user_query>([\s\S]*?)<\/user_query>/.exec(tx)
77
+ if (q) tx = q[1]!
78
+ }
79
+ if (!noisy(tx)) out.push({ role: role === 'assistant' ? 'assistant' : 'user', text: tx, at })
80
+ } else if (b.type === 'tool_use') {
81
+ const input = b.input && typeof b.input === 'object' ? b.input : {}
82
+ if (b.name === 'TodoWrite' || Array.isArray(input.todos)) out.push({ role: 'todo', text: JSON.stringify(input.todos ?? []).slice(0, 3000), at })
83
+ else if (b.name === 'AskUserQuestion') out.push({ role: 'ask', text: JSON.stringify(input).slice(0, 1500), at })
84
+ else out.push({ role: 'tool', text: String(b.name || 'tool'), at })
85
+ }
86
+ }
87
+ continue
88
+ }
89
+ const p = o.payload && typeof o.payload === 'object' ? o.payload : {}
90
+ if (o.type === 'response_item' && p.type === 'message') {
91
+ const tx = (Array.isArray(p.content) ? p.content : []).map((b: any) => (b && typeof b.text === 'string' ? b.text : '')).join('\n')
92
+ if ((p.role === 'user' || p.role === 'assistant') && !noisy(tx)) out.push({ role: p.role, text: tx, at })
93
+ } else if (o.type === 'response_item' && (p.type === 'function_call' || p.type === 'custom_tool_call')) {
94
+ if (p.name === 'update_plan') out.push({ role: 'todo', text: String(p.arguments ?? '').slice(0, 3000), at })
95
+ else out.push({ role: 'tool', text: String(p.name || 'tool'), at })
96
+ }
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** The first line of a Codex rollout says whether it is a child thread (canary C3 precision fix 3). */
102
+ export interface CodexThreadKind { kind: 'thread' | 'guardian' | 'spawn' | 'other_child'; parent?: string }
103
+ export function codexThreadKind(firstLine: string): CodexThreadKind {
104
+ const meta = record(firstLine)?.payload
105
+ const source = meta && typeof meta === 'object' ? meta.source : undefined
106
+ if (!source || typeof source !== 'object' || !('subagent' in source)) return { kind: 'thread' }
107
+ const sub = (source as any).subagent
108
+ if (sub && typeof sub === 'object' && sub.thread_spawn && typeof sub.thread_spawn.parent_thread_id === 'string') {
109
+ return { kind: 'spawn', parent: sub.thread_spawn.parent_thread_id.toLowerCase() }
110
+ }
111
+ if (sub && typeof sub === 'object' && sub.other === 'guardian') return { kind: 'guardian' }
112
+ if (sub === 'guardian') return { kind: 'guardian' }
113
+ return { kind: 'other_child' }
114
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 6.83.0 (Phase A, docs/work-orchestrator.md): the Work state COS Control pushes. Control owns the journal; the server
3
+ * keeps this bounded, versioned copy in `data/work-orchestrator/control-snapshot.json` (0600, atomic). Ids, modes,
4
+ * statuses and times only: never a prompt, a result or a file name. Out of bounds or malformed is refused whole.
5
+ */
6
+ import { mkdirSync, readFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import { atomicWriteFileSync } from '../atomic-fs.js'
9
+
10
+ export const SNAPSHOT_VERSION = 1
11
+ export const SNAPSHOT_MAX_LINKS = 4000
12
+ export const SNAPSHOT_MAX_RECEIPTS = 4000
13
+ export const SNAPSHOT_MAX_LIVE = 400
14
+ const SID = /^(claude|codex|cursor):[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
15
+ /** A task card (`task:<domain>:<12 hex>`), a meeting card (`meeting:<record id>`) or another kind COS Control adds later. */
16
+ const WORK_ID = /^[a-z][a-z0-9_]{0,30}:[^\u0000-\u001f]{1,250}$/
17
+ const MODES = ['continueSession', 'fork', 'newSession'] as const
18
+
19
+ export interface SnapshotLink { sessionID: string; workID: string; at: number }
20
+ export interface SnapshotReceipt {
21
+ id: string; workID: string; mode: typeof MODES[number]; provider: string
22
+ sessionID: string | null; sourceSessionID: string | null; forkChildID: string | null; status: string; createdAt: number
23
+ }
24
+ export interface SnapshotLiveRow { id: string; title: string; status: string; updatedAt: string | null; busy: boolean; waitingOnUser: boolean; appOwned: boolean; summary: string }
25
+ export interface ControlSnapshot {
26
+ version: 1; controlVersion: string; sentAt: number; receivedAt: number
27
+ links: SnapshotLink[]; receipts: SnapshotReceipt[]; live: { checkedAt: number; rows: SnapshotLiveRow[] } | null
28
+ }
29
+
30
+ const obj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
31
+ const str = (v: unknown, n: number): v is string => typeof v === 'string' && v.length <= n && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v)
32
+ /** Seconds since 1970. A value from Swift's reference date (2001) is moved onto 1970. */
33
+ export const epochSeconds = (v: unknown): number | null => typeof v === 'number' && Number.isFinite(v) && v > 0 ? (v < 1.5e9 ? v + 978_307_200 : v > 1e11 ? v / 1000 : v) : null
34
+ const sidOrNull = (v: unknown): string | null => typeof v === 'string' && SID.test(v.toLowerCase()) ? v.toLowerCase() : null
35
+
36
+ export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
37
+ if (!obj(v) || v.version !== SNAPSHOT_VERSION || !str(v.controlVersion, 40) || !Array.isArray(v.links) || v.links.length > SNAPSHOT_MAX_LINKS
38
+ || !Array.isArray(v.receipts) || v.receipts.length > SNAPSHOT_MAX_RECEIPTS) throw new Error('invalid_snapshot')
39
+ const links: SnapshotLink[] = []
40
+ for (const l of v.links) {
41
+ const at = epochSeconds(l?.at), sid = sidOrNull(l?.sessionID)
42
+ if (!obj(l) || !sid || !str(l.workID, 256) || !WORK_ID.test(l.workID) || at === null) throw new Error('invalid_link')
43
+ links.push({ sessionID: sid, workID: l.workID, at })
44
+ }
45
+ const receipts: SnapshotReceipt[] = []
46
+ for (const r of v.receipts) {
47
+ const createdAt = epochSeconds(r?.createdAt)
48
+ if (!obj(r) || !str(r.id, 64) || !str(r.workID, 256) || !WORK_ID.test(r.workID) || !(MODES as readonly unknown[]).includes(r.mode)
49
+ || !str(r.provider, 20) || !str(r.status, 40) || createdAt === null) throw new Error('invalid_receipt')
50
+ receipts.push({ id: r.id, workID: r.workID, mode: r.mode, provider: r.provider, sessionID: sidOrNull(r.sessionID),
51
+ sourceSessionID: sidOrNull(r.sourceSessionID), forkChildID: sidOrNull(r.forkChildID), status: r.status, createdAt })
52
+ }
53
+ let live: ControlSnapshot['live'] = null
54
+ if (v.live != null) {
55
+ const checkedAt = epochSeconds(v.live?.checkedAt)
56
+ if (!obj(v.live) || checkedAt === null || !Array.isArray(v.live.rows) || v.live.rows.length > SNAPSHOT_MAX_LIVE) throw new Error('invalid_live')
57
+ const rows: SnapshotLiveRow[] = []
58
+ for (const r of v.live.rows) {
59
+ const id = sidOrNull(r?.id)
60
+ if (!obj(r) || !id || !str(r.title ?? '', 300) || !str(r.status ?? '', 100) || !str(r.summary ?? '', 2000)) throw new Error('invalid_live_row')
61
+ if (isGuardianRow(id, r.title ?? '', r.summary ?? '')) continue
62
+ rows.push({ id, title: r.title ?? '', status: r.status ?? '', updatedAt: str(r.updatedAt, 40) ? r.updatedAt : null,
63
+ busy: r.busy === true, waitingOnUser: r.waitingOnUser === true, appOwned: r.appOwned === true, summary: r.summary ?? '' })
64
+ }
65
+ live = { checkedAt, rows }
66
+ }
67
+ return { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }
68
+ }
69
+
70
+ /** Codex guardian review threads (canary C3: 17 in the live list) are never offered as work sessions. */
71
+ export function isGuardianRow(id: string, title: string, summary: string): boolean {
72
+ return id.startsWith('codex:') && (title.trim() === 'Guardian review' || summary.startsWith('The following is the Codex agent history'))
73
+ }
74
+
75
+ export class SnapshotStore {
76
+ private current: ControlSnapshot | null = null
77
+ constructor(private readonly path: string) {
78
+ try { const v = JSON.parse(readFileSync(path, 'utf8')); this.current = parseSnapshot(v, (v?.receivedAt ?? 0) * 1000) } catch { this.current = null }
79
+ }
80
+ get(): ControlSnapshot | null { return this.current }
81
+ put(snapshot: ControlSnapshot): void {
82
+ this.current = snapshot
83
+ try { mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); atomicWriteFileSync(this.path, JSON.stringify(snapshot)) } catch (e) {
84
+ console.warn(`[work-orchestrator] snapshot kept in memory only: ${(e as Error).message}`)
85
+ }
86
+ }
87
+ }