@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
@@ -46,6 +46,12 @@ export interface WorkActivity {
46
46
  * `unknown` (older clients); with this flag it is "Sending", not an unresolved delivery. Absent otherwise.
47
47
  */
48
48
  sending?: true
49
+ /**
50
+ * 7.0.1: when the current needs-input or blocked report was made: the newest progress event of that kind (Control
51
+ * records each report as an event at the reply's own time). Absent for any other report, or when no event says.
52
+ * Today dates a waiting row by this, not by the card's newest event.
53
+ */
54
+ waitingSince?: string
49
55
  }
50
56
  /** 6.59.0 (S4): where Control's saved draft for the task's current revision sends. Never the draft's prompt. */
51
57
  export interface SavedDestination { mode: WorkHandoffMode; provider: string; model?: string; sessionId?: string }
@@ -69,7 +75,9 @@ type JournalRecord = Record<string, unknown>
69
75
  export interface WorkJournal { available: true; receipts: JournalRecord[]; drafts: JournalRecord[] }
70
76
  export type WorkJournalRead = WorkJournal | { available: false; reason: string }
71
77
 
72
- export const WORK_ACTIVITY_LIMITS = { evidence: 280, evidenceUnits: 1_200, openItems: 50, trackedDays: 14, progressEvents: 1_000, sendingSeconds: 600 } as const
78
+ export const WORK_ACTIVITY_LIMITS = { evidence: 280, evidenceUnits: 1_200, openItems: 50, trackedDays: 14, progressEvents: 1_000, sendingSeconds: 600,
79
+ /** 7.0.1: session activity this long after a needs-input or blocked report supersedes it (the reply that carried it is not). */
80
+ supersedeSeconds: 60 } as const
73
81
  /** Control's `TaskRow.workStages` (and task-store WORK_STAGES). A move naming anything else is not projected. */
74
82
  export const WORK_ACTIVITY_STAGES: readonly string[] = ['mentioned', 'planned', 'draft', 'built', 'qa', 'complete']
75
83
  const unavailableJournal = (reason: string): WorkJournalRead => ({ available: false, reason })
@@ -147,7 +155,7 @@ export function cleanEvidence(value: unknown): string | undefined {
147
155
  * The receipt's optional `progress` block, parsed on its own. Absent is undefined; a malformed block reads
148
156
  * `{ unreadable: true }` and never makes the journal unavailable: Control writes it, and a newer Control may write more.
149
157
  */
150
- function parseProgress(raw: unknown): { projection: WorkActivityProgress | UnreadableWorkProgress; newestAt?: number } | undefined {
158
+ function parseProgress(raw: unknown): { projection: WorkActivityProgress | UnreadableWorkProgress; newestAt?: number; reportAt?: number } | undefined {
151
159
  if (raw === undefined || raw === null) return undefined
152
160
  const unreadable = { projection: { unreadable: true as const } }
153
161
  if (!object(raw)) return unreadable
@@ -173,13 +181,20 @@ function parseProgress(raw: unknown): { projection: WorkActivityProgress | Unrea
173
181
  }
174
182
  const received = isoFromSeconds(receivedAt)
175
183
  if (received) projection.receivedAt = received
184
+ // 7.0.1: when it started waiting. Control records a session's report as an event of the report's own kind, and a Jev
185
+ // reading as a 'jev' event; the newest such event is the report standing now.
186
+ let reportAt: number | undefined
187
+ if (state === 'needsInput' || state === 'blocked') {
188
+ const kinds = projection.reportedBy === 'jev' ? [state, 'jev'] : [state]
189
+ for (const event of list as JournalRecord[]) if (kinds.includes(event.kind as string)) reportAt = reportAt === undefined ? event.at as number : Math.max(reportAt, event.at as number)
190
+ }
176
191
  // The newest move COS made, undone or not (Control's timeline order: events are appended).
177
192
  const move = [...list].reverse().find(event => (event as JournalRecord).kind === 'moved') as JournalRecord | undefined
178
193
  const moveAt = move && isoFromSeconds(move.at)
179
194
  if (move && moveAt && WORK_ACTIVITY_STAGES.includes(move.fromStage as string) && WORK_ACTIVITY_STAGES.includes(move.toStage as string)) {
180
195
  projection.lastMove = { from: move.fromStage as string, to: move.toStage as string, at: moveAt, undone: finite(move.undoneAt) }
181
196
  }
182
- return { projection, newestAt }
197
+ return { projection, newestAt, reportAt }
183
198
  }
184
199
  /** The report a progress projection carries, or null (none, or unreadable). */
185
200
  export function reportOf(progress: WorkActivity['progress']): WorkActivityProgress['reported'] {
@@ -229,6 +244,8 @@ function projectReceipt(receipt: JournalRecord, domain: string, all: readonly Jo
229
244
  ? isoFromSeconds(Math.max(receipt.createdAt as number, progress.newestAt)) ?? createdAt : createdAt
230
245
  }
231
246
  if (progress) activity.progress = progress.projection
247
+ const waitingSince = isoFromSeconds(progress?.reportAt)
248
+ if (waitingSince) activity.waitingSince = waitingSince
232
249
  // Control's handledByYou: acknowledged, or an unknown or delivered handoff you marked reviewed.
233
250
  activity.acknowledged = finite(receipt.acknowledgedAt) || raw === 'reviewed'
234
251
  // Ownership is the SESSION's, across every receipt that names it (Control's appOwner and serverHold look at all of them).
@@ -384,9 +401,33 @@ export function savedDestinationFor(journal: WorkJournal, row: WorkBoardRowLite)
384
401
  return draft ? draftDestination(draft) : undefined
385
402
  }
386
403
 
404
+ /**
405
+ * 7.0.1: the time a report was superseded, or nothing. The report's time is when it started waiting (`waitingSince`),
406
+ * else the card's newest event. `sessionActivityMs` is the newest turn Miles started in the session
407
+ * (work-session-activity.ts: a real prompt, never the session's own work, a background agent's notification or a tool
408
+ * result; review W3). It has to come more than `supersedeSeconds` after the report, a grace for the tracker's clock.
409
+ */
410
+ function supersededBy(activity: WorkActivity, sessionActivityMs: ReadonlyMap<string, number> | undefined): string | undefined {
411
+ if (!activity.sessionId || !sessionActivityMs) return undefined
412
+ const last = sessionActivityMs.get(activity.sessionId)
413
+ const since = Date.parse(activity.waitingSince ?? activity.updatedAt ?? '')
414
+ if (last === undefined || !Number.isFinite(last) || !Number.isFinite(since)) return undefined
415
+ return last - since > WORK_ACTIVITY_LIMITS.supersedeSeconds * 1000 ? new Date(last).toISOString() : undefined
416
+ }
417
+
387
418
  /** S2 groups, in order. */
388
419
  export type OpenWorkGroup = 'needsInput' | 'attention' | 'running' | 'done'
389
- export interface OpenWorkItem extends WorkActivity { workIdentity: string; title: string; taskRevision: string; group: OpenWorkGroup; savedDestination?: SavedDestination }
420
+ export interface OpenWorkItem extends WorkActivity {
421
+ workIdentity: string; title: string; taskRevision: string; group: OpenWorkGroup; savedDestination?: SavedDestination
422
+ /**
423
+ * 7.0.1: the needs-input or blocked report was superseded: you started a turn in its session after the report, at
424
+ * this time. The item is grouped as if no report stood. The report itself stays in
425
+ * `progress`, as Control recorded it.
426
+ */
427
+ supersededAt?: string
428
+ }
429
+ /** 7.0.1: what the open list knows about each linked session: the newest turn you started in it, ms, by `provider:native`. */
430
+ export interface OpenWorkOptions { sessionActivityMs?: ReadonlyMap<string, number> }
390
431
  export interface OpenWorkList { items: OpenWorkItem[]; total: number; truncated: boolean }
391
432
  const GROUP_RANK: Record<OpenWorkGroup, number> = { needsInput: 0, attention: 1, running: 2, done: 3 }
392
433
 
@@ -400,8 +441,14 @@ const GROUP_RANK: Record<OpenWorkGroup, number> = { needsInput: 0, attention: 1,
400
441
  * A report counts only on a receipt that did not fail, was not refused and not canceled (Control's WorkTracking.latest).
401
442
  * A checked task shows only while its handoff is queued or running (Control's board). Groups in that order, the most
402
443
  * recently updated first within each, cut to 50 after sorting; `total` counts them all. Read-only.
444
+ *
445
+ * 7.0.1 (Miles 11/07, a row "Waiting for your reply" since Oct 31 on a session he talks to all day): a needs-input or
446
+ * blocked report stands only until its session moves on. Control's tracker takes a report from the session's status
447
+ * line for this task, so a later turn that never mentions the task again leaves the old report in place for the whole
448
+ * 14 days. When `options.sessionActivityMs` shows a turn you started in the session more than a minute after the report,
449
+ * the report no longer groups the item. The session's own work never does (review W3).
403
450
  */
404
- export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRowLite[], nowMs: number): OpenWorkList {
451
+ export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRowLite[], nowMs: number, options: OpenWorkOptions = {}): OpenWorkList {
405
452
  const newest = new Map<string, JournalRecord>()
406
453
  for (const receipt of journal.receipts) {
407
454
  const prior = newest.get(receipt.workID as string)
@@ -419,7 +466,9 @@ export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRo
419
466
  if (!receipt || nowSeconds - (receipt.createdAt as number) >= WORK_ACTIVITY_LIMITS.trackedDays * 86_400) continue
420
467
  const activity = projectReceipt(receipt, row.domain, journal.receipts, nowMs)
421
468
  const status = activity.status, handled = activity.acknowledged === true
422
- const reported = ['failed', 'refused', 'canceled'].includes(status) ? null : reportOf(activity.progress)
469
+ let reported = ['failed', 'refused', 'canceled'].includes(status) ? null : reportOf(activity.progress)
470
+ const supersededAt = reported === 'needsInput' || reported === 'blocked' ? supersededBy(activity, options.sessionActivityMs) : undefined
471
+ if (supersededAt) reported = null
423
472
  // A send in flight is running, not unresolved.
424
473
  const sending = activity.sending === true
425
474
  const group: OpenWorkGroup | null = (reported === 'needsInput' || reported === 'blocked') && !handled ? 'needsInput'
@@ -431,6 +480,7 @@ export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRo
431
480
  // Control drops a completed task's card unless its handoff is still in progress.
432
481
  if (row.checked === true && !sending && !['queued', 'running'].includes(status)) continue
433
482
  const item: OpenWorkItem = { ...activity, workIdentity: identity, title: typeof row.title === 'string' ? row.title : '', taskRevision: controlSnapshotRevision(row), group }
483
+ if (supersededAt) item.supersededAt = supersededAt
434
484
  const destination = savedDestinationFor(journal, row)
435
485
  if (destination) item.savedDestination = destination
436
486
  const updatedAt = activity.updatedAt ? Date.parse(activity.updatedAt) : 0
@@ -34,6 +34,18 @@ export const epochSeconds = (v: unknown): number | null => typeof v === 'number'
34
34
  const sidOrNull = (v: unknown): string | null => typeof v === 'string' && SID.test(v.toLowerCase()) ? v.toLowerCase() : null
35
35
 
36
36
  export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
37
+ return parseSnapshotReport(v, now).snapshot
38
+ }
39
+
40
+ /**
41
+ * 7.0.13: the snapshot plus how many live rows were left out. Live rows are evidence only (what the session list
42
+ * showed), so a row COS cannot name by a full session id, or whose text breaks a bound, is skipped and counted instead
43
+ * of refusing the whole snapshot. Control 0.5.384 sent a running Claude CLI peer as `claude:<8 hex>` (the prefix id
44
+ * /api/claude-sessions serves) and every Work chat plan on every card failed with 400 invalid_snapshot. A row that is
45
+ * not an object, and every link and receipt, stay strict: links and receipts decide which sessions belong to a card.
46
+ */
47
+ export function parseSnapshotReport(v: unknown, now = Date.now()): { snapshot: ControlSnapshot; droppedLiveRows: number } {
48
+ let droppedLiveRows = 0
37
49
  if (!obj(v) || v.version !== SNAPSHOT_VERSION || !str(v.controlVersion, 40) || !Array.isArray(v.links) || v.links.length > SNAPSHOT_MAX_LINKS
38
50
  || !Array.isArray(v.receipts) || v.receipts.length > SNAPSHOT_MAX_RECEIPTS) throw new Error('invalid_snapshot')
39
51
  const links: SnapshotLink[] = []
@@ -56,15 +68,17 @@ export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
56
68
  if (!obj(v.live) || checkedAt === null || !Array.isArray(v.live.rows) || v.live.rows.length > SNAPSHOT_MAX_LIVE) throw new Error('invalid_live')
57
69
  const rows: SnapshotLiveRow[] = []
58
70
  for (const r of v.live.rows) {
59
- const id = sidOrNull(r?.id)
60
- if (!obj(r) || !id || !str(r.title ?? '', 300) || !str(r.status ?? '', 100) || !str(r.summary ?? '', 2000)) throw new Error('invalid_live_row')
71
+ if (!obj(r)) throw new Error('invalid_live_row')
72
+ const id = sidOrNull(r.id)
73
+ if (!id) { droppedLiveRows++; continue }
74
+ if (!str(r.title ?? '', 300) || !str(r.status ?? '', 100) || !str(r.summary ?? '', 2000)) { droppedLiveRows++; continue }
61
75
  if (isGuardianRow(id, r.title ?? '', r.summary ?? '')) continue
62
76
  rows.push({ id, title: r.title ?? '', status: r.status ?? '', updatedAt: str(r.updatedAt, 40) ? r.updatedAt : null,
63
77
  busy: r.busy === true, waitingOnUser: r.waitingOnUser === true, appOwned: r.appOwned === true, summary: r.summary ?? '' })
64
78
  }
65
79
  live = { checkedAt, rows }
66
80
  }
67
- return { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }
81
+ return { snapshot: { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }, droppedLiveRows }
68
82
  }
69
83
 
70
84
  /** Codex guardian review threads (canary C3: 17 in the live list) are never offered as work sessions. */
@@ -112,6 +112,19 @@ export class WorkReviewRuntime {
112
112
  return { review: publicReview(await this.reconcileRow(row, true)), replayed: false }
113
113
  })
114
114
  }
115
+ /** S1 (2026-11-09): write a reconciled row only when something other than updatedAt changed.
116
+ * The 5 s timer reconciles every row; a row whose meeting stays unavailable came back identical
117
+ * except for a fresh updatedAt, and each put is an fsync'd rewrite of the whole journal. On
118
+ * Miles's Mac three such rows rewrote the 114 KB journal three times every 5 s (about 68 KB/s,
119
+ * some 60% of the server's idle disk writes). An unchanged row keeps its original updatedAt. */
120
+ private saveIfChanged(before: StoredWorkReview, after: StoredWorkReview): StoredWorkReview {
121
+ const { updatedAt: _a, ...left } = before
122
+ const { updatedAt: _b, ...right } = after
123
+ if (JSON.stringify(left) === JSON.stringify(right)) return before
124
+ after.updatedAt = this.now()
125
+ this.deps.store.put(after)
126
+ return after
127
+ }
115
128
  private async reconcileRow(row: StoredWorkReview, allowAdmission: boolean): Promise<StoredWorkReview> {
116
129
  if (row.status === 'superseded') return row
117
130
  const updated = structuredClone(row)
@@ -119,12 +132,12 @@ export class WorkReviewRuntime {
119
132
  const current = await this.deps.resolveMeeting(row.source.descriptor); assertRecord(current, row.source.descriptor)
120
133
  if (sourceRevision(current) !== row.source.revision) {
121
134
  updated.status = 'superseded'; updated.error = { code: 'meeting_revision_changed', message: 'The saved meeting changed. Request a new review for its current revision.' }
122
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
135
+ return this.saveIfChanged(row, updated)
123
136
  }
124
137
  } catch {
125
138
  if (row.status === 'ready' || row.status === 'failed') updated.suspendedTerminal = { status: row.status, ...(row.error ? { error: row.error } : {}) }
126
139
  updated.status = 'source_unavailable'; updated.error = { code: 'meeting_source_unavailable', message: 'The original saved source cannot be verified. No new work was started.' }
127
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
140
+ return this.saveIfChanged(row, updated)
128
141
  }
129
142
  if (row.status === 'source_unavailable' && row.suspendedTerminal) {
130
143
  // Terminal receipts outlive query-job retention. Restore only after the
@@ -132,7 +145,7 @@ export class WorkReviewRuntime {
132
145
  updated.status = row.suspendedTerminal.status
133
146
  if (row.suspendedTerminal.error) updated.error = row.suspendedTerminal.error; else delete updated.error
134
147
  delete updated.suspendedTerminal
135
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
148
+ return this.saveIfChanged(row, updated)
136
149
  }
137
150
  if (row.status === 'ready' || row.status === 'failed') return row
138
151
  try {
@@ -170,7 +183,7 @@ export class WorkReviewRuntime {
170
183
  if (updated.status === 'ready') { updated.status = 'source_unavailable'; delete updated.markdown }
171
184
  updated.error = { code: e instanceof WorkReviewError ? e.code : 'review_reconciliation_unavailable', message: 'Review reconciliation is unavailable. No automatic duplicate is submitted.' }
172
185
  }
173
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
186
+ return this.saveIfChanged(row, updated)
174
187
  }
175
188
  async reconcile(allowAdmission = false): Promise<void> {
176
189
  await this.serial(async () => { if (this.stopping) return; for (const row of this.deps.store.list()) await this.reconcileRow(row, allowAdmission) })
@@ -0,0 +1,260 @@
1
+ /**
2
+ * 7.0.1 (Needs you stale): when Miles last started a turn in each Work session, for the open list's supersede rule
3
+ * (work-activity.ts projectOpenWork, `sessionActivityMs`).
4
+ *
5
+ * Miles, 11/07: Today showed "Waiting for your reply" on a Work card from Oct 31 while he talked to its session all
6
+ * day. Control's tracker had taken a needs-input report from the session's status line for that task, and no later
7
+ * turn mentioned the task again, so the report stood. The session's own transcript says he moved on.
8
+ *
9
+ * ONLY A TURN HE STARTED COUNTS (review W3). The session's own work is not an answer: a background agent finishing,
10
+ * a tool result, a Monitor or /loop wakeup, a message from another session, a system reminder or a compaction summary
11
+ * all write records into the transcript with nobody typing. Counting those cleared a report Miles still owed.
12
+ * - Claude: a `user` record that is a real prompt: not `isMeta`, not a compaction summary, not a tool result, not a
13
+ * task notification, and from a human origin when the record says where it came from (`origin.kind` and
14
+ * `turnOrigin` are `human` on a typed prompt; `peer`, `task-notification`, `system` and `sdk` are not). Its text,
15
+ * with system reminders taken out, has to say something. Checked against the record shapes of a real transcript.
16
+ * - Codex: a `response_item` message with role `user` whose text is not an injected block (`<environment_context>`,
17
+ * `# AGENTS.md instructions`, the files-mentioned list and the like).
18
+ * - Cursor: transcripts carry no per-record clock or origin, so a Cursor session answers its file's write time. Any
19
+ * write moves it, so a Cursor report can clear on the agent's own work. A listed limit.
20
+ *
21
+ * BOUNDED (review W4): at most `sessions` sessions per call, the stalest waiting reports first (waitingSessionIds);
22
+ * the Claude transcript index is built once per call; each session's answer is cached for `cacheMs`; concurrent
23
+ * callers share one read per session; and `timeoutMs` is a deadline the walk itself checks between folders, sessions
24
+ * and read windows, so a slow disk stops the work, not only the wait. A session it cannot find or read in time is left
25
+ * out, which keeps its report standing: the old behaviour, never a guessed clear.
26
+ */
27
+ import { open, stat } from 'node:fs/promises'
28
+ import {
29
+ agentSessionRoots, codexSessionRootsOf, fileStat, findAgentSessionFile, listCodexJsonlFiles, loadClaudeDesktopAliases, loadClaudeTranscriptIndex,
30
+ type AgentSessionRoots,
31
+ } from './agent-session-store.js'
32
+ import { parseWorkSessionId } from './work-activity.js'
33
+
34
+ export const WORK_SESSION_ACTIVITY_LIMITS = {
35
+ sessions: 12,
36
+ timeoutMs: 2_000,
37
+ /** One session's answer is reused this long: a list poll and the badge recount never walk the disk again inside it. */
38
+ cacheMs: 30_000,
39
+ /** First window read from the end of a transcript, widened x4 while no human turn is found. */
40
+ tailBytes: 256 * 1024,
41
+ /** Largest window: a session working for hours since his last prompt writes a lot of tool output. */
42
+ tailMaxBytes: 64 * 1024 * 1024,
43
+ } as const
44
+
45
+ export interface WorkSessionActivityOptions {
46
+ /** Fixture roots. Without them, an isolated runtime (tests) reads nothing rather than this Mac's transcripts. */
47
+ roots?: AgentSessionRoots
48
+ env?: NodeJS.ProcessEnv
49
+ timeoutMs?: number
50
+ /** Test seam for the cache and deadline clock. */
51
+ now?: () => number
52
+ }
53
+
54
+ type Rec = Record<string, unknown>
55
+ const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
56
+ /** Text a Claude user record carries that nobody typed. */
57
+ const CLAUDE_INJECTED = /^(<task-notification>|<local-command-stdout>|<local-command-stderr>|<local-command-caveat>|Caveat: The messages below were generated)/
58
+ /** Codex user-role blocks the app injects around a prompt. */
59
+ const CODEX_INJECTED = /^(<environment_context>|<user_instructions>|<external_[a-z_]+>|# AGENTS\.md instructions|# Files mentioned by the user)/
60
+
61
+ function originKind(obj: Rec): string | null {
62
+ const origin = obj.origin
63
+ if (origin && typeof origin === 'object' && typeof (origin as Rec).kind === 'string') return (origin as Rec).kind as string
64
+ return null
65
+ }
66
+
67
+ /** A Claude transcript record that is a turn Miles started. Exported for tests. */
68
+ export function claudeHumanTurn(obj: Rec): boolean {
69
+ if (obj.type !== 'user' || obj.isMeta === true || obj.isCompactSummary === true || obj.isVisibleInTranscriptOnly === true || obj.toolUseResult) return false
70
+ const kind = originKind(obj)
71
+ if (kind !== null && kind !== 'human') return false
72
+ if (kind === null && typeof obj.turnOrigin === 'string' && obj.turnOrigin !== 'human') return false
73
+ const content = (obj.message as Rec | undefined)?.content
74
+ const blocks: Rec[] = typeof content === 'string' ? [{ type: 'text', text: content }]
75
+ : Array.isArray(content) ? content.filter((b): b is Rec => !!b && typeof b === 'object') : []
76
+ if (blocks.some(b => b.type === 'tool_result')) return false
77
+ let said = false
78
+ for (const block of blocks) {
79
+ if (block.type === 'image') { said = true; continue }
80
+ if (block.type !== 'text' || typeof block.text !== 'string') continue
81
+ const text = block.text.replace(SYSTEM_REMINDER, '').trim()
82
+ if (!text) continue
83
+ if (CLAUDE_INJECTED.test(text)) return false
84
+ said = true
85
+ }
86
+ return said
87
+ }
88
+
89
+ /** A Codex rollout record that is a message Miles sent. Exported for tests. */
90
+ export function codexHumanTurn(obj: Rec): boolean {
91
+ if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return false
92
+ const payload = obj.payload as Rec
93
+ if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) return false
94
+ return payload.content.some(b => {
95
+ if (!b || typeof b !== 'object') return false
96
+ const block = b as Rec
97
+ if (block.type === 'input_image') return true
98
+ if (typeof block.text !== 'string') return false
99
+ const text = block.text.trim()
100
+ return !!text && !CODEX_INJECTED.test(text)
101
+ })
102
+ }
103
+
104
+ /** The newest human turn's time in a transcript tail, ms, or null. Exported for tests. */
105
+ export function lastHumanTurnFromTail(provider: 'claude' | 'codex', text: string, startsMidFile: boolean): number | null {
106
+ const lines = text.split('\n')
107
+ if (startsMidFile) lines.shift()
108
+ const isHuman = provider === 'claude' ? claudeHumanTurn : codexHumanTurn
109
+ for (let i = lines.length - 1; i >= 0; i--) {
110
+ const line = lines[i]!
111
+ // Cheap prefilter before a parse: every candidate names a user role.
112
+ if (!line.includes('"user"')) continue
113
+ let obj: unknown
114
+ try { obj = JSON.parse(line) } catch { continue }
115
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj) || !isHuman(obj as Rec)) continue
116
+ const at = Date.parse(String((obj as Rec).timestamp ?? ''))
117
+ if (Number.isFinite(at)) return at
118
+ }
119
+ return null
120
+ }
121
+
122
+ const turnMemo = new Map<string, { mtimeMs: number; size: number; at: number | null }>()
123
+ const TURN_MEMO_MAX = 200
124
+
125
+ /** One transcript's newest human turn, memoized on mtime and size. undefined: the deadline came first. */
126
+ async function readLastHumanTurn(provider: 'claude' | 'codex', file: string, pastDeadline: () => boolean): Promise<number | null | undefined> {
127
+ const st = await stat(file)
128
+ const hit = turnMemo.get(file)
129
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.at
130
+ let at: number | null = null
131
+ const handle = await open(file, 'r')
132
+ try {
133
+ for (let window: number = WORK_SESSION_ACTIVITY_LIMITS.tailBytes; ; window *= 4) {
134
+ if (pastDeadline()) return undefined
135
+ const length = Math.min(st.size, window), start = st.size - length
136
+ const buffer = Buffer.alloc(length)
137
+ const { bytesRead } = await handle.read(buffer, 0, length, start)
138
+ at = lastHumanTurnFromTail(provider, buffer.subarray(0, bytesRead).toString('utf8'), start > 0)
139
+ if (at !== null || start === 0 || window >= WORK_SESSION_ACTIVITY_LIMITS.tailMaxBytes) break
140
+ }
141
+ } finally {
142
+ await handle.close()
143
+ }
144
+ turnMemo.delete(file)
145
+ while (turnMemo.size >= TURN_MEMO_MAX) { const oldest = turnMemo.keys().next().value; if (oldest === undefined) break; turnMemo.delete(oldest) }
146
+ turnMemo.set(file, { mtimeMs: st.mtimeMs, size: st.size, at })
147
+ return at
148
+ }
149
+
150
+ /** Per session: the answer and when it was read. `at` null: read, and no human turn (or no transcript, or unreadable). */
151
+ const cache = new Map<string, { at: number | null; readAt: number }>()
152
+ const inflight = new Map<string, Promise<number | null | undefined>>()
153
+ const CACHE_MAX = 500
154
+ let walks = 0
155
+
156
+ function cacheKey(roots: AgentSessionRoots, id: string): string { return `${roots.claudeProjects}\u0000${roots.cursorProjects}\u0000${id}` }
157
+
158
+ /** Newest human turn, ms since 1970, of each session it could read, keyed by the id exactly as given. */
159
+ export async function workSessionActivity(sessionIds: readonly string[], options: WorkSessionActivityOptions = {}): Promise<Map<string, number>> {
160
+ const found = new Map<string, number>()
161
+ const env = options.env ?? process.env
162
+ if (!options.roots && (env.NODE_ENV === 'test' || env.VITEST)) return found
163
+ const roots = options.roots ?? agentSessionRoots()
164
+ const now = options.now ?? (() => Date.now())
165
+ const ids = [...new Set(sessionIds)].filter(id => parseWorkSessionId(id) !== null).slice(0, WORK_SESSION_ACTIVITY_LIMITS.sessions)
166
+ if (!ids.length) return found
167
+ const started = now(), deadline = started + (options.timeoutMs ?? WORK_SESSION_ACTIVITY_LIMITS.timeoutMs)
168
+ const pastDeadline = () => now() >= deadline
169
+ const keep = (id: string, at: number | null | undefined) => { if (typeof at === 'number' && Number.isFinite(at)) found.set(id, at) }
170
+
171
+ const missing: string[] = []
172
+ const waits: Array<Promise<void>> = []
173
+ for (const id of ids) {
174
+ const key = cacheKey(roots, id), hit = cache.get(key)
175
+ if (hit && started - hit.readAt < WORK_SESSION_ACTIVITY_LIMITS.cacheMs) { keep(id, hit.at); continue }
176
+ const running = inflight.get(key)
177
+ if (running) waits.push(running.then(at => keep(id, at)))
178
+ else missing.push(id)
179
+ }
180
+
181
+ if (missing.length) {
182
+ // One walk for every missing session. Each session's promise is registered first, so a concurrent caller waits on
183
+ // this read instead of starting its own.
184
+ const settle = new Map<string, (at: number | null | undefined) => void>()
185
+ for (const id of missing) {
186
+ const key = cacheKey(roots, id)
187
+ const one = new Promise<number | null | undefined>(resolve => settle.set(id, resolve))
188
+ inflight.set(key, one)
189
+ waits.push(one.then(at => keep(id, at)))
190
+ }
191
+ void (async () => {
192
+ let indexed = false
193
+ let claudeFiles: Map<string, string[]> | undefined
194
+ let codexFiles: string[] | undefined
195
+ let aliases: Awaited<ReturnType<typeof loadClaudeDesktopAliases>> | undefined
196
+ for (const id of missing) {
197
+ const key = cacheKey(roots, id)
198
+ let at: number | null | undefined
199
+ try {
200
+ const { provider, native } = parseWorkSessionId(id)!
201
+ if (!pastDeadline() && !indexed) {
202
+ indexed = true
203
+ walks += 1
204
+ if (missing.some(m => parseWorkSessionId(m)!.provider === 'claude')) {
205
+ aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
206
+ claudeFiles = await loadClaudeTranscriptIndex(roots, pastDeadline)
207
+ }
208
+ if (!pastDeadline() && missing.some(m => parseWorkSessionId(m)!.provider === 'codex')) {
209
+ codexFiles = (await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root)))).flat()
210
+ }
211
+ }
212
+ // Past the deadline (a walk it cut short is not a complete index, and resolving against it could pick the
213
+ // wrong file): left out this time and read again on the next call.
214
+ if (pastDeadline()) at = undefined
215
+ else {
216
+ const file = await findAgentSessionFile(provider as 'claude' | 'codex' | 'cursor', native, roots, new Date(), aliases, { claudeFiles, codexFiles })
217
+ if (!file) at = null
218
+ else if (provider === 'cursor') at = (await fileStat(file))?.mtimeMs ?? null
219
+ else at = await readLastHumanTurn(provider as 'claude' | 'codex', file, pastDeadline)
220
+ }
221
+ } catch {
222
+ at = null // unreadable: its report stands, and the answer is cached like any other
223
+ }
224
+ if (at !== undefined) {
225
+ cache.delete(key)
226
+ while (cache.size >= CACHE_MAX) { const oldest = cache.keys().next().value; if (oldest === undefined) break; cache.delete(oldest) }
227
+ cache.set(key, { at, readAt: now() })
228
+ }
229
+ inflight.delete(key)
230
+ settle.get(id)!(at)
231
+ }
232
+ })()
233
+ }
234
+
235
+ let timer: NodeJS.Timeout | undefined
236
+ await Promise.race([Promise.all(waits), new Promise<void>(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - now())) })])
237
+ clearTimeout(timer)
238
+ // A copy: a shared read may still land in `found` after the deadline.
239
+ return new Map(found)
240
+ }
241
+
242
+ /**
243
+ * The sessions whose report the open list would show as waiting: the ones worth reading. The stalest report first
244
+ * (review N7): under the 12-session cap the oldest waits are the likeliest to be stale, like the Oct 31 row.
245
+ */
246
+ export function waitingSessionIds(items: ReadonlyArray<{ group: string; sessionId?: string; waitingSince?: string; updatedAt?: string }>): string[] {
247
+ const when = (item: { waitingSince?: string; updatedAt?: string }) => {
248
+ const t = Date.parse(item.waitingSince ?? item.updatedAt ?? '')
249
+ return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY
250
+ }
251
+ const out: string[] = []
252
+ for (const item of items.filter(i => i.group === 'needsInput' && i.sessionId).sort((a, b) => when(a) - when(b))) {
253
+ if (!out.includes(item.sessionId!)) out.push(item.sessionId!)
254
+ }
255
+ return out
256
+ }
257
+
258
+ /** Test seams. */
259
+ export function clearWorkSessionActivityCache(): void { cache.clear(); inflight.clear(); turnMemo.clear(); walks = 0 }
260
+ export function workSessionActivityWalks(): number { return walks }
@@ -9,6 +9,7 @@
9
9
  * next poll tries again.
10
10
  */
11
11
  import { projectOpenWork, readWorkJournal, type OpenWorkGroup } from './work-activity.js'
12
+ import { waitingSessionIds } from './work-session-activity.js'
12
13
 
13
14
  type Journal = ReturnType<typeof readWorkJournal>
14
15
  type Rows = Parameters<typeof projectOpenWork>[1]
@@ -17,6 +18,8 @@ export interface YourMoveCounterDeps {
17
18
  journal: () => Journal
18
19
  /** The board rows, or null when the board cannot be read in time. */
19
20
  rows: () => Promise<Rows | null>
21
+ /** 7.0.1: each linked session's newest activity, so a superseded report is not counted (the open list's own rule). */
22
+ sessionActivity?: (sessionIds: readonly string[]) => Promise<ReadonlyMap<string, number>>
20
23
  now?: () => number
21
24
  ttlMs?: number
22
25
  }
@@ -25,9 +28,9 @@ export const YOUR_MOVE_TTL_MS = 30_000
25
28
  const WAITING_GROUPS: readonly OpenWorkGroup[] = ['needsInput', 'attention', 'done']
26
29
 
27
30
  /** The pure count: open-work items whose group waits on you. */
28
- export function countYourMove(journal: Journal, rows: Rows, nowMs: number): number {
31
+ export function countYourMove(journal: Journal, rows: Rows, nowMs: number, sessionActivityMs?: ReadonlyMap<string, number>): number {
29
32
  if (!journal.available) return 0
30
- return projectOpenWork(journal, rows, nowMs).items.filter(item => WAITING_GROUPS.includes(item.group)).length
33
+ return projectOpenWork(journal, rows, nowMs, { sessionActivityMs }).items.filter(item => WAITING_GROUPS.includes(item.group)).length
31
34
  }
32
35
 
33
36
  export function createYourMoveCounter(deps: YourMoveCounterDeps): () => Promise<number | null> {
@@ -44,7 +47,10 @@ export function createYourMoveCounter(deps: YourMoveCounterDeps): () => Promise<
44
47
  if (!journal.available) return null
45
48
  const rows = await deps.rows()
46
49
  if (!rows) return null
47
- const value = countYourMove(journal, rows, now())
50
+ const at = now()
51
+ const waiting = deps.sessionActivity && journal.available ? waitingSessionIds(projectOpenWork(journal, rows, at).items) : []
52
+ const activity = waiting.length ? await deps.sessionActivity!(waiting).catch(() => undefined) : undefined
53
+ const value = countYourMove(journal, rows, at, activity)
48
54
  cached = { at: now(), value }
49
55
  return value
50
56
  } catch {
@@ -459,6 +459,13 @@ export interface AgentSessionBindingsDeps {
459
459
  */
460
460
  sessionSteer?: (provider: string, nativeThreadId: string, gateReason: string) => SessionSteerAvailability | null
461
461
 
462
+ /**
463
+ * Steer mid-turn (COS_STEER_MIDTURN): would a message parked at this busy thread go into its
464
+ * running turn at the next tool step? Added to a busy attachability answer as `midTurn`, so the
465
+ * composer says which line is true. Null (field absent) with the switch off.
466
+ */
467
+ sessionMidTurn?: (provider: string, nativeThreadId: string, gateReason: string) => SessionSteerAvailability | null
468
+
462
469
  ownership?: {
463
470
  record: (pid: number, startMs: number) => string
464
471
  release: (pid: number) => boolean
@@ -2164,8 +2171,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2164
2171
  const detected = routeOccupancy(provider, threadId)
2165
2172
  const verdictBody = projectAttachability(detected)
2166
2173
  const cancelReason = detected.busyHolder === true ? 'native_thread_working' : verdictBody.reason
2174
+ // Steer mid-turn: only on a busy answer a turn may queue behind, and only with the switch on.
2175
+ let midTurn: SessionSteerAvailability | null = null
2176
+ if (verdictBody.attachable === false && typeof verdictBody.reason === 'string' && queueableRefusal(verdictBody.reason) && typeof deps.sessionMidTurn === 'function') {
2177
+ try { midTurn = deps.sessionMidTurn(provider, threadId, verdictBody.reason) } catch { midTurn = null }
2178
+ }
2167
2179
  res.json({
2168
2180
  ...verdictBody,
2181
+ ...(midTurn ? { midTurn } : {}),
2169
2182
  cancel: cancelFieldFor(provider, threadId, cancelReason),
2170
2183
  ...(await continueNoteFor(provider, threadId, continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]))),
2171
2184
  })
@@ -2741,8 +2754,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2741
2754
  // 6.99.29 (Codex audit of e4758a6): the record names the SOURCE session, never the fork child
2742
2755
  // that actually receives the words, and the child has no binding yet. Until a destination
2743
2756
  // binding exists, a forked turn is recorded UNVERIFIED for the child: no footer, ever.
2744
- const forkVerdict = unvouched(deps.provenance.originOf(req), 'fork_destination_unbound')
2757
+ // 6.99.31: a signed web fork is judged too, so its record says why (always fork_destination_unbound).
2745
2758
  const forkTurnId = typeof clientForkId === 'string' ? clientForkId : `fork-${randomBytes(12).toString('hex')}`
2759
+ const forkVerdict = deps.provenance.acceptWebKey(unvouched(deps.provenance.originOf(req), 'fork_destination_unbound'), { provider: providerParam, sessionId: null, turnId: forkTurnId, words: prompt, attachments: [], fork: true })
2746
2760
  forkPrompt = deps.provenance.footed(prompt, forkTurnId, forkVerdict, providerParam)
2747
2761
  deps.provenance.record({ turnId: forkTurnId, prompt, verdict: forkVerdict, provider: providerParam, sessionId: threadIdParam, via: 'fork', fork: true })
2748
2762
  }
@@ -3126,6 +3140,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3126
3140
  return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
3127
3141
  }
3128
3142
  }
3143
+ // 6.99.31: a web turn carrying an enrolled phone key's envelope is judged once the binding names
3144
+ // the real target (below). Its files are read here (the only await): the envelope's ordered
3145
+ // manifest must equal them. Null: unreadable, so the turn is not vouched.
3146
+ let webKeyFiles: ReadonlyArray<{ id: string; sha256: string }> | null = []
3147
+ if (turnVerdict?.webKeyCandidate && attachIds.ids.length > 0) {
3148
+ const m = await turnAttachmentManifest(attachIds.ids, deps.turnAttachmentStore)
3149
+ webKeyFiles = m.ok ? m.manifest : null
3150
+ }
3129
3151
  /** Ledgered once the text did, or may have, reached a session. Hash of the text WITHOUT the footer. */
3130
3152
  const ledgerTurn = (via: string, provider: string, sessionId: string): void => {
3131
3153
  if (provenance && turnVerdict && clientTurnId !== null) provenance.record({ turnId: clientTurnId, prompt, verdict: turnVerdict, provider, sessionId, via: turnCarried ? `drain-${via}` : via, continues: turnCarried })
@@ -3201,6 +3223,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3201
3223
  console.warn(`[agent-session-bindings] queued turn seal mismatch (target) turnId=${turnId} clientTurnId=${clientTurnId}`)
3202
3224
  return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
3203
3225
  }
3226
+ // 6.99.31: the envelope against the binding's provider and native thread (the actual receiving
3227
+ // session), the exact words (before the files block) and the files. Synchronous: the nonce is
3228
+ // spent here, in the same tick as the reuse check below. No envelope: the verdict is unchanged.
3229
+ if (provenance && turnVerdict && turnVerdict.webKeyCandidate) {
3230
+ turnVerdict = provenance.acceptWebKey(turnVerdict, { provider: binding.provider, sessionId: binding.nativeThreadId, bindingId, turnId: clientTurnId, words, attachments: webKeyFiles })
3231
+ }
3204
3232
  if (provenance && turnVerdict && provenance.reusesTurnId(clientTurnId, { prompt, verdict: turnVerdict, provider: binding.provider, sessionId: binding.nativeThreadId }, { continues: turnCarried })) {
3205
3233
  return refuseTurn('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
3206
3234
  }
@@ -59,6 +59,8 @@
59
59
  // never be mistaken for a user's explicit opt-out. Off answers 503 and the client
60
60
  // polls, which is the same code path as a server too old to have this route at all.
61
61
 
62
+ import { streamSignInHolds } from '../lib/api-auth.js'
63
+ import { redactLocalPaths } from '../lib/local-path-redact.js'
62
64
  import { Router, type Response } from 'express'
63
65
  import { stat } from 'node:fs/promises'
64
66
  import {
@@ -213,6 +215,16 @@ export async function openingState(
213
215
  */
214
216
  export const SEED_EVENTS = 7
215
217
 
218
+ /**
219
+ * A stream event as it leaves for a client. 7.0.4 final QA: a `prompt` is redacted like the user turns in `?turns=`
220
+ * history (redactLocalPathsDeep, not a command field), so the lens's prompt match (findTurnStart) still finds its turn.
221
+ * Every other kind is unchanged.
222
+ */
223
+ export function clientStreamEvent<T extends { kind: string }>(event: T): T {
224
+ const text = (event as { text?: unknown }).text
225
+ return event.kind === 'prompt' && typeof text === 'string' ? { ...event, text: redactLocalPaths(text) } : event
226
+ }
227
+
216
228
  agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', async (req, res) => {
217
229
  res.set('Cache-Control', 'private, no-store')
218
230
 
@@ -331,6 +343,11 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
331
343
  lastStamp = stampOf(derived)
332
344
  out = { ...event, ...statusDraftWithDerived(event, derived) }
333
345
  }
346
+ // 7.0.4 final QA: the turn history (`?turns=`) redacts local paths in user turns, and the lens finds the turn on
347
+ // screen by matching this prompt's opening words against them (cos-glasses-app session-conversation.ts
348
+ // findTurnStart). An unredacted prompt with a Mac path never matched, so the lens refetched and showed the turn
349
+ // twice. The prompt leaves redacted the same way; server-side consumers keep the raw event.
350
+ out = clientStreamEvent(out)
334
351
  const idLine = typeof out.cursor === 'number' ? `id: ${out.epoch ?? RING_EPOCH}.${out.cursor}\n` : ''
335
352
  res.write(`${idLine}data: ${JSON.stringify({ seq: ++seq, ...out })}\n\n`)
336
353
  } catch {
@@ -350,6 +367,12 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
350
367
  teardown()
351
368
  return
352
369
  }
370
+ // Final review S1: a stream opened by a browser sign-in ends once that sign-in no longer verifies (signed out
371
+ // everywhere, expired, past the 30-day cap). A token stream never re-checks.
372
+ if (!streamSignInHolds(res)) {
373
+ teardown()
374
+ return
375
+ }
353
376
  write({ kind: 'heartbeat', at: Date.now() })
354
377
  }, HEARTBEAT_INTERVAL_MS)
355
378
  if (typeof (heartbeat as any).unref === 'function') (heartbeat as any).unref()