@gotcos/glasses-server 6.58.2 → 6.59.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.
@@ -1,34 +1,257 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from 'node:fs'
2
3
  import { homedir } from 'node:os'
3
4
  import { dirname, join, resolve } from 'node:path'
5
+ import { flattenDisplayText } from './query-job-types.js'
6
+ import { isValidNativeThreadId } from './native-thread-id.js'
7
+ import { isClaudeModel, isCodexModel, isCursorModel, isOllamaModel, normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
4
8
 
9
+ export type WorkHandoffMode = 'continueSession' | 'fork' | 'newSession'
10
+ /** 6.59.0: what COS Control's tracker recorded (its `WorkProgress`, Sources/WorkProgress.swift). */
11
+ export interface WorkActivityProgress {
12
+ reported: 'done' | 'needsInput' | 'blocked' | null
13
+ reportedBy?: 'session' | 'jev'
14
+ /** At most 280 characters and 1,200 UTF-16 units, cleaned as a session name is. The session's own status line, or Jev's reading. */
15
+ evidence?: string
16
+ receivedAt?: string
17
+ lastMove?: { from: string; to: string; at: string; undone: boolean }
18
+ /** A manual move back (or an Undo) paused automatic moves for this handoff. */
19
+ paused: boolean
20
+ }
21
+ /** A progress block the server could not read: there may be a report it cannot show. */
22
+ export interface UnreadableWorkProgress { unreadable: true }
5
23
  export interface WorkActivity {
6
24
  id: string; workId: string; domain: string; status: string
7
25
  provider?: string; sessionId?: string; sessionTitle?: string; error?: string
26
+ // 6.59.0, all optional for older clients.
27
+ mode?: WorkHandoffMode
28
+ model?: string
29
+ createdAt?: string
30
+ updatedAt?: string
31
+ progress?: WorkActivityProgress | UnreadableWorkProgress
32
+ acknowledged?: boolean
33
+ /** Per SESSION: a receipt handed this handoff's session to its app (opened there, a 0.5.248 tab, or channel app). */
34
+ appOpened?: boolean
35
+ /** Per SESSION: the COS server is still running a Work New session on this handoff's session. */
36
+ serverHold?: boolean
37
+ /** Per SESSION: a note for this handoff's session is queued in its app (status queued, channel app); nothing reached the session. */
38
+ waitingInApp?: boolean
39
+ requestedFrom?: 'glasses'
40
+ /** The handoff request this receipt answered (Control 0.5.252 writes it). */
41
+ requestId?: string
42
+ /** How Control delivered it: job, fork, app, turn, queue or tab. */
43
+ channel?: string
44
+ /**
45
+ * A send in flight: on disk it reads preparing or sending and was made in the last 10 minutes. `status` still reads
46
+ * `unknown` (older clients); with this flag it is "Sending", not an unresolved delivery. Absent otherwise.
47
+ */
48
+ sending?: true
49
+ }
50
+ /** 6.59.0 (S4): where Control's saved draft for the task's current revision sends. Never the draft's prompt. */
51
+ export interface SavedDestination { mode: WorkHandoffMode; provider: string; model?: string; sessionId?: string }
52
+ /**
53
+ * `requests` is 1 only while COS Control is consuming the inbox (it listed pending requests in the last 120 s);
54
+ * `requestsInbox` is 1 when the inbox opened on this server; `requestsConsumerSeenAt` is Control's last listing.
55
+ */
56
+ export interface WorkActivityCapabilities { progress: 1; requests: 0 | 1; requestsInbox: 0 | 1; requestsConsumerSeenAt: string | null }
57
+ export interface WorkActivityResult {
58
+ version: 1; available: boolean; reason?: string; capabilities?: WorkActivityCapabilities
59
+ activities: WorkActivity[]; savedDestination?: SavedDestination
60
+ /** The task's own revision (Control's taskSnapshot), for a handoff request's expectedTaskRevision. */
61
+ taskRevision?: string
8
62
  }
9
- export interface WorkActivityResult { version: 1; available: boolean; reason?: string; activities: WorkActivity[] }
10
63
  /** These options are dependency injection for fixtures, never request parameters. */
11
64
  export interface WorkActivityOptions {
12
65
  journalPath?: string; home?: string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv
13
66
  }
14
- const unavailable = (reason: string): WorkActivityResult => ({ version: 1, available: false, reason, activities: [] })
67
+ type JournalRecord = Record<string, unknown>
68
+ /** A native journal that passed every safety and format check. Its receipts and drafts are the validated raw records. */
69
+ export interface WorkJournal { available: true; receipts: JournalRecord[]; drafts: JournalRecord[] }
70
+ export type WorkJournalRead = WorkJournal | { available: false; reason: string }
71
+
72
+ export const WORK_ACTIVITY_LIMITS = { evidence: 280, evidenceUnits: 1_200, openItems: 50, trackedDays: 14, progressEvents: 1_000, sendingSeconds: 600 } as const
73
+ /** Control's `TaskRow.workStages` (and task-store WORK_STAGES). A move naming anything else is not projected. */
74
+ export const WORK_ACTIVITY_STAGES: readonly string[] = ['mentioned', 'planned', 'draft', 'built', 'qa', 'complete']
75
+ const unavailableJournal = (reason: string): WorkJournalRead => ({ available: false, reason })
15
76
  const statuses = new Set(['preparing', 'sending', 'queued', 'running', 'delivered', 'completed', 'failed', 'refused', 'reviewed', 'canceled', 'unknown'])
77
+ /** Control's `WorkHandoffReceipt.terminalStatuses`: never block another handoff. */
78
+ const terminal = new Set(['completed', 'failed', 'refused', 'canceled', 'reviewed'])
16
79
  const providers = new Set(['claude', 'codex', 'cursor', 'ollama'])
80
+ /** Control's provider sets (WorkHandoffStore.swift): Continue, native Fork, Fork targets across platforms, Fork sources. */
81
+ export const CONTINUE_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
82
+ export const FORK_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex'])
83
+ const CROSS_PLATFORM_TARGETS: ReadonlySet<string> = new Set(['claude', 'codex'])
84
+ const EXPORTABLE_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
85
+ /** Control's draftLimit: 32,000 less the status-line reserve, in UTF-16 units. */
86
+ const DRAFT_LIMIT = 32_000 - 480
87
+ const CHANNELS: ReadonlySet<string> = new Set(['job', 'fork', 'app', 'turn', 'queue', 'tab'])
88
+ export const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
17
89
  const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
18
90
  const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.length <= max
91
+ const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
92
+ const absentOr = (value: unknown, check: (v: unknown) => boolean): boolean => value === undefined || value === null || check(value)
93
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
94
+ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
19
95
 
20
- /** Read a native receipt projection, not a provider status or task-completion verdict.
21
- * Atomic native journal replacement permits a consistent opened-file read without
22
- * taking its dispatch lock. No title/keyword matching, inferred target or writes.
96
+ /**
97
+ * A Work session id exactly as a handoff request may name one: `provider:native` with provider claude, codex or cursor
98
+ * and native a full lowercase thread UUID (native-thread-id.ts). Never a short id, a path, or a nested `provider:`.
99
+ */
100
+ export function parseWorkSessionId(value: unknown): { provider: string; native: string } | null {
101
+ if (typeof value !== 'string') return null
102
+ const at = value.indexOf(':'), provider = value.slice(0, at), native = value.slice(at + 1)
103
+ return at > 0 && CONTINUE_PROVIDERS.has(provider) && isValidNativeThreadId(native) ? { provider, native } : null
104
+ }
105
+ /** The provider a model slot runs on. */
106
+ export function modelProvider(slot: ModelPreference): 'claude' | 'codex' | 'cursor' | 'ollama' {
107
+ return isClaudeModel(slot) ? 'claude' : isCodexModel(slot) ? 'codex' : isCursorModel(slot) ? 'cursor' : isOllamaModel(slot) ? 'ollama' : 'claude'
108
+ }
109
+ /**
110
+ * Control's ClaudeSession.sameSession (Models.swift): two `provider:native` ids name one session when the providers
111
+ * match and the native ids are equal, or one is the first 8 or more characters of the other. Case-insensitive.
23
112
  */
24
- export function readWorkActivity(domain: string, workIdentity: string, options: WorkActivityOptions = {}): WorkActivityResult {
113
+ export function sameSession(a: string, b: string): boolean {
114
+ const split = (v: string) => { const at = v.indexOf(':'); return at > 0 && at < v.length - 1 ? [v.slice(0, at).toLowerCase(), v.slice(at + 1).toLowerCase()] : null }
115
+ const x = split(a), y = split(b)
116
+ if (!x || !y || x[0] !== y[0]) return false
117
+ if (x[1] === y[1]) return true
118
+ const [short, long] = x[1].length <= y[1].length ? [x[1], y[1]] : [y[1], x[1]]
119
+ return short.length >= 8 && long.startsWith(short)
120
+ }
121
+
122
+ /** Control's clock (seconds since 1970) as ISO, or nothing for a value no Date can hold. */
123
+ function isoFromSeconds(seconds: unknown): string | undefined {
124
+ return finite(seconds) && seconds > 0 && seconds < 1e11 ? new Date(seconds * 1000).toISOString() : undefined
125
+ }
126
+
127
+ /**
128
+ * Cleaned as a session name is (6.58.2 sanitizeSessionName's characters: controls, zero-width space, direction marks
129
+ * and overrides as spaces, joiners kept, whitespace collapsed), then at most 280 whole characters AND 1,200 UTF-16
130
+ * units, so one character built of many marks cannot make it large. A cut ends in an ellipsis, as Control's clip does.
131
+ */
132
+ export function cleanEvidence(value: unknown): string | undefined {
133
+ if (typeof value !== 'string') return undefined
134
+ const flat = flattenDisplayText(value)
135
+ if (!flat) return undefined
136
+ const chars = Array.from(graphemes.segment(flat), part => part.segment)
137
+ if (chars.length <= WORK_ACTIVITY_LIMITS.evidence && flat.length <= WORK_ACTIVITY_LIMITS.evidenceUnits) return flat
138
+ let kept = ''
139
+ for (const char of chars.slice(0, WORK_ACTIVITY_LIMITS.evidence - 1)) {
140
+ if (kept.length + char.length > WORK_ACTIVITY_LIMITS.evidenceUnits - 1) break
141
+ kept += char
142
+ }
143
+ return kept.trimEnd() + '…'
144
+ }
145
+
146
+ /**
147
+ * The receipt's optional `progress` block, parsed on its own. Absent is undefined; a malformed block reads
148
+ * `{ unreadable: true }` and never makes the journal unavailable: Control writes it, and a newer Control may write more.
149
+ */
150
+ function parseProgress(raw: unknown): { projection: WorkActivityProgress | UnreadableWorkProgress; newestAt?: number } | undefined {
151
+ if (raw === undefined || raw === null) return undefined
152
+ const unreadable = { projection: { unreadable: true as const } }
153
+ if (!object(raw)) return unreadable
154
+ const { reported, reportedBy, evidence, receivedAt, paused, events } = raw
155
+ if (!absentOr(reported, v => typeof v === 'string') || !absentOr(reportedBy, v => typeof v === 'string')
156
+ || !absentOr(evidence, v => text(v, 100_000)) || !absentOr(receivedAt, finite) || !absentOr(paused, v => typeof v === 'boolean')
157
+ || !(events === undefined || (Array.isArray(events) && events.length <= WORK_ACTIVITY_LIMITS.progressEvents))) return unreadable
158
+ const list = (events ?? []) as unknown[]
159
+ let newestAt: number | undefined
160
+ for (const event of list) {
161
+ if (!object(event) || !finite(event.at) || typeof event.kind !== 'string'
162
+ || !absentOr(event.fromStage, v => typeof v === 'string') || !absentOr(event.toStage, v => typeof v === 'string')
163
+ || !absentOr(event.undoneAt, finite)) return unreadable
164
+ newestAt = newestAt === undefined ? event.at : Math.max(newestAt, event.at)
165
+ }
166
+ // Control decodes a kind it does not know as a note, which is no report.
167
+ const state = reported === 'done' || reported === 'needsInput' || reported === 'blocked' ? reported : null
168
+ const projection: WorkActivityProgress = { reported: state, paused: paused === true }
169
+ if (state) {
170
+ if (reportedBy === 'session' || reportedBy === 'jev') projection.reportedBy = reportedBy
171
+ const quoted = cleanEvidence(evidence)
172
+ if (quoted) projection.evidence = quoted
173
+ }
174
+ const received = isoFromSeconds(receivedAt)
175
+ if (received) projection.receivedAt = received
176
+ // The newest move COS made, undone or not (Control's timeline order: events are appended).
177
+ const move = [...list].reverse().find(event => (event as JournalRecord).kind === 'moved') as JournalRecord | undefined
178
+ const moveAt = move && isoFromSeconds(move.at)
179
+ if (move && moveAt && WORK_ACTIVITY_STAGES.includes(move.fromStage as string) && WORK_ACTIVITY_STAGES.includes(move.toStage as string)) {
180
+ projection.lastMove = { from: move.fromStage as string, to: move.toStage as string, at: moveAt, undone: finite(move.undoneAt) }
181
+ }
182
+ return { projection, newestAt }
183
+ }
184
+ /** The report a progress projection carries, or null (none, or unreadable). */
185
+ export function reportOf(progress: WorkActivity['progress']): WorkActivityProgress['reported'] {
186
+ return progress && !('unreadable' in progress) ? progress.reported : null
187
+ }
188
+
189
+ /** A 0.5.248 tab that never reached a session. Control reads it as canceled on load (WorkHandoffStore.unsentTab). */
190
+ const unsentTab = (receipt: JournalRecord): boolean => receipt.channel === 'tab' && receipt.status === 'queued' && receipt.sessionID == null
191
+ /** The status Control shows for a receipt it loaded: an interrupted send is unknown, an unsent tab canceled. */
192
+ const loadedStatus = (receipt: JournalRecord): string => ['preparing', 'sending'].includes(receipt.status as string) ? 'unknown'
193
+ : unsentTab(receipt) ? 'canceled' : receipt.status as string
194
+ const sessionOf = (receipt: JournalRecord): string | null => typeof receipt.sessionID === 'string' && receipt.sessionID !== '' ? receipt.sessionID : null
195
+ /** Control's appOwner, plus a channel app receipt (a Continue it handed to the app): the app owns the session. */
196
+ const ownsInApp = (r: JournalRecord) => (object(r.appOpen) && finite(r.appOpen.openedAt)) || r.channel === 'tab' || r.channel === 'app'
197
+ /** Control's serverHolds: a Work New session the COS server is still running. */
198
+ const serverHolds = (r: JournalRecord) => r.channel === 'job' && r.mode === 'newSession' && typeof r.sessionID === 'string' && !terminal.has(r.status as string)
199
+
200
+ function projectReceipt(receipt: JournalRecord, domain: string, all: readonly JournalRecord[], nowMs: number): WorkActivity {
201
+ const raw = receipt.status as string, status = loadedStatus(receipt)
202
+ const activity: WorkActivity = { id: receipt.id as string, workId: receipt.workID as string, domain, status }
203
+ // Control writes preparing or sending just before it sends, for a few seconds. Older than 10 minutes, it was interrupted.
204
+ if (['preparing', 'sending'].includes(raw) && nowMs / 1000 - (receipt.createdAt as number) < WORK_ACTIVITY_LIMITS.sendingSeconds) activity.sending = true
205
+ // Native sessionIDs are provider-qualified. Refuse cross-provider identity;
206
+ // missing ownership stays missing rather than being inferred from titles.
207
+ if (providers.has(receipt.provider as string)) {
208
+ const provider = receipt.provider as string
209
+ activity.provider = provider
210
+ if (typeof receipt.sessionID === 'string' && receipt.sessionID.startsWith(provider + ':')
211
+ && receipt.sessionID.length > provider.length + 1 && !/[\x00-\x1f\x7f]/.test(receipt.sessionID)) {
212
+ activity.sessionId = receipt.sessionID
213
+ activity.sessionTitle = (receipt.sessionTitle as string).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 500)
214
+ }
215
+ }
216
+ if (['unknown', 'failed', 'refused', 'canceled'].includes(status)) activity.error = status === 'unknown'
217
+ ? 'Delivery is unresolved. Inspect the original session before trying again.'
218
+ : 'This handoff did not complete successfully. Open COS Control for details.'
219
+ // 6.59.0 (S1). Never prompt, detail, result or the work title.
220
+ activity.mode = receipt.mode as WorkHandoffMode
221
+ // Only a New session is sent with a model. Control records the placeholder "existing-session" for Continue and Fork.
222
+ const model = (receipt.modelID as string).replace(CONTROL, ' ').trim()
223
+ if (receipt.mode === 'newSession' && model && model !== 'existing-session') activity.model = model
224
+ const progress = parseProgress(receipt.progress)
225
+ const createdAt = isoFromSeconds(receipt.createdAt)
226
+ if (createdAt) {
227
+ activity.createdAt = createdAt
228
+ activity.updatedAt = progress?.newestAt !== undefined
229
+ ? isoFromSeconds(Math.max(receipt.createdAt as number, progress.newestAt)) ?? createdAt : createdAt
230
+ }
231
+ if (progress) activity.progress = progress.projection
232
+ // Control's handledByYou: acknowledged, or an unknown or delivered handoff you marked reviewed.
233
+ activity.acknowledged = finite(receipt.acknowledgedAt) || raw === 'reviewed'
234
+ // Ownership is the SESSION's, across every receipt that names it (Control's appOwner and serverHold look at all of them).
235
+ const session = sessionOf(receipt)
236
+ const onSession = session ? all.filter(r => { const other = sessionOf(r); return other !== null && sameSession(other, session) }) : []
237
+ activity.appOpened = onSession.some(ownsInApp)
238
+ activity.serverHold = onSession.some(serverHolds)
239
+ activity.waitingInApp = onSession.some(r => r.channel === 'app' && loadedStatus(r) === 'queued')
240
+ if (receipt.requestedFrom === 'glasses') activity.requestedFrom = 'glasses'
241
+ if (typeof receipt.requestId === 'string' && UUID_V4.test(receipt.requestId)) activity.requestId = receipt.requestId
242
+ if (CHANNELS.has(receipt.channel as string)) activity.channel = receipt.channel as string
243
+ return activity
244
+ }
245
+
246
+ /** Read the native journal with every safety and format check. No title/keyword matching, inferred target or writes. */
247
+ export function readWorkJournal(options: WorkActivityOptions = {}): WorkJournalRead {
25
248
  const env = options.env ?? process.env, home = options.home ?? homedir()
26
249
  if (!options.journalPath) {
27
- if ((options.platform ?? process.platform) !== 'darwin') return unavailable('Native Work history is available only on its Mac host.')
250
+ if ((options.platform ?? process.platform) !== 'darwin') return unavailableJournal('Native Work history is available only on its Mac host.')
28
251
  if (env.NODE_ENV === 'test' || env.VITEST || env.COS_CONTROL_TEST_HOME || env.COS_CONTROL2_ISOLATED_PREVIEW === '1'
29
252
  || env.COS_WORK_CONNECTED_TEST === '1'
30
253
  || (env.COS_DATA_DIR && resolve(env.COS_DATA_DIR) !== join(home, '.cos-glasses', 'data'))) {
31
- return unavailable('This isolated runtime does not read the native Work history.')
254
+ return unavailableJournal('This isolated runtime does not read the native Work history.')
32
255
  }
33
256
  }
34
257
  const path = options.journalPath ?? join(home, 'Library', 'Application Support', 'COS Control', 'work-handoffs', 'handoffs.json')
@@ -39,37 +262,37 @@ export function readWorkActivity(domain: string, workIdentity: string, options:
39
262
  let parent = dirname(resolve(path))
40
263
  while (true) {
41
264
  const stat = lstatSync(parent)
42
- if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailable('Native Work history is not safely readable.')
265
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailableJournal('Native Work history is not safely readable.')
43
266
  const next = dirname(parent); if (next === parent) break; parent = next
44
267
  }
45
268
  fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
46
269
  const stat = fstatSync(fd)
47
- if (!stat.isFile() || stat.size >= 10_000_000 || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) return unavailable('Native Work history is not safely readable.')
270
+ if (!stat.isFile() || stat.size >= 10_000_000 || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) return unavailableJournal('Native Work history is not safely readable.')
48
271
  const buffer = Buffer.alloc(Math.min(stat.size + 1, 10_000_000))
49
272
  let count = 0
50
273
  while (count < buffer.length) { const n = readSync(fd, buffer, count, buffer.length - count, null); if (!n) break; count += n }
51
274
  const after = fstatSync(fd), raw = buffer.subarray(0, count)
52
- if (count !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || raw.length >= 10_000_000) return unavailable('Native Work history exceeds the supported limit.')
275
+ if (count !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || raw.length >= 10_000_000) return unavailableJournal('Native Work history exceeds the supported limit.')
53
276
  const journal: unknown = JSON.parse(raw.toString('utf8'))
54
277
  if (!object(journal) || ![1, 2].includes(journal.version as number) || !Array.isArray(journal.receipts)
55
278
  || journal.receipts.length > 10_000 || !Array.isArray(journal.sessions)
56
- || (journal.drafts !== undefined && !Array.isArray(journal.drafts))) return unavailable('Native Work history has an unsupported format.')
279
+ || (journal.drafts !== undefined && !Array.isArray(journal.drafts))) return unavailableJournal('Native Work history has an unsupported format.')
57
280
  // Match native Codable's required session/draft fields. A malformed journal
58
281
  // is unavailable even when its receipt array happens to look plausible.
59
282
  for (const session of journal.sessions) {
60
283
  if (!object(session) || !['id','nativeID','provider','title','summary','project','status'].every(key => text(session[key], 100_000))
61
- || ['waitingDetail','failure'].some(key => session[key] != null && !text(session[key], 100_000))) return unavailable('Native Work history has an unsupported format.')
284
+ || ['waitingDetail','failure'].some(key => session[key] != null && !text(session[key], 100_000))) return unavailableJournal('Native Work history has an unsupported format.')
62
285
  }
63
- const draftKeys = new Set<string>()
286
+ const draftKeys = new Set<string>(), drafts: JournalRecord[] = []
64
287
  for (const draft of journal.drafts ?? []) {
65
288
  if (!object(draft) || !['sourceID','sourceRevision','sessionID','provider','modelID','prompt'].every(key => text(draft[key], 100_000))
66
289
  || !draft.sourceID || !draft.sourceRevision || !['continueSession','fork','newSession'].includes(draft.mode as string)
67
- || typeof draft.editVersion !== 'number' || !Number.isSafeInteger(draft.editVersion) || draft.editVersion < 0) return unavailable('Native Work history has an unsupported format.')
290
+ || typeof draft.editVersion !== 'number' || !Number.isSafeInteger(draft.editVersion) || draft.editVersion < 0) return unavailableJournal('Native Work history has an unsupported format.')
68
291
  const key = JSON.stringify([draft.sourceID, draft.sourceRevision])
69
- if (draftKeys.has(key)) return unavailable('Native Work history has an unsupported format.')
70
- draftKeys.add(key)
292
+ if (draftKeys.has(key)) return unavailableJournal('Native Work history has an unsupported format.')
293
+ draftKeys.add(key); drafts.push(draft)
71
294
  }
72
- const seen = new Set<string>(), activities: WorkActivity[] = [], workId = `task:${domain}:${workIdentity}`
295
+ const seen = new Set<string>(), receipts: JournalRecord[] = []
73
296
  for (const receipt of journal.receipts) {
74
297
  if (!object(receipt) || !text(receipt.id, 256) || !receipt.id || seen.has(receipt.id)
75
298
  || !text(receipt.workID, 1024) || !text(receipt.workTitle, 8000) || !text(receipt.sourceRevision, 1024)
@@ -79,28 +302,143 @@ export function readWorkActivity(domain: string, workIdentity: string, options:
79
302
  || !text(receipt.detail, 100_000) || !text(receipt.prompt, 100_000)
80
303
  || typeof receipt.createdAt !== 'number' || !Number.isFinite(receipt.createdAt)
81
304
  || ['sessionID','result','sourceSessionID','bindingID','boundTo','channel','jobID','serverInstanceID'].some(key => receipt[key] != null && !text(receipt[key], 1_000_000))
82
- || (receipt.epoch != null && (typeof receipt.epoch !== 'number' || !Number.isSafeInteger(receipt.epoch)))) return unavailable('Native Work history has an unsupported format.')
83
- seen.add(receipt.id)
84
- if (receipt.workID !== workId) continue
85
- const status = ['preparing', 'sending'].includes(receipt.status as string) ? 'unknown' : receipt.status as string
86
- const activity: WorkActivity = { id: receipt.id, workId, domain, status }
87
- // Native sessionIDs are provider-qualified. Refuse cross-provider identity;
88
- // missing ownership stays missing rather than being inferred from titles.
89
- if (providers.has(receipt.provider)) {
90
- activity.provider = receipt.provider
91
- if (typeof receipt.sessionID === 'string' && receipt.sessionID.startsWith(receipt.provider + ':')
92
- && receipt.sessionID.length > receipt.provider.length + 1 && !/[\x00-\x1f\x7f]/.test(receipt.sessionID)) {
93
- activity.sessionId = receipt.sessionID
94
- activity.sessionTitle = receipt.sessionTitle.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 500)
95
- }
96
- }
97
- if (['unknown', 'failed', 'refused', 'canceled'].includes(status)) activity.error = status === 'unknown'
98
- ? 'Delivery is unresolved. Inspect the original session before trying again.'
99
- : 'This handoff did not complete successfully. Open COS Control for details.'
100
- activities.push(activity)
305
+ || (receipt.epoch != null && (typeof receipt.epoch !== 'number' || !Number.isSafeInteger(receipt.epoch)))) return unavailableJournal('Native Work history has an unsupported format.')
306
+ seen.add(receipt.id); receipts.push(receipt)
101
307
  }
102
- return { version: 1, available: true, activities }
308
+ return { available: true, receipts, drafts }
103
309
  } catch (error) {
104
- return unavailable((error as NodeJS.ErrnoException).code === 'ENOENT' ? 'No native Work history is available on this host.' : 'Native Work history could not be read.')
310
+ return unavailableJournal((error as NodeJS.ErrnoException).code === 'ENOENT' ? 'No native Work history is available on this host.' : 'Native Work history could not be read.')
105
311
  } finally { if (fd !== undefined) closeSync(fd) }
106
312
  }
313
+
314
+ /** Every receipt for one exact work item, in journal order. `nowMs` only tells a send in flight from an interrupted one. */
315
+ export function projectWorkActivity(journal: WorkJournal, domain: string, workIdentity: string, nowMs: number): WorkActivity[] {
316
+ const workId = `task:${domain}:${workIdentity}`
317
+ return journal.receipts.filter(receipt => receipt.workID === workId).map(receipt => projectReceipt(receipt, domain, journal.receipts, nowMs))
318
+ }
319
+
320
+ /** The board-row fields Control reads for a task (TaskRow), and the ones the open list needs. */
321
+ export interface WorkBoardRowLite {
322
+ id: string; domain: string; title?: string; text?: string; source?: string; doneWhen?: string
323
+ checked?: boolean; workStage?: string; workIdentity?: string
324
+ meetingRefs?: Array<{ recordId?: unknown; domain?: unknown; month?: unknown; filename?: unknown; title?: unknown }>
325
+ }
326
+ const controlOrFormat = /[\p{Cc}\p{Cf}]/u
327
+ /** Control's WorkMeetingReference(JSONValue): refs it cannot read are left out of the snapshot. */
328
+ function controlMeetingRef(ref: unknown): { recordId: string; domain: string; month: string; filename: string; title: string } | null {
329
+ if (!object(ref)) return null
330
+ const { recordId, domain, month, filename, title } = ref
331
+ if (typeof recordId !== 'string' || !recordId || Buffer.byteLength(recordId) > 2048 || controlOrFormat.test(recordId)
332
+ || typeof domain !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(domain)
333
+ || typeof month !== 'string' || !/^[0-9]{4}-(0[1-9]|1[0-2])$/.test(month)
334
+ || typeof filename !== 'string' || !filename || Buffer.byteLength(filename) > 1024 || filename === '.' || filename === '..'
335
+ || filename.includes('/') || filename.includes('\\') || controlOrFormat.test(filename)) return null
336
+ return { recordId, domain, month, filename, title: typeof title === 'string' ? title : 'Saved meeting' }
337
+ }
338
+
339
+ /**
340
+ * Control's `WorkSource.taskSnapshot(task).revision` for a board row: the SHA-256 of the context it sends. A draft is
341
+ * saved against this revision, Control uses only the draft for the task's current one (`draft(for:)`), and a handoff
342
+ * request names it as `expectedTaskRevision`. Pinned to Control's own Swift in __fixtures__/control-task-snapshot.
343
+ */
344
+ export function controlSnapshotRevision(row: WorkBoardRowLite): string {
345
+ const title = typeof row.title === 'string' ? row.title : ''
346
+ const words = typeof row.text === 'string' ? row.text : title
347
+ const str = (value: unknown) => typeof value === 'string' ? value : ''
348
+ let context = `Task: ${words === '' ? title : words}\nProject: ${str(row.domain)}\nDone when: ${str(row.doneWhen)}\nSource: ${str(row.source)}`
349
+ const refs = (Array.isArray(row.meetingRefs) ? row.meetingRefs : []).map(controlMeetingRef).filter(ref => ref !== null)
350
+ if (refs.length) {
351
+ context += '\nConfirmed meeting references (explicit links; transcript evidence is not included):\n'
352
+ + refs.map(ref => `- ${ref.title} | canonical ID: ${ref.recordId} | saved source: ${ref.domain}/${ref.month}/${ref.filename}`).join('\n')
353
+ }
354
+ return createHash('sha256').update(context, 'utf8').digest('hex')
355
+ }
356
+
357
+ /**
358
+ * A draft's destination, only when Control's sendPlan (WorkHandoffStore.swift) would send exactly that AND a handoff
359
+ * request with it would pass this server's rules. The prompt is read only for being there; it is never returned.
360
+ */
361
+ function draftDestination(draft: JournalRecord): SavedDestination | undefined {
362
+ const mode = draft.mode as WorkHandoffMode, provider = draft.provider as string, prompt = draft.prompt as string
363
+ if (!prompt.trim() || prompt.length > DRAFT_LIMIT) return undefined
364
+ const slot = normalizeModelPreference(draft.modelID)
365
+ if (mode === 'newSession') return slot && modelProvider(slot) === provider ? { mode, provider, model: slot } : undefined
366
+ const session = parseWorkSessionId(draft.sessionID)
367
+ if (!session) return undefined
368
+ const sessionId = draft.sessionID as string
369
+ if (mode === 'continueSession') return { mode, provider: session.provider, sessionId }
370
+ // Fork. Across platforms when the draft names another target platform: Control needs an exportable source and a
371
+ // model of that target; a request takes a Fork only from claude or codex. Otherwise a native Fork, whatever other
372
+ // provider the draft still holds.
373
+ if (CROSS_PLATFORM_TARGETS.has(provider) && provider !== session.provider) {
374
+ if (!EXPORTABLE_PROVIDERS.has(session.provider) || !FORK_PROVIDERS.has(session.provider) || !slot || modelProvider(slot) !== provider) return undefined
375
+ return { mode, provider, model: slot, sessionId }
376
+ }
377
+ return FORK_PROVIDERS.has(session.provider) ? { mode, provider: session.provider, sessionId } : undefined
378
+ }
379
+
380
+ /** S4: the destination saved in Control's draft for this task's current revision, when it names one. */
381
+ export function savedDestinationFor(journal: WorkJournal, row: WorkBoardRowLite): SavedDestination | undefined {
382
+ const sourceID = `task:${row.domain}:${row.workIdentity || row.id}`, revision = controlSnapshotRevision(row)
383
+ const draft = journal.drafts.find(d => d.sourceID === sourceID && d.sourceRevision === revision)
384
+ return draft ? draftDestination(draft) : undefined
385
+ }
386
+
387
+ /** S2 groups, in order. */
388
+ export type OpenWorkGroup = 'needsInput' | 'attention' | 'running' | 'done'
389
+ export interface OpenWorkItem extends WorkActivity { workIdentity: string; title: string; taskRevision: string; group: OpenWorkGroup; savedDestination?: SavedDestination }
390
+ export interface OpenWorkList { items: OpenWorkItem[]; total: number; truncated: boolean }
391
+ const GROUP_RANK: Record<OpenWorkGroup, number> = { needsInput: 0, attention: 1, running: 2, done: 3 }
392
+
393
+ /**
394
+ * S2: every board task whose newest handoff (any revision), sent in the last 14 days, still wants something:
395
+ * - needsInput: it reports needs input or blocked and you have not acknowledged it;
396
+ * - attention: failed, refused, unknown, or completed with no report, and not acknowledged;
397
+ * - running: queued, running or delivered, or a send in flight (preparing or sending, made in the last 10 minutes;
398
+ * an older one was interrupted and is unknown);
399
+ * - done: it reports done and you have not acknowledged it.
400
+ * A report counts only on a receipt that did not fail, was not refused and not canceled (Control's WorkTracking.latest).
401
+ * A checked task shows only while its handoff is queued or running (Control's board). Groups in that order, the most
402
+ * recently updated first within each, cut to 50 after sorting; `total` counts them all. Read-only.
403
+ */
404
+ export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRowLite[], nowMs: number): OpenWorkList {
405
+ const newest = new Map<string, JournalRecord>()
406
+ for (const receipt of journal.receipts) {
407
+ const prior = newest.get(receipt.workID as string)
408
+ // Control's order: createdAt, then id.
409
+ if (!prior || (receipt.createdAt as number) > (prior.createdAt as number)
410
+ || (receipt.createdAt === prior.createdAt && (receipt.id as string) > (prior.id as string))) newest.set(receipt.workID as string, receipt)
411
+ }
412
+ const nowSeconds = nowMs / 1000, ranked: Array<{ item: OpenWorkItem; rank: number; updatedAt: number; createdAt: number }> = []
413
+ const listed = new Set<string>()
414
+ for (const row of rows) {
415
+ const identity = row.workIdentity || row.id, workId = `task:${row.domain}:${identity}`
416
+ if (!/^[a-f0-9]{12}$/.test(identity) || listed.has(workId)) continue
417
+ listed.add(workId)
418
+ const receipt = newest.get(workId)
419
+ if (!receipt || nowSeconds - (receipt.createdAt as number) >= WORK_ACTIVITY_LIMITS.trackedDays * 86_400) continue
420
+ const activity = projectReceipt(receipt, row.domain, journal.receipts, nowMs)
421
+ const status = activity.status, handled = activity.acknowledged === true
422
+ const reported = ['failed', 'refused', 'canceled'].includes(status) ? null : reportOf(activity.progress)
423
+ // A send in flight is running, not unresolved.
424
+ const sending = activity.sending === true
425
+ const group: OpenWorkGroup | null = (reported === 'needsInput' || reported === 'blocked') && !handled ? 'needsInput'
426
+ : reported === 'done' && !handled ? 'done'
427
+ : sending ? 'running'
428
+ : !handled && (['failed', 'refused', 'unknown'].includes(status) || (status === 'completed' && reported === null)) ? 'attention'
429
+ : ['queued', 'running', 'delivered'].includes(status) ? 'running' : null
430
+ if (!group) continue
431
+ // Control drops a completed task's card unless its handoff is still in progress.
432
+ if (row.checked === true && !sending && !['queued', 'running'].includes(status)) continue
433
+ const item: OpenWorkItem = { ...activity, workIdentity: identity, title: typeof row.title === 'string' ? row.title : '', taskRevision: controlSnapshotRevision(row), group }
434
+ const destination = savedDestinationFor(journal, row)
435
+ if (destination) item.savedDestination = destination
436
+ const updatedAt = activity.updatedAt ? Date.parse(activity.updatedAt) : 0
437
+ ranked.push({ item, rank: GROUP_RANK[group], updatedAt, createdAt: receipt.createdAt as number })
438
+ }
439
+ const items = ranked
440
+ .sort((a, b) => a.rank - b.rank || b.updatedAt - a.updatedAt || b.createdAt - a.createdAt || (a.item.id < b.item.id ? 1 : a.item.id > b.item.id ? -1 : 0))
441
+ .slice(0, WORK_ACTIVITY_LIMITS.openItems)
442
+ .map(entry => entry.item)
443
+ return { items, total: ranked.length, truncated: ranked.length > items.length }
444
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 6.59.0: one board read shared by the glasses' Work routes, bounded in time.
3
+ *
4
+ * The board comes from the COS task bridge, one process per domain. It usually answers in about 0.1 s (88 to 101 ms
5
+ * measured on 2026-09-30), but the bridge's own timeout is 12 s, and the glasses give up on a request after about 8 to
6
+ * 10 s. So the activity, open-work and handoff-request routes wait at most `timeoutMs` (5 s) and then answer without
7
+ * the board (503 for a request, which then creates nothing). A read that outlives its caller keeps running and fills
8
+ * the cache, so the next call is fast. Concurrent callers share one read.
9
+ *
10
+ * The cache's age counts from the moment a read FINISHED, so a slow read is still served afterwards. A read that began
11
+ * before the one already cached never replaces it, and `invalidate()` (after a Work stage or meeting write) drops the
12
+ * cache and every read that began before it. GET /api/work-board keeps its own unbounded read (COS Control waits for
13
+ * it) and primes this cache with what it read.
14
+ */
15
+ export const WORK_BOARD_READ_LIMITS = { timeoutMs: 5_000, savedDestinationMs: 3_000 } as const
16
+
17
+ export class WorkBoardTimeoutError extends Error {
18
+ constructor() { super('work_board_timeout') }
19
+ }
20
+
21
+ /** When a read began, and which invalidation it began after. */
22
+ export interface WorkBoardReadToken { startedAt: number; generation: number }
23
+ export interface WorkBoardReader<Row> {
24
+ /** Rows whose read finished no more than `maxAgeMs` ago, a read already running, or a new read; rejects after `timeoutMs`. */
25
+ read(maxAgeMs: number, timeoutMs?: number): Promise<Row[]>
26
+ /** Mark the start of a read made elsewhere (GET /api/work-board), to hand to `prime`. */
27
+ begin(): WorkBoardReadToken
28
+ /** Keep the rows of a read made elsewhere. */
29
+ prime(rows: Row[], token: WorkBoardReadToken): void
30
+ /** The board was just written: nothing read before now may be served. */
31
+ invalidate(): void
32
+ }
33
+
34
+ export function createWorkBoardReader<Row>(list: () => Promise<Row[]>, options: { timeoutMs?: number; now?: () => number } = {}): WorkBoardReader<Row> {
35
+ const defaultTimeoutMs = options.timeoutMs ?? WORK_BOARD_READ_LIMITS.timeoutMs, now = options.now ?? Date.now
36
+ let generation = 0
37
+ let cache: { startedAt: number; endedAt: number; rows: Row[] } | undefined
38
+ let inflight: { generation: number; promise: Promise<Row[]> } | undefined
39
+ const keep = (rows: Row[], token: WorkBoardReadToken) => {
40
+ if (token.generation !== generation) return // it began before a write: it may show the board as it was
41
+ if (cache && token.startedAt < cache.startedAt) return // a newer read is already cached
42
+ cache = { startedAt: token.startedAt, endedAt: now(), rows }
43
+ }
44
+ return {
45
+ read(maxAgeMs, timeoutMs = defaultTimeoutMs) {
46
+ const at = now()
47
+ if (cache && at - cache.endedAt <= maxAgeMs) return Promise.resolve(cache.rows)
48
+ if (!inflight || inflight.generation !== generation) {
49
+ const token = { startedAt: at, generation }
50
+ const promise = Promise.resolve().then(list).then(rows => { keep(rows, token); return rows })
51
+ inflight = { generation, promise }
52
+ promise.catch(() => {}).finally(() => { if (inflight?.promise === promise) inflight = undefined })
53
+ }
54
+ const running = inflight.promise
55
+ let timer: ReturnType<typeof setTimeout> | undefined
56
+ const bound = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new WorkBoardTimeoutError()), timeoutMs) })
57
+ return Promise.race([running, bound]).finally(() => clearTimeout(timer))
58
+ },
59
+ begin() { return { startedAt: now(), generation } },
60
+ prime(rows, token) { keep(rows, token) },
61
+ invalidate() { generation++; cache = undefined },
62
+ }
63
+ }