@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.
- package/CHANGELOG.md +81 -0
- package/docs/work-orchestrator.md +184 -0
- package/package.json +4 -1
- package/server/index.ts +12 -0
- package/server/lib/claude-bridge.ts +4 -0
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
- package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
|
@@ -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
|
+
}
|