@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -2,9 +2,12 @@
2
2
  // so COS can query Glasses sessions by original UUID, date, domain, or content.
3
3
  // Fires on: TTL expiry, explicit /api/sessions/:id/end, server shutdown.
4
4
 
5
- import { appendFileSync, chmodSync, mkdirSync } from 'node:fs'
5
+ import { appendFileSync, chmodSync, closeSync, constants as fsConstants, createReadStream, fstatSync, mkdirSync, openSync, statSync } from 'node:fs'
6
+ import { createHash } from 'node:crypto'
6
7
  import { resolve, dirname } from 'node:path'
7
8
  import type { Exchange } from './conversation.js'
9
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
10
+ import { dataPath } from './data-dir.js'
8
11
 
9
12
  // Resolve COS_SCRIPTS_DIR independently (same pattern as session-cache-writer)
10
13
  let SCRIPTS_DIR: string | null = null
@@ -132,7 +135,13 @@ export function buildSessionLogEntry(input: EndSessionInput): SessionLogEntry {
132
135
  }
133
136
  }
134
137
 
135
- /** Append a session log entry to .glasses_sessions.jsonl */
138
+ /** Where the ledger lives, or null when COS_SCRIPTS_DIR is not set. */
139
+ export function sessionLogPath(): string | null {
140
+ return SCRIPTS_DIR ? resolve(SCRIPTS_DIR, LOG_FILE_NAME) : null
141
+ }
142
+
143
+ /** Append a session log entry to .glasses_sessions.jsonl. Unconditional: callers that may
144
+ * repeat an unchanged session go through writeSessionLogIfChanged instead. */
136
145
  export function writeSessionLog(entry: SessionLogEntry): boolean {
137
146
  if (!SCRIPTS_DIR) {
138
147
  console.warn('[session-log] COS_SCRIPTS_DIR not set — skipping session log')
@@ -153,11 +162,311 @@ export function writeSessionLog(entry: SessionLogEntry): boolean {
153
162
  }
154
163
  }
155
164
 
156
- /** End a session: build entry and write to JSONL */
165
+ // ── Write-once-per-content ledger state (S1, 2026-11-09) ──────────────────────────────
166
+ // Before this, every server shutdown appended EVERY session with exchanges (about 300 on
167
+ // Miles's Mac, since sessions.json keeps all ages), and the first auto-snapshot after
168
+ // each boot appended them all again, because its "unchanged" check lived only in memory.
169
+ // 72 boots in 14 days grew the ledger to 1.94 GB: 118,221 entries for 588 sessions, one
170
+ // session copied 1,254 times. The state below survives restarts, so an unchanged session
171
+ // is never appended twice. It is keyed by the ledger path, so pointing COS_SCRIPTS_DIR
172
+ // elsewhere starts a fresh state instead of suppressing writes to the new file.
173
+
174
+ /** Why an entry is being written. A snapshot or a shutdown is a copy of a live session;
175
+ * an end (TTL, explicit end, clear) closes it. */
176
+ export type SessionLogKind = 'snapshot' | 'shutdown' | 'end'
177
+
178
+ interface LedgerStateRow { sig: string; kind: SessionLogKind; at: number }
179
+ /** `ino`: the ledger file the rows describe. A different or missing file (compaction, a restore, a
180
+ * deletion) means the rows no longer describe what is on disk, so they are dropped (Q1 review S5). */
181
+ interface LedgerState { version: 1; logPath: string; ino?: number; sessions: Record<string, LedgerStateRow> }
182
+
183
+ function ledgerInode(logPath: string): number | null {
184
+ try { return statSync(logPath).ino } catch { return null }
185
+ }
186
+
187
+ /** Most sessions the state remembers; the oldest by last write are dropped past this. */
188
+ export const SESSION_LOG_STATE_CAP = 5_000
189
+ const STATE_FILE_NAME = 'session-log-state.json'
190
+
191
+ let ledgerState: LedgerState | null = null
192
+ let ledgerStateDirty = false
193
+ let ledgerStateFlushTimer: ReturnType<typeof setTimeout> | null = null
194
+
195
+ function stateFilePath(): string {
196
+ return dataPath(STATE_FILE_NAME)
197
+ }
198
+
199
+ function loadLedgerState(logPath: string): LedgerState {
200
+ if (ledgerState && ledgerState.logPath === logPath) return ledgerState
201
+ const fresh: LedgerState = { version: 1, logPath, sessions: {} }
202
+ try {
203
+ const result = loadJsonOrQuarantine<LedgerState>(stateFilePath())
204
+ const loaded = result.status === 'ok' ? result.data : null
205
+ if (loaded && loaded.version === 1 && loaded.logPath === logPath
206
+ && loaded.sessions && typeof loaded.sessions === 'object') {
207
+ ledgerState = loaded
208
+ return loaded
209
+ }
210
+ } catch { /* unreadable state: start fresh; the worst case is one more copy per session */ }
211
+ ledgerState = fresh
212
+ return fresh
213
+ }
214
+
215
+ /** What makes two entries for one session the same: its messages, counts and model.
216
+ * Timestamps the writer invents (a snapshot's ended_at is "now") are not part of it. */
217
+ export function sessionLogSignature(entry: SessionLogEntry): string {
218
+ return createHash('sha256').update(JSON.stringify([
219
+ entry.total_message_count,
220
+ entry.user_message_count,
221
+ entry.assistant_message_count,
222
+ entry.model_preference,
223
+ entry.created_at,
224
+ entry.messages,
225
+ ])).digest('hex').slice(0, 32)
226
+ }
227
+
228
+ /** Persist the state now (atomic). Called at the end of a shutdown or snapshot pass. */
229
+ export function flushSessionLogState(): void {
230
+ if (ledgerStateFlushTimer) { clearTimeout(ledgerStateFlushTimer); ledgerStateFlushTimer = null }
231
+ if (!ledgerState || !ledgerStateDirty) return
232
+ const rows = Object.entries(ledgerState.sessions)
233
+ if (rows.length > SESSION_LOG_STATE_CAP) {
234
+ rows.sort((a, b) => b[1].at - a[1].at)
235
+ ledgerState.sessions = Object.fromEntries(rows.slice(0, SESSION_LOG_STATE_CAP))
236
+ }
237
+ try {
238
+ mkdirSync(dirname(stateFilePath()), { recursive: true, mode: 0o700 })
239
+ atomicWriteFileSync(stateFilePath(), JSON.stringify(ledgerState), { mode: 0o600 })
240
+ ledgerStateDirty = false
241
+ } catch (err) {
242
+ console.error('[session-log] Failed to save ledger state:', err)
243
+ }
244
+ }
245
+
246
+ function scheduleStateFlush(): void {
247
+ if (ledgerStateFlushTimer) return
248
+ ledgerStateFlushTimer = setTimeout(() => { ledgerStateFlushTimer = null; flushSessionLogState() }, 1_000)
249
+ ledgerStateFlushTimer.unref?.()
250
+ }
251
+
252
+ /** 'unconfigured': COS_SCRIPTS_DIR is not set, so there is no ledger. That is the default for
253
+ * every new install (COS Control's generated .env does not set it), not a failure. */
254
+ export type SessionLogWriteResult = 'written' | 'unchanged' | 'unconfigured' | 'failed'
255
+
256
+ let unconfiguredNoted = false
257
+ /** Say once per process that there is no ledger (Q1 review M2: the auto-snapshot printed it per
258
+ * session every 5 minutes on a new install). */
259
+ function noteUnconfigured(): void {
260
+ if (unconfiguredNoted) return
261
+ unconfiguredNoted = true
262
+ console.log('[session-log] COS_SCRIPTS_DIR not set: no .glasses_sessions.jsonl ledger on this install (said once)')
263
+ }
264
+
265
+ /** Append the entry unless this session's content was already written.
266
+ * - snapshot / shutdown: skipped when the last write for the session (any kind) had the same content.
267
+ * - end: skipped only when the last write was itself an end with the same content, so a real end
268
+ * always lands once even after a snapshot of the same content (its slug and end_reason differ). */
269
+ export function writeSessionLogIfChanged(entry: SessionLogEntry, kind: SessionLogKind): SessionLogWriteResult {
270
+ const logPath = sessionLogPath()
271
+ if (!logPath) { noteUnconfigured(); return 'unconfigured' }
272
+ const state = loadLedgerState(logPath)
273
+ const ino = ledgerInode(logPath)
274
+ if (state.ino !== undefined && state.ino !== ino) {
275
+ state.sessions = {}
276
+ state.ino = undefined
277
+ ledgerStateDirty = true
278
+ }
279
+ const sig = sessionLogSignature(entry)
280
+ const prev = state.sessions[entry.session_id]
281
+ if (prev && prev.sig === sig && (kind !== 'end' || prev.kind === 'end')) return 'unchanged'
282
+ if (!writeSessionLog(entry)) return 'failed'
283
+ state.ino = ledgerInode(logPath) ?? undefined
284
+ state.sessions[entry.session_id] = { sig, kind, at: Date.now() }
285
+ ledgerStateDirty = true
286
+ scheduleStateFlush()
287
+ return 'written'
288
+ }
289
+
290
+ function kindForReason(reason: EndSessionInput['endReason']): SessionLogKind {
291
+ return reason === 'server_shutdown' ? 'shutdown' : 'end'
292
+ }
293
+
294
+ /** End a session: build entry and write to JSONL (once per content; see writeSessionLogIfChanged). */
157
295
  export function logSessionEnd(input: EndSessionInput): SessionLogEntry | null {
158
296
  if (input.exchanges.length === 0) return null
159
297
 
160
298
  const entry = buildSessionLogEntry(input)
161
- writeSessionLog(entry)
299
+ writeSessionLogIfChanged(entry, kindForReason(input.endReason))
162
300
  return entry
163
301
  }
302
+
303
+ // ── Reader (S1, 2026-11-09) ───────────────────────────────────────────────────────────
304
+ // /api/sessions/lookup read the whole ledger with readFileSync(...).toString(). At 1.94 GB
305
+ // that throws (the string is longer than V8 allows), the catch treated it as "no log
306
+ // file", and every lookup silently answered from live sessions only, after blocking the
307
+ // event loop for the read. This reader streams and reads only the bytes appended since the
308
+ // last call. It keeps, per session, what the old loop kept: the FIRST line's range, plus the
309
+ // latest end any line reported. Which of the two a lookup uses is the route's old rule
310
+ // (lookupRangesFromHistory below), unchanged.
311
+
312
+ export interface SessionLogRange {
313
+ /** created_at of the session's first line in the file. Never widened (the old loop never moved it). */
314
+ start: number
315
+ /** ended_at of that first line. */
316
+ firstEnd: number
317
+ /** The latest ended_at of any line for the session. */
318
+ end: number
319
+ }
320
+
321
+ interface RangeIndex { logPath: string; ino: number; offset: number; ranges: Map<string, SessionLogRange> }
322
+ let rangeIndex: RangeIndex | null = null
323
+ let rangeIndexBuild: Promise<Map<string, SessionLogRange>> | null = null
324
+
325
+ const ENTRY_PREFIX = /^\{"session_id":"((?:[^"\\]|\\.)*)","device_id":"(?:[^"\\]|\\.)*","created_at":"([^"]*)","ended_at":"([^"]*)"/
326
+
327
+ /** The identity and time range of one ledger line, or null for a line that is not an entry. */
328
+ export function parseSessionLogLine(line: string): { sessionId: string; start: number; end: number } | null {
329
+ let sessionId: string | undefined
330
+ let createdAt: string | undefined
331
+ let endedAt: string | undefined
332
+ const prefix = ENTRY_PREFIX.exec(line.slice(0, 512))
333
+ if (prefix) {
334
+ try { sessionId = JSON.parse(`"${prefix[1]}"`) } catch { sessionId = undefined }
335
+ createdAt = prefix[2]
336
+ endedAt = prefix[3]
337
+ }
338
+ if (sessionId === undefined) {
339
+ try {
340
+ const d = JSON.parse(line)
341
+ if (d && typeof d.session_id === 'string') {
342
+ sessionId = d.session_id
343
+ createdAt = typeof d.created_at === 'string' ? d.created_at : undefined
344
+ endedAt = typeof d.ended_at === 'string' ? d.ended_at : undefined
345
+ }
346
+ } catch { return null }
347
+ }
348
+ if (!sessionId || !createdAt) return null
349
+ const start = new Date(createdAt).getTime()
350
+ if (!Number.isFinite(start)) return null
351
+ const endParsed = endedAt ? new Date(endedAt).getTime() : NaN
352
+ const end = Number.isFinite(endParsed) ? endParsed : start + 7_200_000
353
+ return { sessionId, start, end }
354
+ }
355
+
356
+ function mergeRange(ranges: Map<string, SessionLogRange>, id: string, start: number, end: number): void {
357
+ const existing = ranges.get(id)
358
+ if (!existing) { ranges.set(id, { start, firstEnd: end, end }); return }
359
+ if (end > existing.end) existing.end = end
360
+ }
361
+
362
+ /**
363
+ * The ranges /api/sessions/lookup matches against, in first-seen order: the route's rule from
364
+ * before S1, unchanged (Q1 review M1, 2026-11-09). A session's range starts at its first line.
365
+ * Its end may be widened by later lines ONLY when the session is not in memory. A session still
366
+ * in memory (restored from sessions.json, however old) keeps its first line's end, because every
367
+ * boot snapshot of it is stamped ended_at = now: widening would let an October session claim
368
+ * November timestamps, and the glasses companion stores the answer for good.
369
+ * Sessions in `live` are skipped (the route lists them first, extended to now).
370
+ */
371
+ export function lookupRangesFromHistory(
372
+ history: ReadonlyMap<string, SessionLogRange>,
373
+ inMemory: (sessionId: string) => boolean,
374
+ live: ReadonlySet<string> = new Set(),
375
+ ): Array<{ sid: string; start: number; end: number }> {
376
+ const out: Array<{ sid: string; start: number; end: number }> = []
377
+ for (const [sid, range] of history) {
378
+ if (live.has(sid)) continue
379
+ out.push({ sid, start: range.start, end: inMemory(sid) ? range.firstEnd : Math.max(range.firstEnd, range.end) })
380
+ }
381
+ return out
382
+ }
383
+
384
+ /** Index lines in [from, to) of the file into `ranges`. Returns the offset just past the
385
+ * last complete line, so a half-written last line is read again next time. */
386
+ async function indexBytes(fd: number, from: number, to: number, ranges: Map<string, SessionLogRange>): Promise<number> {
387
+ if (to <= from) return from
388
+ let offset = from
389
+ let carry: Buffer[] = []
390
+ let carryLen = 0
391
+ // Reads the descriptor readSessionLogRanges opened and checked (a regular file), never the path again.
392
+ const stream = createReadStream('', { fd, autoClose: false, start: from, end: to - 1, highWaterMark: 1 << 20 })
393
+ for await (const raw of stream) {
394
+ let chunk = raw as Buffer
395
+ let nl = chunk.indexOf(10)
396
+ while (nl !== -1) {
397
+ const piece = chunk.subarray(0, nl)
398
+ // Only the first 512 bytes are needed for the fast path; keep the whole line only
399
+ // while it is short or when the fast path fails (legacy key order).
400
+ const lineBuf = carryLen ? Buffer.concat([...carry, piece]) : piece
401
+ const lineLen = carryLen + piece.length
402
+ carry = []; carryLen = 0
403
+ const head = lineBuf.subarray(0, Math.min(lineBuf.length, 512)).toString('utf8')
404
+ let parsed = ENTRY_PREFIX.test(head) ? parseSessionLogLine(head) : null
405
+ if (!parsed && lineBuf.length <= 64 * 1024 * 1024) parsed = parseSessionLogLine(lineBuf.toString('utf8'))
406
+ if (parsed) mergeRange(ranges, parsed.sessionId, parsed.start, parsed.end)
407
+ offset += lineLen + 1
408
+ chunk = chunk.subarray(nl + 1)
409
+ nl = chunk.indexOf(10)
410
+ }
411
+ if (chunk.length) { carry.push(Buffer.from(chunk)); carryLen += chunk.length }
412
+ }
413
+ return offset
414
+ }
415
+
416
+ /** One time range per session from the ledger, read incrementally. A shrunk or replaced
417
+ * file (compaction) is re-read from the start. A missing file is an empty map. */
418
+ export async function readSessionLogRanges(path: string | null = sessionLogPath()): Promise<Map<string, SessionLogRange>> {
419
+ if (!path) return new Map()
420
+ if (rangeIndexBuild) return rangeIndexBuild
421
+ rangeIndexBuild = (async () => {
422
+ // O_NONBLOCK (hazard-invariants ratchet): openSync on a FIFO with no writer never returns, and it runs on
423
+ // the server's only JS thread. Not O_NOFOLLOW: the append writer follows a symlinked ledger, and the reader
424
+ // must read what the writer writes. fstat follows the link, so a link to a FIFO or folder is refused below.
425
+ const nonBlock = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0
426
+ let fd: number
427
+ try {
428
+ fd = openSync(path, fsConstants.O_RDONLY | nonBlock)
429
+ } catch {
430
+ rangeIndex = null
431
+ return new Map<string, SessionLogRange>()
432
+ }
433
+ try {
434
+ const st = fstatSync(fd)
435
+ if (!st.isFile()) {
436
+ rangeIndex = null
437
+ return new Map<string, SessionLogRange>()
438
+ }
439
+ if (!rangeIndex || rangeIndex.logPath !== path || rangeIndex.ino !== st.ino || rangeIndex.offset > st.size) {
440
+ rangeIndex = { logPath: path, ino: st.ino, offset: 0, ranges: new Map() }
441
+ }
442
+ const index = rangeIndex
443
+ index.offset = await indexBytes(fd, index.offset, st.size, index.ranges)
444
+ return index.ranges
445
+ } finally {
446
+ closeSync(fd)
447
+ }
448
+ })()
449
+ try {
450
+ return await rangeIndexBuild
451
+ } finally {
452
+ rangeIndexBuild = null
453
+ }
454
+ }
455
+
456
+ /** Size of the ledger in bytes, or null when it does not exist (diagnostics). */
457
+ export function sessionLogSize(): number | null {
458
+ const path = sessionLogPath()
459
+ if (!path) return null
460
+ try { return statSync(path).size } catch { return null }
461
+ }
462
+
463
+ /** Tests only: forget the in-memory state and reader index. */
464
+ export function __resetSessionLogForTests(): void {
465
+ if (ledgerStateFlushTimer) clearTimeout(ledgerStateFlushTimer)
466
+ ledgerStateFlushTimer = null
467
+ ledgerState = null
468
+ ledgerStateDirty = false
469
+ unconfiguredNoted = false
470
+ rangeIndex = null
471
+ rangeIndexBuild = null
472
+ }
@@ -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
  *
@@ -1435,6 +1453,8 @@ export interface DerivedStatusFields {
1435
1453
  state_since: string
1436
1454
  waiting_kind?: string
1437
1455
  waiting_detail?: string
1456
+ waiting_source?: 'turn_end'
1457
+ waiting_choices?: string[]
1438
1458
  failure?: string
1439
1459
  last_reply?: string
1440
1460
  }
@@ -1446,8 +1466,11 @@ export function statusDraftWithDerived(
1446
1466
  derived: DerivedStatusFields | undefined,
1447
1467
  ): StatusDraftWithDerived {
1448
1468
  if (!derived) return draft
1469
+ // 7.0.18 (X1): a turn that ended by asking is finished work, so feeds and pet counts read it idle;
1470
+ // its row fields still say what it asked.
1449
1471
  const state: SessionStreamState =
1450
- derived.agent_state === 'running' || derived.agent_state === 'waiting' ? 'working'
1472
+ derived.agent_state === 'waiting' && derived.waiting_source === 'turn_end' ? 'idle'
1473
+ : derived.agent_state === 'running' || derived.agent_state === 'waiting' ? 'working'
1451
1474
  : derived.agent_state === 'ended' ? 'done'
1452
1475
  : 'idle'
1453
1476
  return {
@@ -1460,6 +1483,7 @@ export function statusDraftWithDerived(
1460
1483
  state_since: derived.state_since,
1461
1484
  ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
1462
1485
  ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
1486
+ ...(derived.waiting_source ? { waiting_source: derived.waiting_source, waiting_choices: [...(derived.waiting_choices ?? [])] } : {}),
1463
1487
  ...(derived.failure ? { failure: derived.failure } : {}),
1464
1488
  ...(state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
1465
1489
  }
@@ -154,6 +154,16 @@ export function sessionSteerEnabled(env: NodeJS.ProcessEnv = process.env): boole
154
154
  return triStateFlag(env, 'COS_SESSION_STEER', false)
155
155
  }
156
156
 
157
+ /**
158
+ * `COS_STEER_MIDTURN` (steer mid-turn, Miles 2026-11-07): a turn parked at a busy Claude desk
159
+ * session goes INTO the running turn through its peer inbox at the drain, and lands at its next
160
+ * tool step, instead of waiting for the turn to end. Default ON; `0` is the 6.99.33 behaviour.
161
+ * Independent of `COS_SESSION_STEER`, which only answers a client that asks for `mode: "steer"`.
162
+ */
163
+ export function steerMidturnEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
164
+ return triStateFlag(env, 'COS_STEER_MIDTURN', true)
165
+ }
166
+
157
167
  /**
158
168
  * Reasons whose outcome COS cannot establish. Such a row is never resent and is kept for
159
169
  * UNKNOWN_DELIVERY_RETENTION_MS rather than 30 minutes, because the phone has to be able to