@gotcos/glasses-server 6.81.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/docs/work-orchestrator.md +184 -0
  3. package/package.json +6 -1
  4. package/server/index.ts +19 -0
  5. package/server/lib/claude-bridge.ts +10 -1
  6. package/server/lib/codex-bridge.ts +1 -1
  7. package/server/lib/cursor-bridge.ts +1 -1
  8. package/server/lib/direct-bridge.ts +1 -1
  9. package/server/lib/morning-brief-schedule.ts +13 -7
  10. package/server/lib/query-job-coordinator.ts +5 -1
  11. package/server/lib/query-job-runtime.ts +3 -0
  12. package/server/lib/routines-config.ts +421 -0
  13. package/server/lib/routines-registry.ts +151 -0
  14. package/server/lib/routines-runtime.ts +64 -0
  15. package/server/lib/routines-scheduler.ts +509 -0
  16. package/server/lib/work-chat.ts +76 -9
  17. package/server/lib/work-lens-rounds.ts +445 -0
  18. package/server/lib/work-orchestrator/discovery.ts +99 -0
  19. package/server/lib/work-orchestrator/input.ts +109 -0
  20. package/server/lib/work-orchestrator/pickup.ts +85 -0
  21. package/server/lib/work-orchestrator/plan-log.ts +38 -0
  22. package/server/lib/work-orchestrator/redact.ts +41 -0
  23. package/server/lib/work-orchestrator/runtime.ts +74 -0
  24. package/server/lib/work-orchestrator/session-log.ts +114 -0
  25. package/server/lib/work-orchestrator/snapshot.ts +87 -0
  26. package/server/lib/work-orchestrator/tag-index.ts +272 -0
  27. package/server/lib/work-orchestrator/tags.ts +52 -0
  28. package/server/lib/work-orchestrator/usage-events.ts +30 -0
  29. package/server/lib/work-orchestrator/usage.ts +341 -0
  30. package/server/routes/routines.ts +101 -0
  31. package/server/routes/work-chat.ts +54 -11
  32. package/server/routes/work-lens-rounds.ts +179 -0
  33. package/server/routes/work-orchestrator.ts +71 -0
  34. package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 6.83.0 (M1, canary C3): which sessions belong to a Work card, from COS Control's snapshot and the tag index.
3
+ * Pure: no I/O. The rules and their ranks are in docs/work-orchestrator.md.
4
+ */
5
+ import type { ControlSnapshot } from './snapshot.js'
6
+ import type { TagHit } from './tag-index.js'
7
+ import { tagForWorkID, tagsIn } from './tags.js'
8
+
9
+ /** Evidence kinds, strongest first. The first five make a session the card's; status and listed confirm one. */
10
+ export const EVIDENCE_RANK = ['link', 'created', 'forked', 'continued', 'prompt', 'status', 'listed', 'meeting'] as const
11
+ export type Evidence = typeof EVIDENCE_RANK[number]
12
+ const MEMBER: ReadonlySet<Evidence> = new Set(['link', 'created', 'forked', 'continued', 'prompt'])
13
+ const CONFIRMS: ReadonlySet<Evidence> = new Set(['status', 'listed'])
14
+
15
+ export interface CardSession {
16
+ id: string
17
+ /** Evidence kinds for this card, in rank order. */
18
+ discoveredBy: Evidence[]
19
+ /** True: one of ranks 1 to 5 holds. False: a suggestion (status or listed alone, or the source-meeting join). */
20
+ member: boolean
21
+ linked: boolean
22
+ /** Newest evidence for this card (ms). */
23
+ lastTouchedCard: number | null
24
+ /** Set when the session's newest strong evidence is on another card: it works there now, and keeps this one as history. */
25
+ onAnotherCard: boolean
26
+ }
27
+
28
+ interface Ev { kind: Evidence; at: number | null }
29
+
30
+ /**
31
+ * Strong evidence per card tag and session, from Control's records and the logs.
32
+ * A placeholder or never-used tag (canary precision fix 7: `aaaaaaaaaaaa`, `0123456789ab`) is inert by construction: only
33
+ * the requested card's own tag is read for this card, and only member evidence (a Control record or a real handoff
34
+ * prompt) can make another card the newer owner, so a status line with an unknown tag never changes a row.
35
+ */
36
+ function strongByTag(snapshot: ControlSnapshot | null, hits: TagHit[]): Map<string, Map<string, Ev[]>> {
37
+ const out = new Map<string, Map<string, Ev[]>>()
38
+ const add = (tag: string, sid: string | null, kind: Evidence, at: number | null) => {
39
+ if (!sid) return
40
+ let card = out.get(tag); if (!card) out.set(tag, card = new Map())
41
+ let list = card.get(sid); if (!list) card.set(sid, list = [])
42
+ list.push({ kind, at })
43
+ }
44
+ for (const l of snapshot?.links ?? []) add(tagForWorkID(l.workID), l.sessionID, 'link', l.at * 1000)
45
+ for (const r of snapshot?.receipts ?? []) {
46
+ const tag = tagForWorkID(r.workID), at = r.createdAt * 1000
47
+ if (r.mode === 'newSession') add(tag, r.sessionID, 'created', at)
48
+ else if (r.mode === 'fork') {
49
+ // A fork's child: journaled by Control 0.5.333, or a sessionID that is not the source (canary precision fix 1).
50
+ const child = r.forkChildID ?? (r.sessionID && r.sessionID !== r.sourceSessionID ? r.sessionID : null)
51
+ add(tag, child, 'forked', at)
52
+ } else add(tag, r.sessionID ?? r.sourceSessionID, 'continued', at)
53
+ // A sourceSessionID is where a fork or new session came FROM, never membership (precision fix 2).
54
+ }
55
+ for (const h of hits) {
56
+ if (h.kind === 'prompt') add(h.tag, h.sid, 'prompt', h.at)
57
+ else if (h.kind === 'status') add(h.tag, h.sid, 'status', h.at)
58
+ }
59
+ for (const row of snapshot?.live?.rows ?? []) {
60
+ for (const tag of tagsIn(row.summary)) add(tag, row.id, 'listed', row.updatedAt ? Date.parse(row.updatedAt) || null : null)
61
+ }
62
+ return out
63
+ }
64
+
65
+ const newest = (list: Ev[], kinds?: ReadonlySet<Evidence>) => list.filter(e => !kinds || kinds.has(e.kind)).reduce<number | null>((m, e) => (e.at !== null && (m === null || e.at > m) ? e.at : m), null)
66
+
67
+ /**
68
+ * The card's sessions. `meetingWorkIDs`: the card's confirmed source meetings as meeting cards (`meeting:<recordId>`);
69
+ * a session that is a member of one of them is suggested here with the reason (rule 8).
70
+ */
71
+ export function discoverCardSessions(workID: string, snapshot: ControlSnapshot | null, hits: TagHit[], meetingWorkIDs: string[] = []): CardSession[] {
72
+ const byTag = strongByTag(snapshot, hits)
73
+ const tag = tagForWorkID(workID)
74
+ const mine = new Map<string, Ev[]>(byTag.get(tag) ?? [])
75
+ for (const m of meetingWorkIDs) {
76
+ for (const [sid, list] of byTag.get(tagForWorkID(m)) ?? []) {
77
+ if (!list.some(e => MEMBER.has(e.kind))) continue
78
+ const at = newest(list, MEMBER)
79
+ mine.set(sid, [...(mine.get(sid) ?? []), { kind: 'meeting', at }])
80
+ }
81
+ }
82
+ const out: CardSession[] = []
83
+ for (const [sid, list] of mine) {
84
+ const kinds = EVIDENCE_RANK.filter(k => list.some(e => e.kind === k))
85
+ const member = kinds.some(k => MEMBER.has(k))
86
+ if (!member && !kinds.some(k => CONFIRMS.has(k) || k === 'meeting')) continue
87
+ // Newest strong evidence decides "working on" (precision fix 5): another card with newer member evidence wins.
88
+ const here = newest(list, MEMBER) ?? newest(list)
89
+ let elsewhere: number | null = null
90
+ for (const [otherTag, sessions] of byTag) {
91
+ if (otherTag === tag || meetingWorkIDs.some(m => tagForWorkID(m) === otherTag)) continue
92
+ const t = newest(sessions.get(sid) ?? [], MEMBER)
93
+ if (t !== null && (elsewhere === null || t > elsewhere)) elsewhere = t
94
+ }
95
+ out.push({ id: sid, discoveredBy: kinds, member, linked: kinds.includes('link'), lastTouchedCard: newest(list),
96
+ onAnotherCard: elsewhere !== null && here !== null && elsewhere > here })
97
+ }
98
+ return out
99
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 6.83.0: the orchestrator input for one Work card. Joins discovery, the live session list Control just read, the
3
+ * pick-up notes and the usage snapshot into the rows the planner sees, ranked and bounded, with what was left out and
4
+ * why. It sends nothing and changes nothing.
5
+ */
6
+ import type { ControlSnapshot, SnapshotLiveRow } from './snapshot.js'
7
+ import { discoverCardSessions, type CardSession, type Evidence } from './discovery.js'
8
+ import { pickupNote, PICKUP_MAX_CHARS } from './pickup.js'
9
+ import type { UsageProvider, UsageView } from './usage.js'
10
+ import type { LogProvider } from './session-log.js'
11
+
12
+ export const ORCHESTRATOR_MAX_ROWS = 12
13
+ export const ORCHESTRATOR_MAX_LEFT_OUT = 12
14
+ /** The live list Control sends is "just read" for this long; older, rows read as not in the session list. */
15
+ export const LIVE_FRESH_MS = 3 * 60_000
16
+
17
+ export const UNAVAILABLE_REASONS = ['not_in_session_list', 'app_owned', 'cursor', 'plan_limit', 'on_another_card'] as const
18
+ export type UnavailableReason = typeof UNAVAILABLE_REASONS[number]
19
+
20
+ export interface OrchestratorRow {
21
+ id: string; title: string; summary: string; status: string
22
+ discoveredBy: Evidence[]; linked: boolean; member: boolean
23
+ lastTouchedCard: string | null; lastActivity: string | null
24
+ live: boolean; busy: boolean; pickup: string
25
+ available: boolean; reason?: UnavailableReason
26
+ /** A fork can start from it: Claude or Codex, its log is on this Mac, and that provider has plan usage left. */
27
+ forkable: boolean
28
+ }
29
+ export interface LeftOut { id: string; title: string; reason: string }
30
+ export interface OrchestratorInput {
31
+ protocolVersion: 1; workID: string; now: string
32
+ rows: OrchestratorRow[]; leftOut: LeftOut[]; usage: UsageView[]
33
+ snapshotAgeSec: number | null; liveListAgeSec: number | null; logScanComplete: boolean
34
+ discovered: number
35
+ }
36
+
37
+ export interface InputDeps {
38
+ snapshot: ControlSnapshot | null
39
+ hits: import('./tag-index.js').TagHit[]
40
+ usage: UsageView[]
41
+ /** Log path for a session, or null when this Mac has none. */
42
+ pathOf: (sid: string) => Promise<string | null>
43
+ /** Last-modified time of a log, for sessions outside the live list. */
44
+ mtimeOf?: (path: string) => Promise<number | null>
45
+ skipped?: Set<string>
46
+ logScanComplete?: boolean
47
+ now?: number
48
+ home?: string
49
+ }
50
+
51
+ const iso = (ms: number | null | undefined) => (ms ? new Date(ms).toISOString() : null)
52
+
53
+ export async function buildOrchestratorInput(workID: string, meetingWorkIDs: string[], deps: InputDeps): Promise<OrchestratorInput> {
54
+ const now = deps.now ?? Date.now()
55
+ const snapshot = deps.snapshot
56
+ const liveFresh = !!snapshot?.live && now - snapshot.live.checkedAt * 1000 <= LIVE_FRESH_MS
57
+ const live = new Map<string, SnapshotLiveRow>((liveFresh ? snapshot!.live!.rows : []).map(r => [r.id, r]))
58
+ const usageBy = new Map<UsageProvider, UsageView>(deps.usage.map(u => [u.provider, u]))
59
+ const found = discoverCardSessions(workID, snapshot, deps.hits, meetingWorkIDs)
60
+ const leftOut: LeftOut[] = []
61
+ const candidates: Array<{ s: CardSession; row: SnapshotLiveRow | undefined }> = []
62
+ for (const s of found) {
63
+ if (deps.skipped?.has(s.id)) { leftOut.push({ id: s.id, title: '', reason: 'codex child thread (guardian or subagent)' }); continue }
64
+ candidates.push({ s, row: live.get(s.id) })
65
+ }
66
+ // Members before suggestions; in the live list before not; the session that touched this card most recently first.
67
+ candidates.sort((a, b) => Number(b.s.member) - Number(a.s.member) || Number(!!b.row) - Number(!!a.row)
68
+ || Number(!b.s.onAnotherCard) - Number(!a.s.onAnotherCard) || (b.s.lastTouchedCard ?? 0) - (a.s.lastTouchedCard ?? 0) || a.s.id.localeCompare(b.s.id))
69
+ const kept = candidates.slice(0, ORCHESTRATOR_MAX_ROWS)
70
+ for (const c of candidates.slice(ORCHESTRATOR_MAX_ROWS)) leftOut.push({ id: c.s.id, title: c.row?.title ?? '', reason: 'more than 12 sessions on this card' })
71
+ const rows: OrchestratorRow[] = []
72
+ for (const { s, row } of kept) {
73
+ const provider = s.id.split(':')[0] as LogProvider
74
+ const path = await deps.pathOf(s.id).catch(() => null)
75
+ let pickup = 'No log for this session on this Mac.'
76
+ let lastActivity: string | null = row?.updatedAt ?? null
77
+ if (path) {
78
+ try {
79
+ const note = await pickupNote(path, provider, s.id.split(':')[1]!, row ? { busy: row.busy, waitingOnUser: row.waitingOnUser } : undefined, deps.home)
80
+ pickup = note.note.slice(0, PICKUP_MAX_CHARS)
81
+ lastActivity = lastActivity ?? note.lastActivity
82
+ } catch { pickup = 'The log could not be read.' }
83
+ if (!lastActivity && deps.mtimeOf) lastActivity = iso(await deps.mtimeOf(path).catch(() => null))
84
+ }
85
+ const usage = provider === 'cursor' ? undefined : usageBy.get(provider)
86
+ const exhausted = usage?.state === 'exhausted'
87
+ let reason: UnavailableReason | undefined
88
+ if (provider === 'cursor') reason = 'cursor'
89
+ else if (s.onAnotherCard) reason = 'on_another_card'
90
+ else if (exhausted) reason = 'plan_limit'
91
+ else if (!row) reason = 'not_in_session_list'
92
+ else if (row.appOwned) reason = 'app_owned'
93
+ rows.push({
94
+ id: s.id, title: (row?.title || '').slice(0, 300) || s.id.slice(0, 15), summary: (row?.summary ?? '').slice(0, 2000),
95
+ status: (row ? row.status || 'idle' : 'not in the session list').slice(0, 100),
96
+ discoveredBy: s.discoveredBy, linked: s.linked, member: s.member,
97
+ lastTouchedCard: iso(s.lastTouchedCard), lastActivity,
98
+ live: !!row, busy: !!row?.busy, pickup,
99
+ available: !reason, ...(reason ? { reason } : {}),
100
+ forkable: (provider === 'claude' || provider === 'codex') && !!path && !exhausted,
101
+ })
102
+ }
103
+ return {
104
+ protocolVersion: 1, workID, now: new Date(now).toISOString(), rows, leftOut: leftOut.slice(0, ORCHESTRATOR_MAX_LEFT_OUT), usage: deps.usage,
105
+ snapshotAgeSec: snapshot ? Math.round(now / 1000 - snapshot.receivedAt) : null,
106
+ liveListAgeSec: snapshot?.live ? Math.round(now / 1000 - snapshot.live.checkedAt) : null,
107
+ logScanComplete: deps.logScanComplete ?? true, discovered: found.length,
108
+ }
109
+ }
@@ -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
+ }