@gotcos/glasses-server 7.0.7 → 7.0.27

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 (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The session organizer (Sessions chat-style layout, step 2a; contract
3
+ * operations/personal/wk46_2026/sessions_chat_layout/S1_CONTRACT_step2a.md).
4
+ *
5
+ * COS's own folders, COS pins and renamed titles for agent sessions, shared by Control Web and the Mac app. It only
6
+ * ADDS: nothing an agent wrote is changed (a COS pin is not a Claude star), and a list without this store is today's.
7
+ *
8
+ * - One file, data/session-organizer.json, 0600, written durably (temp file, fsync, rename) on every change.
9
+ * - Keyed `<provider>:<canonical id>`, lowercase. The caller canonicalizes (Claude Desktop ids map to their CLI id)
10
+ * BEFORE it reaches this module, so this module never guesses identity.
11
+ * - One change per call, applied in memory and written at once; `rev` rises by one per change. Nothing takes a whole
12
+ * store, so two devices editing different things never overwrite each other.
13
+ * - Folder names and titles never reach a log line: logs carry counts and reason codes only.
14
+ * - A file that exists but cannot be READ makes the store unavailable (no writes), so a transient read error can
15
+ * never be followed by a write that replaces real data with an empty store. A file that reads but does not parse,
16
+ * or has the wrong shape, is moved aside as `.corrupt-<time>` and the store starts empty.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
19
+ import { randomBytes } from 'node:crypto'
20
+ import { dirname } from 'node:path'
21
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
22
+ import { dataPath } from './data-dir.js'
23
+
24
+ export const ORGANIZER_FILE = 'session-organizer.json'
25
+ export const ORGANIZER_LIMITS = { folders: 200, entries: 5_000, folderName: 60, title: 120 } as const
26
+ export type OrganizerProvider = 'claude' | 'codex' | 'cursor'
27
+ const PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
28
+ const FOLDER_ID_RE = /^f_[0-9a-f]{16}$/
29
+
30
+ export interface OrganizerFolder { name: string; created_at: string }
31
+ /** `desktop_id`: for a Claude session, the Claude Desktop id it was written under when one is known (Q1 FIX 2). A row
32
+ * matches an entry by its key OR by this id, so an entry survives the Desktop alias appearing or changing. */
33
+ export interface OrganizerEntry { folder_id?: string; cos_pinned?: true; title?: string; desktop_id?: string; updated_at: string }
34
+ interface OrganizerFile {
35
+ version: 1
36
+ rev: number
37
+ folders: Record<string, OrganizerFolder>
38
+ sessions: Record<string, OrganizerEntry>
39
+ }
40
+
41
+ export class OrganizerError extends Error {
42
+ constructor(readonly status: number, readonly reason: string) { super(reason) }
43
+ }
44
+
45
+ /** What a list row or search hit carries from the store (each omitted when absent). */
46
+ export interface OrganizerRowFields { folder_id?: string; cos_pinned?: true; title_override?: string }
47
+
48
+ /** Trim, drop control characters and line breaks, collapse inner whitespace. Empty means refused. */
49
+ export function cleanOrganizerText(value: unknown, max: number): string | null {
50
+ if (typeof value !== 'string') return null
51
+ // eslint-disable-next-line no-control-regex
52
+ const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim()
53
+ if (!cleaned || [...cleaned].length > max) return null
54
+ return cleaned
55
+ }
56
+
57
+ export function organizerKey(provider: string, sessionId: string): string {
58
+ return `${provider}:${sessionId.trim().toLowerCase()}`
59
+ }
60
+
61
+ function emptyFile(): OrganizerFile { return { version: 1, rev: 0, folders: {}, sessions: {} } }
62
+
63
+ function validShape(value: unknown): value is OrganizerFile {
64
+ if (!value || typeof value !== 'object') return false
65
+ const v = value as Partial<OrganizerFile>
66
+ if (v.version !== 1 || typeof v.rev !== 'number' || !Number.isInteger(v.rev) || v.rev < 0) return false
67
+ if (!v.folders || typeof v.folders !== 'object' || Array.isArray(v.folders)) return false
68
+ if (!v.sessions || typeof v.sessions !== 'object' || Array.isArray(v.sessions)) return false
69
+ for (const [id, folder] of Object.entries(v.folders)) {
70
+ if (!FOLDER_ID_RE.test(id) || !folder || typeof folder.name !== 'string' || typeof folder.created_at !== 'string') return false
71
+ }
72
+ for (const [key, entry] of Object.entries(v.sessions)) {
73
+ const provider = key.split(':', 1)[0]
74
+ if (!PROVIDERS.has(provider) || key.length <= provider.length + 1 || !entry || typeof entry.updated_at !== 'string') return false
75
+ if (entry.folder_id !== undefined && (typeof entry.folder_id !== 'string' || !v.folders[entry.folder_id])) return false
76
+ if (entry.cos_pinned !== undefined && entry.cos_pinned !== true) return false
77
+ if (entry.title !== undefined && typeof entry.title !== 'string') return false
78
+ if (entry.desktop_id !== undefined && typeof entry.desktop_id !== 'string') return false
79
+ }
80
+ return true
81
+ }
82
+
83
+ export type OrganizerLog = (line: string) => void
84
+
85
+ export class SessionOrganizer {
86
+ private state: OrganizerFile = emptyFile()
87
+ private available = true
88
+
89
+ constructor(private readonly path: string = dataPath(ORGANIZER_FILE), private readonly log: OrganizerLog = line => console.warn(line),
90
+ private readonly now: () => Date = () => new Date()) {
91
+ this.load()
92
+ }
93
+
94
+ private load(): void {
95
+ if (!existsSync(this.path)) return
96
+ let raw: string
97
+ try {
98
+ raw = readFileSync(this.path, 'utf8')
99
+ } catch (error) {
100
+ // Exists but unreadable (permission, I/O): never write over it.
101
+ this.available = false
102
+ this.log(`[session-organizer] unavailable: the store exists but could not be read (${(error as NodeJS.ErrnoException)?.code ?? 'error'})`)
103
+ return
104
+ }
105
+ let parsed: unknown
106
+ try { parsed = JSON.parse(raw) } catch { parsed = undefined }
107
+ if (validShape(parsed)) { this.state = parsed; return }
108
+ const aside = `${this.path}.corrupt-${Date.now()}`
109
+ try {
110
+ renameSync(this.path, aside)
111
+ this.log('[session-organizer] the store did not parse; moved aside and started empty (reason=corrupt_store)')
112
+ } catch {
113
+ this.available = false
114
+ this.log('[session-organizer] unavailable: the store did not parse and could not be moved aside (reason=corrupt_store)')
115
+ }
116
+ }
117
+
118
+ isAvailable(): boolean { return this.available }
119
+ rev(): number { return this.state.rev }
120
+ isEmpty(): boolean { return Object.keys(this.state.folders).length === 0 && Object.keys(this.state.sessions).length === 0 }
121
+
122
+ private requireAvailable(): void {
123
+ if (!this.available) throw new OrganizerError(503, 'organizer_unavailable')
124
+ }
125
+
126
+ private commit(next: OrganizerFile): void {
127
+ next.rev = this.state.rev + 1
128
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
129
+ durableAtomicWriteFileSync(this.path, JSON.stringify(next), { mode: 0o600 })
130
+ this.state = next
131
+ }
132
+
133
+ private clone(): OrganizerFile { return structuredClone(this.state) }
134
+
135
+ private counts(): Map<string, number> {
136
+ const counts = new Map<string, number>()
137
+ for (const entry of Object.values(this.state.sessions)) {
138
+ if (entry.folder_id) counts.set(entry.folder_id, (counts.get(entry.folder_id) ?? 0) + 1)
139
+ }
140
+ return counts
141
+ }
142
+
143
+ listFolders(): Array<{ id: string; name: string; created_at: string; count: number }> {
144
+ const counts = this.counts()
145
+ return Object.entries(this.state.folders)
146
+ .map(([id, f]) => ({ id, name: f.name, created_at: f.created_at, count: counts.get(id) ?? 0 }))
147
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.id.localeCompare(b.id))
148
+ }
149
+
150
+ private nameTaken(name: string, except?: string): boolean {
151
+ const lower = name.toLocaleLowerCase()
152
+ return Object.entries(this.state.folders).some(([id, f]) => id !== except && f.name.toLocaleLowerCase() === lower)
153
+ }
154
+
155
+ createFolder(rawName: unknown): { id: string; name: string; created_at: string; count: number } {
156
+ this.requireAvailable()
157
+ const name = cleanOrganizerText(rawName, ORGANIZER_LIMITS.folderName)
158
+ if (!name) throw new OrganizerError(400, 'invalid_name')
159
+ if (Object.keys(this.state.folders).length >= ORGANIZER_LIMITS.folders) throw new OrganizerError(409, 'too_many_folders')
160
+ if (this.nameTaken(name)) throw new OrganizerError(409, 'folder_name_taken')
161
+ let id = ''
162
+ do { id = `f_${randomBytes(8).toString('hex')}` } while (this.state.folders[id])
163
+ const next = this.clone()
164
+ const created_at = this.now().toISOString()
165
+ next.folders[id] = { name, created_at }
166
+ this.commit(next)
167
+ return { id, name, created_at, count: 0 }
168
+ }
169
+
170
+ renameFolder(id: string, rawName: unknown): { id: string; name: string; created_at: string; count: number } {
171
+ this.requireAvailable()
172
+ const folder = this.state.folders[id]
173
+ if (!folder) throw new OrganizerError(404, 'folder_not_found')
174
+ const name = cleanOrganizerText(rawName, ORGANIZER_LIMITS.folderName)
175
+ if (!name) throw new OrganizerError(400, 'invalid_name')
176
+ if (this.nameTaken(name, id)) throw new OrganizerError(409, 'folder_name_taken')
177
+ const next = this.clone()
178
+ next.folders[id] = { ...folder, name }
179
+ this.commit(next)
180
+ return { id, name, created_at: folder.created_at, count: this.counts().get(id) ?? 0 }
181
+ }
182
+
183
+ /** Removes the folder. Its sessions leave it; no session is ever deleted. Returns how many left. */
184
+ deleteFolder(id: string): number {
185
+ this.requireAvailable()
186
+ if (!this.state.folders[id]) throw new OrganizerError(404, 'folder_not_found')
187
+ const next = this.clone()
188
+ delete next.folders[id]
189
+ let released = 0
190
+ const stamp = this.now().toISOString()
191
+ for (const [key, entry] of Object.entries(next.sessions)) {
192
+ if (entry.folder_id !== id) continue
193
+ released++
194
+ delete entry.folder_id
195
+ entry.updated_at = stamp
196
+ if (!entry.cos_pinned && entry.title === undefined) delete next.sessions[key]
197
+ }
198
+ this.commit(next)
199
+ return released
200
+ }
201
+
202
+ /**
203
+ * One session's change. `key` must already be canonical (organizerKey on the canonical id). Keys left out of
204
+ * `change` are untouched; `null` clears. An entry left with nothing in it is removed.
205
+ */
206
+ updateSession(key: string, change: { folder_id?: unknown; cos_pinned?: unknown; title?: unknown }, ids: { desktopId?: string } = {}): OrganizerRowFields {
207
+ this.requireAvailable()
208
+ const touchesFolder = Object.prototype.hasOwnProperty.call(change, 'folder_id')
209
+ const touchesPin = Object.prototype.hasOwnProperty.call(change, 'cos_pinned')
210
+ const touchesTitle = Object.prototype.hasOwnProperty.call(change, 'title')
211
+ if (!touchesFolder && !touchesPin && !touchesTitle) throw new OrganizerError(400, 'empty_change')
212
+ let folderId: string | null | undefined
213
+ if (touchesFolder) {
214
+ if (change.folder_id === null) folderId = null
215
+ else if (typeof change.folder_id === 'string' && this.state.folders[change.folder_id]) folderId = change.folder_id
216
+ else throw new OrganizerError(404, 'folder_not_found')
217
+ }
218
+ if (touchesPin && typeof change.cos_pinned !== 'boolean') throw new OrganizerError(400, 'invalid_pin')
219
+ let title: string | null | undefined
220
+ if (touchesTitle) {
221
+ if (change.title === null) title = null
222
+ else {
223
+ title = cleanOrganizerText(change.title, ORGANIZER_LIMITS.title)
224
+ if (!title) throw new OrganizerError(400, 'invalid_title')
225
+ }
226
+ }
227
+ const desktopId = ids.desktopId?.trim().toLowerCase() || undefined
228
+ // Q1 FIX 2: an entry written under the same Desktop id but another key (its CLI alias appeared or changed since)
229
+ // moves to this key, so one session never has two entries.
230
+ const movedFrom = !this.state.sessions[key] && desktopId
231
+ ? Object.keys(this.state.sessions).find(k => k.startsWith('claude:') && this.state.sessions[k].desktop_id === desktopId)
232
+ : undefined
233
+ const existing = this.state.sessions[key] ?? (movedFrom ? this.state.sessions[movedFrom] : undefined)
234
+ if (!existing && Object.keys(this.state.sessions).length >= ORGANIZER_LIMITS.entries) throw new OrganizerError(409, 'too_many_entries')
235
+ const next = this.clone()
236
+ const entry: OrganizerEntry = { ...(existing ? structuredClone(existing) : {}), updated_at: this.now().toISOString() }
237
+ if (movedFrom) delete next.sessions[movedFrom]
238
+ if (desktopId) entry.desktop_id = desktopId
239
+ if (folderId === null) delete entry.folder_id; else if (folderId) entry.folder_id = folderId
240
+ if (touchesPin) { if (change.cos_pinned === true) entry.cos_pinned = true; else delete entry.cos_pinned }
241
+ if (title === null) delete entry.title; else if (title) entry.title = title
242
+ if (!entry.folder_id && !entry.cos_pinned && entry.title === undefined) delete next.sessions[key]
243
+ else next.sessions[key] = entry
244
+ this.commit(next)
245
+ return this.fieldsFor(key)
246
+ }
247
+
248
+ /** The fields for the first of these keys that has an entry, or for an entry written under one of `desktopIds`. */
249
+ fieldsForAny(keys: readonly string[], desktopIds: readonly string[] = []): OrganizerRowFields {
250
+ for (const key of keys) if (this.state.sessions[key]) return this.fieldsFor(key)
251
+ if (desktopIds.length) {
252
+ const wanted = new Set(desktopIds.map(id => id.toLowerCase()))
253
+ for (const [key, entry] of Object.entries(this.state.sessions)) {
254
+ if (entry.desktop_id && key.startsWith('claude:') && wanted.has(entry.desktop_id)) return this.fieldsFor(key)
255
+ }
256
+ }
257
+ return {}
258
+ }
259
+
260
+ /** The fields a row for this canonical key carries (empty when none). */
261
+ fieldsFor(key: string): OrganizerRowFields {
262
+ const entry = this.state.sessions[key]
263
+ if (!entry) return {}
264
+ return {
265
+ ...(entry.folder_id ? { folder_id: entry.folder_id } : {}),
266
+ ...(entry.cos_pinned ? { cos_pinned: true as const } : {}),
267
+ ...(entry.title !== undefined ? { title_override: entry.title } : {}),
268
+ }
269
+ }
270
+
271
+ /** Canonical ids per provider that the list keeps at any age and outside its caps: COS pins and folder members. */
272
+ keptIds(): Record<OrganizerProvider, Set<string>> {
273
+ const kept: Record<OrganizerProvider, Set<string>> = { claude: new Set(), codex: new Set(), cursor: new Set() }
274
+ for (const [key, entry] of Object.entries(this.state.sessions)) {
275
+ if (!entry.cos_pinned && !entry.folder_id) continue
276
+ const at = key.indexOf(':')
277
+ kept[key.slice(0, at) as OrganizerProvider].add(key.slice(at + 1))
278
+ // The list maps a Desktop id to its current CLI id, so an entry follows its session when the alias changes.
279
+ if (entry.desktop_id) kept.claude.add(entry.desktop_id)
280
+ }
281
+ return kept
282
+ }
283
+
284
+ /** Keys whose renamed title contains `query` (case-insensitive), for search. */
285
+ titleMatches(query: string): Array<{ provider: OrganizerProvider; session_id: string }> {
286
+ const needle = query.trim().toLocaleLowerCase()
287
+ if (!needle) return []
288
+ const out: Array<{ provider: OrganizerProvider; session_id: string }> = []
289
+ for (const [key, entry] of Object.entries(this.state.sessions)) {
290
+ if (entry.title === undefined || !entry.title.toLocaleLowerCase().includes(needle)) continue
291
+ const at = key.indexOf(':')
292
+ out.push({ provider: key.slice(0, at) as OrganizerProvider, session_id: key.slice(at + 1) })
293
+ }
294
+ return out
295
+ }
296
+ }
297
+
298
+ let shared: SessionOrganizer | null = null
299
+ /** The server's one organizer, loaded on first use. */
300
+ export function sessionOrganizer(): SessionOrganizer {
301
+ if (!shared) shared = new SessionOrganizer()
302
+ return shared
303
+ }
304
+ /** Tests only. */
305
+ export function __resetSessionOrganizerForTests(next: SessionOrganizer | null = null): void { shared = next }
@@ -23,6 +23,7 @@
23
23
  // `running|recent` and Control renders anything else as "Running" (validation round 1).
24
24
 
25
25
  import type { SessionSignal, WaitingKind } from './session-signal-store.js'
26
+ import { TURN_ASK_BUSY_GRACE_MS, type VisibleTurnAsk } from './turn-end-ask.js'
26
27
 
27
28
  export type AgentState = 'running' | 'waiting' | 'idle' | 'failed' | 'ended'
28
29
  export type StateSource = 'hook' | 'registry' | 'transcript'
@@ -40,6 +41,10 @@ export interface DerivedSessionState {
40
41
  pending_permission_id?: string
41
42
  /** 6.52.0: the permission broker holds this AskUserQuestion; `GET /api/session-questions` has it. */
42
43
  pending_question_id?: string
44
+ /** 7.0.18 (X1): 'turn_end' when the wait is a turn that ended by asking (lib/turn-end-ask.ts). */
45
+ waiting_source?: 'turn_end'
46
+ /** 7.0.18 (X1): with `waiting_source`, the reply-button labels (0 to 2; [] with the words switch off). */
47
+ waiting_choices?: string[]
43
48
  /** Consecutive derives that saw a dead registry pid; carried by the caller between polls. */
44
49
  deadScans: number
45
50
  /** When the pid was first seen dead, so two observations must also be DEAD_GRACE_MS apart. */
@@ -79,6 +84,12 @@ export interface DeriveInput {
79
84
  prevDeadScans?: number
80
85
  /** The previous derive's `deadSince`. */
81
86
  prevDeadSince?: number | null
87
+ /**
88
+ * 7.0.18 (X1): the turn-end ask the tracker shows now (stopped, desk latch passed, not aged out), or
89
+ * undefined. It only ever turns an IDLE verdict into waiting; it never overrides running, a real wait,
90
+ * failed or ended. Undefined whenever COS_TURN_END_ASKS is off, so every row is 7.0.17's.
91
+ */
92
+ turnEndAsk?: VisibleTurnAsk | null
82
93
  }
83
94
 
84
95
  /** Harness `MISS_LIMIT`: a pid must be dead on two consecutive scans before a row ends. */
@@ -101,6 +112,27 @@ export const OPEN_TURN_CEILING_MS = 30 * 60_000
101
112
  const iso = (ms: number) => new Date(ms).toISOString()
102
113
 
103
114
  export function deriveSessionState(input: DeriveInput): DerivedSessionState {
115
+ const base = deriveBaseState(input)
116
+ const ask = input.turnEndAsk
117
+ if (!ask || base.agent_state !== 'idle') return base
118
+ const { signal, registry } = input
119
+ // A print run never shows one (the broker's own rule; the tracker never arms one either).
120
+ if (signal?.entrypoint === 'sdk-cli') return base
121
+ // The registry running again after the stop is a new turn, even before its hooks arrive.
122
+ if (registry?.alive && registry.status === 'busy' && registry.statusUpdatedAt !== null && registry.statusUpdatedAt > ask.stoppedAt + TURN_ASK_BUSY_GRACE_MS) return base
123
+ return {
124
+ ...base,
125
+ agent_state: 'waiting',
126
+ state_source: 'hook',
127
+ state_since: iso(ask.stoppedAt),
128
+ waiting_kind: 'question',
129
+ waiting_detail: ask.ask,
130
+ waiting_source: 'turn_end',
131
+ waiting_choices: [...ask.choices],
132
+ }
133
+ }
134
+
135
+ function deriveBaseState(input: DeriveInput): DerivedSessionState {
104
136
  const { signal, registry, transcript, now } = input
105
137
  const prevDead = input.prevDeadScans ?? 0
106
138
  const dead = !!registry && !registry.alive
@@ -228,6 +260,8 @@ export function derivedStatusFields(derived: DerivedSessionState): {
228
260
  state_since: string
229
261
  waiting_kind?: WaitingKind
230
262
  waiting_detail?: string
263
+ waiting_source?: 'turn_end'
264
+ waiting_choices?: string[]
231
265
  failure?: string
232
266
  last_reply?: string
233
267
  } {
@@ -237,6 +271,7 @@ export function derivedStatusFields(derived: DerivedSessionState): {
237
271
  state_since: derived.state_since,
238
272
  ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
239
273
  ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
274
+ ...(derived.waiting_source ? { waiting_source: derived.waiting_source, waiting_choices: [...(derived.waiting_choices ?? [])] } : {}),
240
275
  ...(derived.failure ? { failure: derived.failure } : {}),
241
276
  ...(derived.agent_state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
242
277
  }
@@ -251,6 +286,7 @@ export function derivedRowFields(derived: DerivedSessionState | undefined): Reco
251
286
  state_since: derived.state_since,
252
287
  ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
253
288
  ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
289
+ ...(derived.waiting_source ? { waiting_source: derived.waiting_source, waiting_choices: [...(derived.waiting_choices ?? [])] } : {}),
254
290
  ...(derived.failure ? { failure: derived.failure } : {}),
255
291
  ...(derived.last_reply ? { last_reply: derived.last_reply } : {}),
256
292
  ...(derived.pending_permission_id ? { pending_permission_id: derived.pending_permission_id } : {}),
@@ -307,9 +307,27 @@ export function detailForTool(name: unknown, input: unknown): string {
307
307
  const lines = countLines(args.content)
308
308
  return lines === 0 ? '' : oneLine(`${lines} lines`, DETAIL_MAX_CHARS)
309
309
  }
310
+ if (lower === 'sendmessage') return sendMessageDetail(args.to)
310
311
  return ''
311
312
  }
312
313
 
314
+ /**
315
+ * 7.0.9 (Miles 11/08, "Why aren't you responding to the other session?"): who a SendMessage went to, so a COS answer
316
+ * that relayed a message can say where its reply will show. Claude Code's `to` is a peer's name, `bridge:<session id>`
317
+ * or `uds:<socket>`. A socket is a file on the Mac and is never shown; a session id shows its first 8 characters; a
318
+ * name with a slash in it is treated as a path. Nothing else of the message (its words) is read.
319
+ */
320
+ export function sendMessageDetail(to: unknown): string {
321
+ const raw = typeof to === 'string' ? oneLine(to, 200) : ''
322
+ if (!raw) return ''
323
+ if (raw === '*') return 'to every session'
324
+ if (/^uds:/i.test(raw)) return 'to another session'
325
+ const bridge = /^bridge:\s*(.+)$/i.exec(raw)
326
+ const who = bridge ? `session ${bridge[1].trim().slice(0, 8)}` : raw
327
+ if (/[\\/~]/.test(who)) return 'to another session'
328
+ return oneLine(`to ${who}`, DETAIL_MAX_CHARS)
329
+ }
330
+
313
331
  /**
314
332
  * The outcome token for one tool result, from the transcript row that carries it.
315
333
  *
@@ -1336,6 +1354,16 @@ export function draftsFromLine(provider: SessionStreamProvider, line: string): S
1336
1354
  *
1337
1355
  * Skipped: `isMeta` and `isCompactSummary` rows (injected context, not a prompt), tool
1338
1356
  * results, and the bookkeeping rows (`system`, `last-prompt`, `attachment`, `mode`...).
1357
+ *
1358
+ * ONLY `type: 'user'` AND `type: 'assistant'` ROWS ARE THE CONVERSATION (2026-11-11). Every
1359
+ * other row type is skipped, whatever its `message.role`. Claude Code 2.1.300 (first seen
1360
+ * 2026-10-14) writes `api-request-blob` rows: no timestamp, `message.role` user or assistant,
1361
+ * copies of earlier blocks, written AFTER the tool results they repeat. Read as a reply, a
1362
+ * copied `tool_use` re-opened a tool that had already answered, the tail read `tool_pending`,
1363
+ * and the queue held a turn parked for an idle Desktop session until 30 minutes of quiet
1364
+ * (S1, lanes/S1_queue_hold_diagnosis.md: 15 of 56 ended transcripts read open). An UNKNOWN
1365
+ * row type therefore never opens a turn: it fails toward "ended", and the hooks, the registry
1366
+ * and the gate still decide whether anything is sent.
1339
1367
  * Codex keeps its event rule (task_complete / turn_complete end, task_started opens).
1340
1368
  *
1341
1369
  * PURE, like everything else here. The queue store reads the tail; this decides.
@@ -1382,7 +1410,9 @@ export function turnFromTail(provider: SessionStreamProvider, lines: readonly st
1382
1410
  const content = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
1383
1411
  const blocks = content.map(asRecord).filter((b): b is Record<string, unknown> => b !== null)
1384
1412
 
1385
- if (type === 'user' || message.role === 'user') {
1413
+ // The conversation is `user` and `assistant` rows only, by row TYPE, never by `message.role` (see the header:
1414
+ // an api-request-blob carries a role, and any row type this build does not know falls through both branches).
1415
+ if (type === 'user') {
1386
1416
  const results = blocks.filter(b => b.type === 'tool_result')
1387
1417
  if (results.length > 0) {
1388
1418
  for (const b of results) if (typeof b.tool_use_id === 'string') pendingToolUses.delete(b.tool_use_id)
@@ -1395,7 +1425,7 @@ export function turnFromTail(provider: SessionStreamProvider, lines: readonly st
1395
1425
  continue
1396
1426
  }
1397
1427
 
1398
- if (type === 'assistant' || message.role === 'assistant') {
1428
+ if (type === 'assistant') {
1399
1429
  for (const b of blocks) if (b.type === 'tool_use' && typeof b.id === 'string') pendingToolUses.add(b.id)
1400
1430
  const stop = typeof message.stop_reason === 'string' ? message.stop_reason : ''
1401
1431
  if (TERMINAL_STOP_REASONS.has(stop)) {
@@ -1435,6 +1465,8 @@ export interface DerivedStatusFields {
1435
1465
  state_since: string
1436
1466
  waiting_kind?: string
1437
1467
  waiting_detail?: string
1468
+ waiting_source?: 'turn_end'
1469
+ waiting_choices?: string[]
1438
1470
  failure?: string
1439
1471
  last_reply?: string
1440
1472
  }
@@ -1446,8 +1478,11 @@ export function statusDraftWithDerived(
1446
1478
  derived: DerivedStatusFields | undefined,
1447
1479
  ): StatusDraftWithDerived {
1448
1480
  if (!derived) return draft
1481
+ // 7.0.18 (X1): a turn that ended by asking is finished work, so feeds and pet counts read it idle;
1482
+ // its row fields still say what it asked.
1449
1483
  const state: SessionStreamState =
1450
- derived.agent_state === 'running' || derived.agent_state === 'waiting' ? 'working'
1484
+ derived.agent_state === 'waiting' && derived.waiting_source === 'turn_end' ? 'idle'
1485
+ : derived.agent_state === 'running' || derived.agent_state === 'waiting' ? 'working'
1451
1486
  : derived.agent_state === 'ended' ? 'done'
1452
1487
  : 'idle'
1453
1488
  return {
@@ -1460,6 +1495,7 @@ export function statusDraftWithDerived(
1460
1495
  state_since: derived.state_since,
1461
1496
  ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
1462
1497
  ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
1498
+ ...(derived.waiting_source ? { waiting_source: derived.waiting_source, waiting_choices: [...(derived.waiting_choices ?? [])] } : {}),
1463
1499
  ...(derived.failure ? { failure: derived.failure } : {}),
1464
1500
  ...(state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
1465
1501
  }
@@ -122,6 +122,14 @@ export interface QueuedThreadTurn {
122
122
  * the words alone, so the pending row and its preview never show a path. Absent on a turn without files.
123
123
  */
124
124
  attachmentIds?: string[]
125
+ /**
126
+ * 2026-11-11 (S1, Miles's note held 08:01): WHY a `waiting` row is held, as a short code the drain decided, and
127
+ * since when that code has held (epoch ms). Written only when the code CHANGES, never per sweep, so a held row
128
+ * costs no extra writes. Additive: an older server or client ignores both. Codes only, never text
129
+ * (`holdReasonFor`, `HOLD_REASON_CODE`).
130
+ */
131
+ holdReason?: string
132
+ holdSince?: number
125
133
  }
126
134
 
127
135
  // ------------------------------------------------------------------- 6.78.0 flags
@@ -376,6 +384,22 @@ export function drainDecision(
376
384
  return seen.activity === 'idle' ? 'deliver' : 'hold'
377
385
  }
378
386
 
387
+ /** A hold reason is a short code: lowercase letters, digits and underscores. Anything else reads `gate_closed`. */
388
+ export const HOLD_REASON_CODE = /^[a-z0-9_]{1,48}$/
389
+
390
+ /**
391
+ * Why `drainDecision` held this row, as a code (2026-11-11). Same order as the decision, so the code names the
392
+ * branch that held: a cancel hold, then the gate (its own reason), then an open turn, then a turn not yet ended.
393
+ * Only meaningful for a row the decision held; `files_wait_for_turn_end` is set by the drain itself, when the
394
+ * row's files are the only thing that kept it from going into the running turn.
395
+ */
396
+ export function holdReasonFor(seen: DrainObservation, now: number): string {
397
+ if (typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && now < seen.cancelHoldUntil) return 'cancel_hold'
398
+ if (!seen.attachable) return typeof seen.reason === 'string' && HOLD_REASON_CODE.test(seen.reason) ? seen.reason : 'gate_closed'
399
+ if (seen.turnOpen === true) return 'turn_open'
400
+ return 'turn_not_ended'
401
+ }
402
+
379
403
  /**
380
404
  * Admit a turn to the queue, or say why not.
381
405
  *