@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,445 @@
1
+ /**
2
+ * 6.83.0: lens plans in Work chat history, and "Send this plan" (contract:
3
+ * operations/personal/wk43_2026/CONTRACT_lens_plans_6.83.0_2026-10-21.md in the MU repo; docs/work-orchestrator.md).
4
+ *
5
+ * The lens records a plan it made (POST /api/work/chat with the orchestrator input) as a lens round, and may ask for it
6
+ * to be sent. This store only holds rounds and requests. COS Control owns every send and its Work chat journal: it pulls
7
+ * the inbox, writes the round into the card's Work chat record, performs each send through its own Send path and
8
+ * reports results. Nothing here sends to a session.
9
+ *
10
+ * Storage: `data/work-lens-rounds.json`, atomic (0600), at most 200 rounds, each kept 14 days. Ids, states, times and
11
+ * short text only; plans carry prompts, which are the user's own words.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
14
+ import { dirname } from 'node:path'
15
+ import { randomBytes } from 'node:crypto'
16
+ import { atomicWriteFileSync } from './atomic-fs.js'
17
+ import { dataPath } from './data-dir.js'
18
+ import { WORK_CHAT_EFFORTS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_PLANNERS } from './work-chat.js'
19
+ import type { ControlSnapshot } from './work-orchestrator/snapshot.js'
20
+
21
+ export const LENS_ROUNDS_MAX = 200
22
+ export const LENS_ROUND_TTL_MS = 14 * 24 * 3600_000
23
+ /** A send request COS Control has not picked up in 10 minutes is expired. */
24
+ export const LENS_SEND_PICKUP_MS = 10 * 60_000
25
+ /** A picked-up send whose assignment COS Control never reported in 15 minutes is marked failed, so the lens stops polling. */
26
+ export const LENS_SEND_REPORT_MS = 15 * 60_000
27
+ /** COS Control is connected while it has polled the inbox in the last 60 seconds. */
28
+ export const LENS_CONTROL_FRESH_MS = 60_000
29
+ export const LENS_INBOX_MAX = 50
30
+ export const LENS_ROUND_BODY_BYTES = 256_000
31
+ export const LENS_QUESTION_CHARS = 4000
32
+ export const LENS_ANSWER_CHARS = 6000
33
+ export const LENS_PROMPT_CHARS = 6000
34
+ export const LENS_DETAIL_CHARS = 200
35
+ const MAX_SENDS_KEPT = 8
36
+ const MAX_STOPS_KEPT = 8
37
+
38
+ const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
39
+ const CLIENT_ROUND_ID = /^lens-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
40
+ 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}$/
41
+ const ITEM_ID = /^[a-z]{1,8}(:[a-f0-9]{12})?$/
42
+ const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,80}$/
43
+ export const ROUND_ID = /^lr_[a-f0-9]{16}$/
44
+ export const SEND_ID = /^ls_[a-f0-9]{16}$/
45
+
46
+ export const ASSIGNMENT_MODES = ['continue', 'fork', 'newSession'] as const
47
+ export const ASSIGNMENT_STATES = ['pending', 'held', 'sent', 'delivered', 'failed', 'refused', 'stopped'] as const
48
+ export const ROUND_STATES = ['recorded', 'in_history', 'send_requested', 'sending', 'sent', 'partly_sent', 'failed', 'stopped', 'refused', 'expired'] as const
49
+ export type AssignmentMode = typeof ASSIGNMENT_MODES[number]
50
+ export type AssignmentState = typeof ASSIGNMENT_STATES[number]
51
+ export type RoundState = typeof ROUND_STATES[number]
52
+ type SendState = 'send_requested' | 'sending' | Outcome | 'expired'
53
+ type Outcome = 'sent' | 'partly_sent' | 'failed' | 'stopped' | 'refused'
54
+ const TERMINAL: readonly AssignmentState[] = ['sent', 'delivered', 'failed', 'refused', 'stopped']
55
+ const SENT_LIKE: readonly AssignmentState[] = ['sent', 'delivered']
56
+ /** An assignment the lens may not send again: it reached its session, or COS Control holds it to send after earlier work. */
57
+ const ALREADY: readonly AssignmentState[] = ['sent', 'delivered', 'held']
58
+
59
+ export interface LensAssignment {
60
+ id: string; mode: AssignmentMode; sessionID?: string; provider?: string; prompt: string; contextItemIDs?: string[]
61
+ state: AssignmentState; receiptId?: string; detail?: string; reportedSessionID?: string; updatedAt?: string
62
+ }
63
+ export interface LensSendRequest {
64
+ id: string; assignmentIds: string[]; confirm: 'two-step' | null; confirmedAt: string | null; autoSend: boolean
65
+ requestedAt: number; pickedUpAt: number | null; state: SendState; finishedAt: number | null
66
+ }
67
+ export interface LensStopRequest { id: string; assignmentIds: string[]; sendRequestId: string | null; requestedAt: number; pickedUpAt: number | null }
68
+ export interface LensRound {
69
+ roundId: string; seq: number; clientRoundId: string; workID: string; question: string; answer: string
70
+ planner: string | null; effort: string | null; orchestrator: boolean; assignments: LensAssignment[]
71
+ createdAt: string; recordedAt: number; inHistory: boolean; historyAt: number | null; autoSendCard: boolean
72
+ /** The card's sessions (Control snapshot links and receipts) when the plan was recorded; null when no snapshot. */
73
+ cardSessions: string[] | null
74
+ sends: LensSendRequest[]; stops: LensStopRequest[]; updatedAt: number
75
+ /** COS Control's own Send of this round's assignments on the Mac (the lens was told to send from the Mac): when, and which. */
76
+ mac?: { at: number; ids: string[] } | null
77
+ }
78
+ interface StoreFile { version: 1; seq: number; rounds: LensRound[] }
79
+
80
+ export class LensRoundError extends Error {
81
+ constructor(readonly code: string, readonly status: number, message: string, readonly extra: Record<string, unknown> = {}) { super(message) }
82
+ }
83
+ const invalid = (field: string, message = `The plan's ${field} is not valid.`) => new LensRoundError('invalid_lens_round', 400, message, { field })
84
+
85
+ const obj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
86
+ const str = (v: unknown, n: number): v is string => typeof v === 'string' && v.length <= n && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v)
87
+ const iso = (v: unknown): v is string => str(v, 40) && !Number.isNaN(Date.parse(v as string))
88
+ const newId = (prefix: string) => `${prefix}_${randomBytes(8).toString('hex')}`
89
+
90
+ export interface LensRoundInput {
91
+ workID: string; clientRoundId: string; question: string; answer: string; planner: string | null; effort: string | null
92
+ orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string
93
+ }
94
+
95
+ /** The record body. Unknown keys are ignored and never stored. */
96
+ export function parseLensRound(v: unknown): LensRoundInput {
97
+ if (!obj(v)) throw invalid('body', 'The plan could not be read.')
98
+ if (v.v !== 1) throw invalid('v')
99
+ if (!str(v.workID, 256) || !WORK_ID.test(v.workID)) throw invalid('workID')
100
+ if (!str(v.clientRoundId, 60) || !CLIENT_ROUND_ID.test(v.clientRoundId)) throw invalid('clientRoundId')
101
+ if (!str(v.question, LENS_QUESTION_CHARS) || !v.question.trim()) throw invalid('question')
102
+ if (!str(v.answer, LENS_ANSWER_CHARS)) throw invalid('answer')
103
+ if (v.planner !== undefined && !(WORK_CHAT_PLANNERS as readonly unknown[]).includes(v.planner)) throw invalid('planner')
104
+ if (v.effort !== undefined && !(WORK_CHAT_EFFORTS as readonly unknown[]).includes(v.effort)) throw invalid('effort')
105
+ if (v.orchestrator !== undefined && typeof v.orchestrator !== 'boolean') throw invalid('orchestrator')
106
+ if (v.source !== 'lens') throw invalid('source')
107
+ if (!iso(v.createdAt)) throw invalid('createdAt')
108
+ if (!Array.isArray(v.assignments) || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')
109
+ const assignments: LensRoundInput['assignments'] = []
110
+ v.assignments.forEach((a: unknown, i: number) => {
111
+ if (!obj(a) || a.id !== `a${i + 1}`) throw invalid('assignments.id', 'Assignment ids must be a1 to aN in answer order.')
112
+ const mode = a.mode ?? 'continue'
113
+ if (!(ASSIGNMENT_MODES as readonly unknown[]).includes(mode)) throw invalid('assignments.mode')
114
+ if (mode !== 'newSession' && !(str(a.sessionID, 64) && SID.test(a.sessionID))) throw invalid('assignments.sessionID')
115
+ if (mode === 'newSession' && a.sessionID !== undefined && a.sessionID !== null) throw invalid('assignments.sessionID')
116
+ if (mode === 'newSession' ? !['claude', 'codex'].includes(a.provider) : (a.provider !== undefined && !['claude', 'codex', 'cursor'].includes(a.provider))) throw invalid('assignments.provider')
117
+ if (!str(a.prompt, LENS_PROMPT_CHARS) || !a.prompt.trim()) throw invalid('assignments.prompt')
118
+ if (a.contextItemIDs !== undefined && !(Array.isArray(a.contextItemIDs) && a.contextItemIDs.length <= WORK_CHAT_MAX_ITEMS
119
+ && a.contextItemIDs.every((id: unknown) => str(id, 24) && ITEM_ID.test(id)))) throw invalid('assignments.contextItemIDs')
120
+ assignments.push({ id: a.id, mode: mode as AssignmentMode, ...(mode !== 'newSession' ? { sessionID: a.sessionID } : {}),
121
+ ...(a.provider ? { provider: a.provider } : {}), prompt: a.prompt,
122
+ ...(a.contextItemIDs ? { contextItemIDs: [...new Set<string>(a.contextItemIDs)] } : {}) })
123
+ })
124
+ return { workID: v.workID, clientRoundId: v.clientRoundId, question: v.question, answer: v.answer, planner: v.planner ?? null,
125
+ effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt }
126
+ }
127
+
128
+ /** The card's sessions as COS Control's snapshot records them: confirmed links, receipt targets and fork children. */
129
+ export function cardSessionsOf(snapshot: ControlSnapshot | null, workID: string): string[] | null {
130
+ if (!snapshot) return null
131
+ const out = new Set<string>()
132
+ for (const l of snapshot.links) if (l.workID === workID) out.add(l.sessionID)
133
+ for (const r of snapshot.receipts) {
134
+ if (r.workID !== workID) continue
135
+ if (r.sessionID && !(r.mode === 'fork' && r.sessionID === r.sourceSessionID)) out.add(r.sessionID)
136
+ if (r.forkChildID) out.add(r.forkChildID)
137
+ }
138
+ return [...out].sort()
139
+ }
140
+
141
+ /**
142
+ * A plan is stale when the card's sessions changed since it was recorded: a session added (a link, a receipt's new
143
+ * session, a fork child) or one gone. Sessions this round's own sends reported (a New session, a fork child) are not
144
+ * a change, so a partly sent plan can send the rest. Unknown on either side (no snapshot) is never stale.
145
+ */
146
+ export function planIsStale(round: LensRound, now: string[] | null): boolean {
147
+ if (!round.cardSessions || !now) return false
148
+ const before = new Set(round.cardSessions)
149
+ const own = new Set(round.assignments.map(a => a.reportedSessionID).filter((s): s is string => !!s && !before.has(s)))
150
+ const after = now.filter(s => !own.has(s))
151
+ return after.length !== before.size || after.some(s => !before.has(s))
152
+ }
153
+
154
+ /**
155
+ * Where a send ended, from its assignments (all final). All reached their sessions: `sent`; some: `partly_sent`. None:
156
+ * `stopped` when every one was stopped, `refused` when every one not stopped was refused (confirmation, a stale plan, a
157
+ * COS Control check), otherwise `failed`.
158
+ */
159
+ export function sendOutcome(states: AssignmentState[]): Outcome {
160
+ const sent = states.filter(s => SENT_LIKE.includes(s)).length
161
+ if (sent === states.length) return 'sent'
162
+ if (sent > 0) return 'partly_sent'
163
+ if (states.every(s => s === 'stopped')) return 'stopped'
164
+ return states.every(s => s === 'refused' || s === 'stopped') ? 'refused' : 'failed'
165
+ }
166
+
167
+ export interface ControlSeen { at: number; autoSend: boolean }
168
+
169
+ export class LensRoundStore {
170
+ private file: StoreFile = { version: 1, seq: 0, rounds: [] }
171
+ private control: ControlSeen | null = null
172
+ constructor(private readonly path: string | null, private readonly now: () => number = Date.now) {
173
+ if (!path || !existsSync(path)) return
174
+ try {
175
+ const v = JSON.parse(readFileSync(path, 'utf8'))
176
+ if (!obj(v) || v.version !== 1 || !Array.isArray(v.rounds) || typeof v.seq !== 'number') throw new Error('unrecognised')
177
+ this.file = { version: 1, seq: v.seq, rounds: v.rounds.filter((r: unknown) => obj(r) && typeof r.roundId === 'string' && ROUND_ID.test(r.roundId)).slice(-LENS_ROUNDS_MAX) }
178
+ } catch (e) {
179
+ // Never overwrite what could not be read: move it aside and start empty.
180
+ const aside = `${path}.unreadable-${this.now()}`
181
+ try { renameSync(path, aside) } catch { /* nothing to move */ }
182
+ console.warn(`[work-lens-rounds] ${path} could not be read (${(e as Error).message}); moved to ${aside}`)
183
+ }
184
+ }
185
+
186
+ private persist(): void {
187
+ if (!this.path) return
188
+ try { mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); atomicWriteFileSync(this.path, JSON.stringify(this.file)) }
189
+ catch (e) { console.warn(`[work-lens-rounds] kept in memory only: ${(e as Error).message}`) }
190
+ }
191
+
192
+ /** Drops rounds past 14 days, expires sends not picked up in 10 minutes, and fails sends never reported. */
193
+ private sweep(): boolean {
194
+ const now = this.now()
195
+ let changed = false
196
+ const kept = this.file.rounds.filter(r => now - r.recordedAt <= LENS_ROUND_TTL_MS)
197
+ if (kept.length !== this.file.rounds.length) { this.file.rounds = kept; changed = true }
198
+ for (const r of this.file.rounds) {
199
+ const send = r.sends[r.sends.length - 1]
200
+ if (!send) continue
201
+ if (send.state === 'send_requested' && now - send.requestedAt > LENS_SEND_PICKUP_MS) {
202
+ send.state = 'expired'; send.finishedAt = now; r.updatedAt = now; changed = true
203
+ } else if (send.state === 'sending' && send.pickedUpAt !== null && now - send.pickedUpAt > LENS_SEND_REPORT_MS) {
204
+ const mine = r.assignments.filter(a => send.assignmentIds.includes(a.id))
205
+ const silent = mine.filter(a => a.state === 'pending')
206
+ if (silent.length) {
207
+ for (const a of silent) { a.state = 'failed'; a.detail = 'COS Control did not report this send.'; a.updatedAt = new Date(now).toISOString() }
208
+ this.settle(r, send, now); changed = true
209
+ }
210
+ }
211
+ }
212
+ return changed
213
+ }
214
+
215
+ private settle(r: LensRound, send: LensSendRequest, now: number): void {
216
+ const states = r.assignments.filter(a => send.assignmentIds.includes(a.id)).map(a => a.state)
217
+ if (states.every(s => TERMINAL.includes(s))) { send.state = sendOutcome(states); send.finishedAt ??= now }
218
+ else if (send.state !== 'sending') { send.state = 'sending'; send.finishedAt = null }
219
+ r.updatedAt = now
220
+ }
221
+
222
+ private tick(): void { if (this.sweep()) this.persist() }
223
+
224
+ find(roundId: string): LensRound | undefined { this.tick(); return this.file.rounds.find(r => r.roundId === roundId) }
225
+
226
+ controlStatus(): { connected: boolean; lastSeenSec: number | null; autoSend: boolean } {
227
+ const c = this.control
228
+ if (!c) return { connected: false, lastSeenSec: null, autoSend: false }
229
+ const age = Math.max(0, this.now() - c.at)
230
+ return { connected: age <= LENS_CONTROL_FRESH_MS, lastSeenSec: Math.round(age / 1000), autoSend: c.autoSend }
231
+ }
232
+
233
+ record(input: LensRoundInput, cardSessions: string[] | null): { round: LensRound; created: boolean } {
234
+ this.tick()
235
+ const existing = this.replay(input.clientRoundId)
236
+ if (existing) return { round: existing, created: false }
237
+ const now = this.now()
238
+ const round: LensRound = {
239
+ roundId: newId('lr'), seq: ++this.file.seq, clientRoundId: input.clientRoundId, workID: input.workID, question: input.question,
240
+ answer: input.answer, planner: input.planner, effort: input.effort, orchestrator: input.orchestrator,
241
+ assignments: input.assignments.map(a => ({ ...a, state: 'pending' as const })), createdAt: input.createdAt, recordedAt: now,
242
+ inHistory: false, historyAt: null, autoSendCard: false, cardSessions, sends: [], stops: [], updatedAt: now,
243
+ }
244
+ this.file.rounds.push(round)
245
+ while (this.file.rounds.length > LENS_ROUNDS_MAX) {
246
+ const idle = this.file.rounds.findIndex(r => !r.sends.some(s => s.state === 'send_requested' || s.state === 'sending'))
247
+ this.file.rounds.splice(idle >= 0 ? idle : 0, 1)
248
+ }
249
+ this.persist()
250
+ return { round, created: true }
251
+ }
252
+
253
+ replay(clientRoundId: string): LensRound | undefined { return this.file.rounds.find(r => r.clientRoundId === clientRoundId) }
254
+
255
+ private active(r: LensRound): LensSendRequest | undefined {
256
+ const s = r.sends[r.sends.length - 1]
257
+ return s && (s.state === 'send_requested' || s.state === 'sending') ? s : undefined
258
+ }
259
+
260
+ /**
261
+ * A send request. Omitted ids mean the whole plan, minus assignments already sent (so a partly sent plan sends the
262
+ * rest); naming an assignment already sent, or nothing left to send, is `already_sent`.
263
+ */
264
+ requestSend(roundId: string, body: { assignmentIds?: string[]; confirm: 'two-step' | null; confirmedAt: string | null },
265
+ checks: { cardSessions: string[] | null; controlConnected: boolean }): LensSendRequest {
266
+ const r = this.find(roundId)
267
+ if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
268
+ if (this.active(r)) throw new LensRoundError('send_in_progress', 409, 'This plan is already being sent.')
269
+ const known = new Set(r.assignments.map(a => a.id))
270
+ if (body.assignmentIds && body.assignmentIds.some(id => !known.has(id))) throw invalid('assignmentIds', 'That assignment is not in this plan.')
271
+ const sentAlready = (id: string) => ALREADY.includes(r.assignments.find(a => a.id === id)!.state)
272
+ const ids = body.assignmentIds ? [...new Set(body.assignmentIds)] : r.assignments.map(a => a.id).filter(id => !sentAlready(id))
273
+ if (!ids.length || ids.some(sentAlready)) {
274
+ throw new LensRoundError('already_sent', 409, 'This plan was already sent.', { assignmentIds: r.assignments.map(a => a.id).filter(sentAlready) })
275
+ }
276
+ if (planIsStale(r, checks.cardSessions)) throw new LensRoundError('plan_stale', 409, "This card's sessions changed since the plan. Plan again.")
277
+ if (body.confirm !== 'two-step' && !r.autoSendCard) throw new LensRoundError('confirmation_required', 428, 'Confirm on the lens to send this plan.')
278
+ if (!checks.controlConnected) throw new LensRoundError('control_not_connected', 423, 'COS Control is not connected. The plan is saved; send it from the Mac.')
279
+ const now = this.now()
280
+ const send: LensSendRequest = { id: newId('ls'), assignmentIds: ids, confirm: body.confirm, confirmedAt: body.confirmedAt,
281
+ autoSend: body.confirm !== 'two-step', requestedAt: now, pickedUpAt: null, state: 'send_requested', finishedAt: null }
282
+ for (const a of r.assignments) if (ids.includes(a.id)) { a.state = 'pending'; delete a.detail; delete a.receiptId }
283
+ r.sends.push(send); if (r.sends.length > MAX_SENDS_KEPT) r.sends.splice(0, r.sends.length - MAX_SENDS_KEPT)
284
+ r.updatedAt = now
285
+ this.persist()
286
+ return send
287
+ }
288
+
289
+ requestStop(roundId: string, assignmentIds: string[], controlConnected: boolean): LensStopRequest {
290
+ const r = this.find(roundId)
291
+ if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
292
+ const known = new Set(r.assignments.map(a => a.id))
293
+ if (!assignmentIds.length || assignmentIds.some(id => !known.has(id))) throw invalid('assignmentIds', 'That assignment is not in this plan.')
294
+ if (!controlConnected) throw new LensRoundError('control_not_connected', 423, 'COS Control is not connected. Stop it from the Mac.')
295
+ const now = this.now()
296
+ const ids = [...new Set(assignmentIds)]
297
+ const send = [...r.sends].reverse().find(s => ids.some(id => s.assignmentIds.includes(id))) ?? null
298
+ const stop: LensStopRequest = { id: newId('lst'), assignmentIds: ids, sendRequestId: send?.id ?? null, requestedAt: now, pickedUpAt: null }
299
+ r.stops.push(stop); if (r.stops.length > MAX_STOPS_KEPT) r.stops.splice(0, r.stops.length - MAX_STOPS_KEPT)
300
+ r.updatedAt = now
301
+ this.persist()
302
+ return stop
303
+ }
304
+
305
+ /**
306
+ * COS Control's poll. Lists rounds not yet in history (after `since`, oldest first), send requests waiting to be picked
307
+ * up and stop requests, never more than 50 in all. Listing a send or a stop is its pick-up: it is listed once.
308
+ */
309
+ inbox(since: number, autoSend: boolean) {
310
+ this.tick()
311
+ const now = this.now()
312
+ this.control = { at: now, autoSend }
313
+ const rounds: LensRound[] = [], sends: { round: LensRound; send: LensSendRequest }[] = [], stops: { round: LensRound; stop: LensStopRequest }[] = []
314
+ let room = LENS_INBOX_MAX, changed = false
315
+ for (const r of this.file.rounds) {
316
+ for (const s of r.sends) if (room > 0 && s.state === 'send_requested') { s.state = 'sending'; s.pickedUpAt = now; r.updatedAt = now; sends.push({ round: r, send: s }); room--; changed = true }
317
+ for (const s of r.stops) if (room > 0 && s.pickedUpAt === null) { s.pickedUpAt = now; stops.push({ round: r, stop: s }); room--; changed = true }
318
+ }
319
+ for (const r of this.file.rounds) if (room > 0 && !r.inHistory && r.seq > since) { rounds.push(r); room-- }
320
+ if (changed) this.persist()
321
+ const content = (r: LensRound) => ({ roundId: r.roundId, workID: r.workID, clientRoundId: r.clientRoundId, question: r.question, answer: r.answer,
322
+ planner: r.planner, effort: r.effort, orchestrator: r.orchestrator, createdAt: r.createdAt, recordedAt: new Date(r.recordedAt).toISOString(),
323
+ assignments: r.assignments.map(a => ({ id: a.id, mode: a.mode, ...(a.sessionID ? { sessionID: a.sessionID } : {}), ...(a.provider ? { provider: a.provider } : {}),
324
+ prompt: a.prompt, ...(a.contextItemIDs ? { contextItemIDs: a.contextItemIDs } : {}) })) })
325
+ return {
326
+ v: 1 as const,
327
+ cursor: rounds.reduce((m, r) => Math.max(m, r.seq), since),
328
+ rounds: rounds.map(content),
329
+ sends: sends.map(({ round, send }) => ({ sendRequestId: send.id, roundId: round.roundId, workID: round.workID, assignmentIds: send.assignmentIds,
330
+ confirm: send.confirm, confirmedAt: send.confirmedAt, autoSend: send.autoSend, requestedAt: new Date(send.requestedAt).toISOString(),
331
+ round: content(round) })),
332
+ stops: stops.map(({ round, stop }) => ({ stopRequestId: stop.id, roundId: round.roundId, workID: round.workID, assignmentIds: stop.assignmentIds,
333
+ sendRequestId: stop.sendRequestId, requestedAt: new Date(stop.requestedAt).toISOString() })),
334
+ }
335
+ }
336
+
337
+ markInHistory(roundId: string, autoSendCard: boolean | undefined): LensRound {
338
+ const r = this.find(roundId)
339
+ if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
340
+ const now = this.now()
341
+ if (!r.inHistory) { r.inHistory = true; r.historyAt = now }
342
+ if (autoSendCard !== undefined) r.autoSendCard = autoSendCard
343
+ r.updatedAt = now
344
+ this.persist()
345
+ return r
346
+ }
347
+
348
+ /**
349
+ * COS Control sent assignments of this round with its own Send on the Mac (no lens send request). Their states land on
350
+ * the round, so a later lens send never delivers them twice (`already_sent`) and an omitted list sends only the rest.
351
+ */
352
+ reportMac(roundId: string, results: { id: string; state: AssignmentState; receiptId?: string; detail?: string; sessionID?: string }[]): LensRound {
353
+ const r = this.find(roundId)
354
+ if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
355
+ if (results.some(x => !r.assignments.some(a => a.id === x.id))) throw invalid('assignments.id', 'That assignment is not in this plan.')
356
+ const now = this.now(), at = new Date(now).toISOString()
357
+ for (const x of results) {
358
+ const a = r.assignments.find(y => y.id === x.id)!
359
+ a.state = x.state; a.updatedAt = at
360
+ if (x.receiptId) a.receiptId = x.receiptId
361
+ if (x.detail !== undefined) a.detail = x.detail
362
+ if (x.sessionID) a.reportedSessionID = x.sessionID
363
+ }
364
+ r.mac = { at: now, ids: [...new Set([...(r.mac?.ids ?? []), ...results.map(x => x.id)])] }
365
+ r.updatedAt = now
366
+ this.persist()
367
+ return r
368
+ }
369
+
370
+ report(sendRequestId: string, results: { id: string; state: AssignmentState; receiptId?: string; detail?: string; sessionID?: string }[]): LensRound {
371
+ this.tick()
372
+ const r = this.file.rounds.find(x => x.sends.some(s => s.id === sendRequestId))
373
+ const send = r?.sends.find(s => s.id === sendRequestId)
374
+ if (!r || !send) throw new LensRoundError('unknown_send', 404, 'No such send request.')
375
+ if (send.state === 'send_requested' || send.state === 'expired') throw new LensRoundError('send_not_picked_up', 409, 'This send request was not picked up.')
376
+ if (results.some(x => !send.assignmentIds.includes(x.id))) throw invalid('assignments.id', 'That assignment is not in this send request.')
377
+ const now = this.now(), at = new Date(now).toISOString()
378
+ for (const x of results) {
379
+ const a = r.assignments.find(y => y.id === x.id)!
380
+ a.state = x.state; a.updatedAt = at
381
+ if (x.receiptId) a.receiptId = x.receiptId
382
+ if (x.detail !== undefined) a.detail = x.detail
383
+ if (x.sessionID) a.reportedSessionID = x.sessionID
384
+ }
385
+ this.settle(r, send, now)
386
+ this.persist()
387
+ return r
388
+ }
389
+ }
390
+
391
+ export function parseSendBody(v: unknown): { assignmentIds?: string[]; confirm: 'two-step' | null; confirmedAt: string | null } {
392
+ if (v !== undefined && !obj(v)) throw invalid('body', 'The send request could not be read.')
393
+ const b = (v ?? {}) as Record<string, any>
394
+ if (b.v !== undefined && b.v !== 1) throw invalid('v')
395
+ if (b.assignmentIds !== undefined && !(Array.isArray(b.assignmentIds) && b.assignmentIds.length >= 1 && b.assignmentIds.length <= WORK_CHAT_MAX_ASSIGNMENTS
396
+ && b.assignmentIds.every((id: unknown) => typeof id === 'string' && /^a[1-9]$/.test(id)))) throw invalid('assignmentIds')
397
+ if (b.confirm !== undefined && b.confirm !== 'two-step') throw invalid('confirm')
398
+ if (b.confirmedAt !== undefined && !iso(b.confirmedAt)) throw invalid('confirmedAt')
399
+ if (b.confirm === 'two-step' && b.confirmedAt === undefined) throw invalid('confirmedAt', 'A confirmed send carries the time it was confirmed.')
400
+ return { ...(b.assignmentIds ? { assignmentIds: b.assignmentIds } : {}), confirm: b.confirm ?? null, confirmedAt: b.confirmedAt ?? null }
401
+ }
402
+
403
+ export function parseStopBody(v: unknown): string[] {
404
+ if (!obj(v) || (v.v !== undefined && v.v !== 1) || !Array.isArray(v.assignmentIds) || v.assignmentIds.length < 1 || v.assignmentIds.length > WORK_CHAT_MAX_ASSIGNMENTS
405
+ || !v.assignmentIds.every((id: unknown) => typeof id === 'string' && /^a[1-9]$/.test(id))) throw invalid('assignmentIds')
406
+ return v.assignmentIds
407
+ }
408
+
409
+ export function parseResultBody(v: unknown): { id: string; state: AssignmentState; receiptId?: string; detail?: string; sessionID?: string }[] {
410
+ if (!obj(v) || !Array.isArray(v.assignments) || v.assignments.length < 1 || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')
411
+ return v.assignments.map((a: unknown) => {
412
+ if (!obj(a) || typeof a.id !== 'string' || !/^a[1-9]$/.test(a.id)) throw invalid('assignments.id')
413
+ if (!(ASSIGNMENT_STATES as readonly unknown[]).includes(a.state)) throw invalid('assignments.state')
414
+ if (a.receiptId !== undefined && a.receiptId !== null && !(typeof a.receiptId === 'string' && RECEIPT_ID.test(a.receiptId))) throw invalid('assignments.receiptId')
415
+ if (a.detail !== undefined && a.detail !== null && !str(a.detail, LENS_DETAIL_CHARS)) throw invalid('assignments.detail')
416
+ if (a.sessionID !== undefined && a.sessionID !== null && !(typeof a.sessionID === 'string' && SID.test(a.sessionID))) throw invalid('assignments.sessionID')
417
+ return { id: a.id, state: a.state, ...(a.receiptId ? { receiptId: a.receiptId } : {}), ...(typeof a.detail === 'string' ? { detail: a.detail } : {}),
418
+ ...(a.sessionID ? { sessionID: a.sessionID } : {}) }
419
+ })
420
+ }
421
+
422
+ /** The round as the lens reads it: state, never the prompts. */
423
+ export function roundView(r: LensRound) {
424
+ const send = r.sends[r.sends.length - 1]
425
+ let state: RoundState = send ? send.state : r.inHistory ? 'in_history' : 'recorded'
426
+ // The newest send was COS Control's own on the Mac: the round reads as that send's outcome.
427
+ if (r.mac && (!send || r.mac.at >= send.requestedAt)) {
428
+ const states = r.assignments.filter(a => r.mac!.ids.includes(a.id)).map(a => a.state)
429
+ state = states.length && states.every(s => TERMINAL.includes(s)) ? sendOutcome(states) : 'sending'
430
+ // Part of the plan sent from the Mac and the rest never sent: partly sent, and the rest may still go from the lens.
431
+ if (state === 'sent' && r.mac.ids.length < r.assignments.length) state = 'partly_sent'
432
+ }
433
+ return {
434
+ roundId: r.roundId, workID: r.workID, state, inHistory: r.inHistory, autoSendCard: r.autoSendCard,
435
+ assignments: r.assignments.map(a => ({ id: a.id, state: a.state, ...(a.reportedSessionID ?? a.sessionID ? { sessionID: a.reportedSessionID ?? a.sessionID } : {}),
436
+ ...(a.receiptId ? { receiptId: a.receiptId } : {}), ...(a.detail ? { detail: a.detail } : {}) })),
437
+ updatedAt: new Date(r.updatedAt).toISOString(),
438
+ }
439
+ }
440
+
441
+ let shared: LensRoundStore | null = null
442
+ /** The process-wide store. Inside a test it is in memory only, so a test never reads or moves this Mac's data file. */
443
+ export function defaultLensRoundStore(): LensRoundStore {
444
+ return shared ??= new LensRoundStore(process.env.VITEST || process.env.NODE_ENV === 'test' ? null : dataPath('work-lens-rounds.json'))
445
+ }
@@ -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
+ }