@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -24,13 +24,13 @@ import {
24
24
  admitToQueue, drainDecision, queueableRefusal, queuePosition,
25
25
  MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
26
26
  queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
27
- type SessionSteerAvailability,
27
+ steerMidturnEnabled, type SessionSteerAvailability,
28
28
  } from '../lib/thread-turn-queue.js'
29
29
  import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queue-store.js'
30
30
  import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
31
31
  import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
32
32
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
33
- import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
33
+ import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, sealMatches, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
34
34
  import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
35
35
 
36
36
  /**
@@ -115,6 +115,23 @@ export interface ThreadTurnQueueDeps {
115
115
  begin: (provider: string, threadId: string, clientTurnId: string, mode: 'turn' | 'steer', now: number) => boolean
116
116
  settle: (provider: string, threadId: string, clientTurnId: string, exit: 'sent' | 'not_sent' | 'unknown', now: number) => boolean
117
117
  }
118
+ /**
119
+ * Steer mid-turn (COS_STEER_MIDTURN), optional: ask for a drain pass for this thread now, so a
120
+ * turn parked at a busy session that can take it mid-turn goes in within seconds instead of at
121
+ * the next 20 s sweep. Production: the shared drain kick (one pass in flight, ever). Absent: the
122
+ * sweep still delivers it, only later.
123
+ */
124
+ kickDrain?: (threadId: string) => void
125
+ }
126
+
127
+ /**
128
+ * Can this thread take a parked turn mid-turn right now (COS_STEER_MIDTURN)? The same shared rule
129
+ * as Session steer, plus the switch and the two members the drain's steer needs. Never throws.
130
+ */
131
+ export function midTurnAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe' | 'deliverLiveTurn' | 'continueLive' | 'attempts'>, provider: string, threadId: string, gateReason: string | null): SessionSteerAvailability | null {
132
+ if (!steerMidturnEnabled()) return null
133
+ if (typeof deps.deliverLiveTurn !== 'function' || !deps.attempts) return { available: false, reason: 'no_outside_channel' }
134
+ return steerAvailabilityFor(deps, provider, threadId, gateReason)
118
135
  }
119
136
 
120
137
  /**
@@ -210,6 +227,159 @@ function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boole
210
227
  return queueableRefusal(outcome.reason)
211
228
  }
212
229
 
230
+ /**
231
+ * The verdict a drain hop with no parked origin is judged by: the loopback call it is. The front
232
+ * door reaches the same verdict through `originOf` on the drainer's own request (local socket,
233
+ * token shown), so a steered turn and a front-door turn are ledgered alike. Never footed.
234
+ */
235
+ function localDrainVerdict(at: number): OriginVerdict {
236
+ return {
237
+ origin: { remote: '127.0.0.1', loopback: true, ownAddress: true, clientInstance: null, client: 'local', tokenPresented: true, webSession: false },
238
+ verifiedOrigin: false,
239
+ reason: 'local_socket',
240
+ judgedAt: at,
241
+ }
242
+ }
243
+
244
+ /** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
245
+ type MidTurnOutcome = 'delivered' | 'settled' | 'held'
246
+
247
+ /**
248
+ * Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
249
+ * of a busy Claude desk session through its peer inbox. Claude Code takes the frame at once (its
250
+ * `enqueue` row is the proof `deliverLiveTurn` waits for) and folds it in at its next tool step.
251
+ *
252
+ * Called only by `drainThread`, only for a row the drain would otherwise hold behind a busy gate,
253
+ * and only in FIFO order (the caller stops at the first row that holds).
254
+ *
255
+ * WHO MAY BE STEERED: a row never attempted (`attempts === 0`: no front-door attempt, no earlier
256
+ * steer, no restart requeue, so nothing about it is unknown), with no files (the peer inbox
257
+ * carries words only), on a thread the shared steer rule says can take it now.
258
+ *
259
+ * PROVENANCE IS CHECKED HERE AS THE TURNS ROUTE CHECKS IT, because this hop does not go through
260
+ * that front door: footer-shaped words are refused, the parked origin is carried (a single-use
261
+ * carry token, spent in this process), its seal must name this provider, thread and no files,
262
+ * a turn id held for another turn is refused, and only the server writes the footer. A refusal
263
+ * settles the row `refused` with its own reason, so it reads "Not sent" with the reason line.
264
+ *
265
+ * NEVER TWICE, in the same order as `steerTurn`: the intent row (`delivering`, `mode: 'steer'`,
266
+ * `journal: 1`, this boot) durably, then the attempt `pending`, then the frame, then the
267
+ * verdict, journal first. Unverified, a failed write or a throw: `refused`, `steer_unverified`,
268
+ * `deliveryState: 'unknown'`, never resent. Nothing reached the session: back to waiting, the
269
+ * attempt given back, so the turn-end front door sends it as before.
270
+ *
271
+ * NO AWAIT BEFORE THE INTENT ROW IS WRITTEN: the caller read this row as `waiting` in the same
272
+ * synchronous step, so a cancel cannot slip between that read and the `delivering` write.
273
+ */
274
+ async function steerHeldTurn(
275
+ provider: string,
276
+ threadId: string,
277
+ turn: QueuedThreadTurn,
278
+ gateReason: string | null,
279
+ deps: ThreadTurnQueueDeps,
280
+ ): Promise<MidTurnOutcome> {
281
+ if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
282
+ if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
283
+ const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
284
+ if (!availability || !availability.available) return 'held'
285
+ const { clientTurnId, prompt } = turn
286
+
287
+ const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
288
+ const latest = readQueue(provider, threadId, deps.now())
289
+ const row = latest.find(t => t.clientTurnId === clientTurnId)
290
+ if (!row) return null
291
+ apply(row)
292
+ writeQueue(provider, threadId, latest, { durable: true })
293
+ return row
294
+ }
295
+ const refuse = (reason: string): MidTurnOutcome => {
296
+ const at = deps.now()
297
+ rewrite(row => { row.status = 'refused'; row.reason = reason; row.settledAt = at })
298
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
299
+ console.warn(`[thread-turn-queue] mid-turn refused provider=${provider} clientTurnId=${clientTurnId} reason=${reason}`)
300
+ return 'settled'
301
+ }
302
+
303
+ // The front door's provenance gates, in its order.
304
+ let verdict: OriginVerdict | null = null
305
+ if (deps.provenance) {
306
+ if (hasFooterShapedLine(prompt)) return refuse('footer_line_refused')
307
+ const token = deps.provenance.issueCarry({ provider, threadId, clientTurnId, prompt })
308
+ const carried = token ? deps.provenance.takeCarry(token, clientTurnId, prompt) : null
309
+ // No carry could be made: leave it for the front door, which decides with its own carry.
310
+ if (carried === null) return 'held'
311
+ if (carried.seal !== null && !sealMatches(carried.seal, { provider, threadId, attachments: '' })) return refuse('queued_turn_tampered')
312
+ verdict = carried.verdict ?? localDrainVerdict(deps.now())
313
+ if (deps.provenance.reusesTurnId(clientTurnId, { prompt, verdict, provider, sessionId: threadId }, { continues: true })) return refuse('turn_id_in_use')
314
+ }
315
+ const sent = deps.provenance && verdict ? deps.provenance.footed(prompt, clientTurnId, verdict, provider) : prompt
316
+ const ledger = (via: string): void => {
317
+ if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via, continues: true })
318
+ }
319
+
320
+ // 1. The intent row, durably.
321
+ const intent = rewrite(row => {
322
+ row.status = 'delivering'
323
+ row.attempts = 1
324
+ row.mode = 'steer'
325
+ row.journal = 1
326
+ if (typeof deps.bootId === 'string' && deps.bootId.length > 0) row.deliveringBootId = deps.bootId
327
+ else delete row.deliveringBootId
328
+ })
329
+ if (!intent) return 'held'
330
+ const backToWaiting = (row: QueuedThreadTurn): void => {
331
+ row.status = 'waiting'
332
+ row.attempts = 0
333
+ delete row.mode
334
+ delete row.journal
335
+ delete row.deliveringBootId
336
+ }
337
+
338
+ // 2. The attempt `pending`, durably, before any frame.
339
+ let begun = false
340
+ try { begun = deps.attempts!.begin(provider, threadId, clientTurnId, 'steer', deps.now()) === true } catch { begun = false }
341
+ if (!begun) {
342
+ rewrite(backToWaiting)
343
+ console.warn(`[thread-turn-queue] mid-turn not attempted provider=${provider} clientTurnId=${clientTurnId}: the attempt journal could not record it`)
344
+ return 'held'
345
+ }
346
+
347
+ // 3. The frame.
348
+ const startedAt = deps.now()
349
+ let live: { ok: boolean; reason: string } | null = null
350
+ try {
351
+ live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt: sent, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
352
+ } catch (error) {
353
+ console.error(`[thread-turn-queue] mid-turn delivery threw: ${error instanceof Error ? error.message : error}`)
354
+ live = null
355
+ }
356
+ const at = deps.now()
357
+ const journal = (exit: 'sent' | 'not_sent' | 'unknown'): void => {
358
+ try { deps.attempts!.settle(provider, threadId, clientTurnId, exit, at) } catch { /* the reconcile reads `pending` as unknown */ }
359
+ }
360
+ console.log(`[thread-turn-queue] mid-turn provider=${provider} clientTurnId=${clientTurnId} hop=${live?.reason ?? 'threw'} ms=${Math.max(0, at - startedAt)} waitedMs=${Math.max(0, at - turn.queuedAt)}`)
361
+
362
+ // 4. The verdict, journal first, then the row.
363
+ if (live?.ok === true) {
364
+ journal('sent')
365
+ ledger('drain-steer')
366
+ rewrite(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
367
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
368
+ return 'delivered'
369
+ }
370
+ if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
371
+ journal('unknown')
372
+ ledger('drain-steer-unverified')
373
+ rewrite(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
374
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
375
+ return 'settled'
376
+ }
377
+ // The transport's own word that nothing reached a session: the row waits for the turn end.
378
+ journal('not_sent')
379
+ rewrite(backToWaiting)
380
+ return 'held'
381
+ }
382
+
213
383
  /**
214
384
  * One drain pass over one thread.
215
385
  *
@@ -243,9 +413,14 @@ export async function drainThread(
243
413
  if (turn.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, turn.clientTurnId)
244
414
  }
245
415
 
416
+ // Steer mid-turn (COS_STEER_MIDTURN): FIFO per thread. Once a row ahead cannot go in mid-turn
417
+ // (or is still being delivered), nothing behind it may jump it; it waits for the turn end too.
418
+ let midTurnOrderHeld = false
419
+
246
420
  for (const snapshot of queue) {
247
421
  const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
248
422
  if (!turn) continue
423
+ if (turn.status === 'delivering') midTurnOrderHeld = true
249
424
  if (turn.status !== 'waiting') continue
250
425
 
251
426
  // Reuse only refusal within this synchronous pass. Rechecking a busy thread
@@ -284,7 +459,21 @@ export async function drainThread(
284
459
  }
285
460
  const decision = drainDecision(turn, seen, deps.now())
286
461
 
287
- if (decision === 'hold') { held += 1; continue }
462
+ if (decision === 'hold') {
463
+ // Steer mid-turn: never during a cancel hold (the person said stop), only for the first
464
+ // rows in order, and only where the shared steer rule says this session can take it now,
465
+ // which also means the hold is the busy GATE: the rule answers only for a refusal that
466
+ // names a desk run (an attachable thread has no reason). Anything else holds as before.
467
+ const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
468
+ if (!midTurnOrderHeld && !cancelHeld) {
469
+ const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
470
+ if (outcome === 'delivered') { delivered += 1; continue }
471
+ if (outcome === 'settled') { retired += 1; continue }
472
+ }
473
+ midTurnOrderHeld = true
474
+ held += 1
475
+ continue
476
+ }
288
477
  if (decision === 'expire' || decision === 'give_up') {
289
478
  turn.status = decision === 'expire' ? 'expired' : 'refused'
290
479
  turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
@@ -710,12 +899,27 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
710
899
 
711
900
  writeQueue(provider, threadId, admitted.queue)
712
901
  if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via: 'queue' })
902
+ // Steer mid-turn (COS_STEER_MIDTURN): the park answer stays the 202 every client reads (a
903
+ // lens that sees anything else says "could not queue"); the drain is asked to run now, and it
904
+ // puts the words into the running turn. `landsAt` (additive, only with the switch on) tells a
905
+ // client which line to show: `next_step` only when this row is first in line, has no files,
906
+ // and the session can take it now; `turn_end` otherwise.
907
+ const midTurn = midTurnAvailabilityFor(deps, provider, threadId, gate.reason)
908
+ let landsAt: 'next_step' | 'turn_end' | null = null
909
+ if (midTurn) {
910
+ const firstInLine = queuePosition(admitted.queue, clientTurnId) === 0 && !admitted.queue.some(t => t.status === 'delivering')
911
+ landsAt = midTurn.available && firstInLine && attach.ids.length === 0 ? 'next_step' : 'turn_end'
912
+ if (landsAt === 'next_step') {
913
+ try { deps.kickDrain?.(threadId) } catch { /* the 20 s sweep still delivers it */ }
914
+ }
915
+ }
713
916
  return res.status(202).json({
714
917
  queued: true,
715
918
  clientTurnId,
716
919
  position: queuePosition(admitted.queue, clientTurnId),
717
920
  waitingOn: gate.reason,
718
921
  ...(steerFallback !== null ? { steerFallback } : {}),
922
+ ...(landsAt !== null ? { landsAt } : {}),
719
923
  })
720
924
  })
721
925
 
@@ -4,10 +4,19 @@
4
4
  * - GET /api/today/brief the cached summary; never generates.
5
5
  * - POST /api/today/brief an open Today's ask, with the rows it read (titles, states, how long, last lines,
6
6
  * what needs you). Generates only when the switch is on, the inputs changed, 10 minutes
7
- * passed and the day's 60 are not spent. The Work cards in progress come from the
7
+ * passed and the day's cap (Summaries per day, 7.0.5) is not spent. The Work cards in progress come from the
8
8
  * server's own shared board read, never the body.
9
+ * 7.0.1: `"force": true` in the body is the Today refresh button. It skips the
10
+ * 10-minute interval and the "inputs unchanged" check, at most once every 30 s,
11
+ * inside the same daily cap. A throttled one answers reason `refresh_throttled` and
12
+ * `retryAfterMs`.
13
+ * 7.0.5: `"reason": "reconnect"` says the client's read just recovered from a failed
14
+ * one: that start skips the 10-minute interval, at most once per 10 minutes. The
15
+ * "inputs unchanged" check, the breaker, the switch and the daily cap still apply.
9
16
  * - GET /api/today/brief/settings the switch and what set it.
10
17
  * - POST /api/today/brief/settings { enabled: boolean }, the Settings toggle. COS_TODAY_BRIEF_LLM, when set, still wins.
18
+ * - GET /api/today/brief/settings/cap 7.0.5 "Summaries per day": { dailyCap, min: 1, max: 100, default: 100, usedToday }.
19
+ * - PUT /api/today/brief/settings/cap { dailyCap: number }, clamped to 1 to 100 and saved; answers the same shape.
11
20
  * Auth is the parent /api gate (token or web session cookie). Every answer is private, no-store.
12
21
  */
13
22
  import { Router } from 'express'
@@ -61,7 +70,7 @@ export async function readSubagentsFor(keys: string[], now = Date.now()): Promis
61
70
  return out
62
71
  }
63
72
 
64
- /** The server's own brief: the data folder, Haiku through the isolated text runner, one audit row per start. */
73
+ /** The server's own brief: the data folder, Sonnet (7.0.1) through the isolated text runner, one audit row per start. */
65
74
  export function defaultTodayBrief(): TodayBrief {
66
75
  return createTodayBrief({
67
76
  stateFile: dataPath(TODAY_BRIEF_STATE_FILE),
@@ -95,6 +104,18 @@ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
95
104
  return out.sort((a, b) => Number(b.running) - Number(a.running)).slice(0, 12)
96
105
  }
97
106
 
107
+ /**
108
+ * The body without `force` and `reason`, and what they asked. Anything but a boolean `force` is a bad request; 7.0.5
109
+ * `reason` may only be "reconnect".
110
+ */
111
+ export function splitForce(raw: unknown): { body: unknown; force: boolean; reconnect: boolean } {
112
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('force' in raw || 'reason' in raw)) return { body: raw, force: false, reconnect: false }
113
+ const { force, reason, ...rest } = raw as Record<string, unknown>
114
+ if (force !== undefined && typeof force !== 'boolean') throw new TodayBriefInputError('invalid_today_brief')
115
+ if (reason !== undefined && reason !== 'reconnect') throw new TodayBriefInputError('invalid_today_brief')
116
+ return { body: rest, force: force === true, reconnect: reason === 'reconnect' }
117
+ }
118
+
98
119
  export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
99
120
  const router = Router()
100
121
  router.use('/today/brief', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
@@ -108,18 +129,21 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
108
129
  if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > TODAY_BRIEF_BODY_MAX_BYTES) {
109
130
  return res.status(413).json({ error: { code: 'today_brief_too_large', message: 'Too much for the Today summary.' } })
110
131
  }
132
+ // 7.0.1: `force` is the refresh button's flag, taken off before the rows are read (the rows reject unknown keys).
133
+ const { body, force, reconnect } = splitForce(req.body)
111
134
  // The switch first: off reads nothing, not even the board.
112
135
  if (!deps.brief.settings().enabled) {
113
- normalizeTodayBriefInputs(req.body)
136
+ normalizeTodayBriefInputs(body)
114
137
  return res.json(deps.brief.peek())
115
138
  }
116
- // Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval): a gated ask
117
- // answers from the page's rows alone and reads neither the board nor any transcript.
118
- const pageInputs = normalizeTodayBriefInputs(req.body)
119
- const closed = deps.brief.gate()
139
+ // Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval or, forced,
140
+ // the 30 s refresh floor): a gated ask answers from the page's rows alone and reads neither the board nor any
141
+ // transcript.
142
+ const pageInputs = normalizeTodayBriefInputs(body)
143
+ const closed = deps.brief.gate({ force, reconnect })
120
144
  if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
121
145
  const work = workInProgress(await deps.board().catch(() => null))
122
- let inputs = normalizeTodayBriefInputs(req.body, work)
146
+ let inputs = normalizeTodayBriefInputs(body, work)
123
147
  const keys = subagentLookups(inputs)
124
148
  if (keys.length && deps.subagents) {
125
149
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -127,7 +151,7 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
127
151
  const readings = await Promise.race([deps.subagents(keys).catch(() => new Map<string, SubagentReading>()), late]).finally(() => clearTimeout(timer))
128
152
  inputs = withSubagents(inputs, readings, (deps.now ?? Date.now)())
129
153
  }
130
- res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs)))
154
+ res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs), { force, reconnect }))
131
155
  } catch (error) {
132
156
  if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
133
157
  console.warn(`[today-brief] ask failed (${error instanceof Error ? error.name : 'error'})`)
@@ -144,5 +168,16 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
144
168
  }
145
169
  try { res.json(deps.brief.setEnabled(body.enabled)) } catch { failed(res) }
146
170
  })
171
+ // 7.0.5 "Summaries per day" (Control Web Settings and COS Control use the same route).
172
+ router.get('/today/brief/settings/cap', (_req, res) => {
173
+ try { res.json(deps.brief.cap()) } catch { failed(res) }
174
+ })
175
+ router.put('/today/brief/settings/cap', (req, res) => {
176
+ const body = req.body
177
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'dailyCap') || typeof body.dailyCap !== 'number' || !Number.isFinite(body.dailyCap)) {
178
+ return res.status(400).json({ error: { code: 'invalid_today_brief_cap', message: 'Send { dailyCap: a number from 1 to 100 }.' } })
179
+ }
180
+ try { res.json(deps.brief.setDailyCap(body.dailyCap)) } catch { failed(res) }
181
+ })
147
182
  return router
148
183
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { Router } from 'express'
6
6
  import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
7
+ import { calendarToday, readCalendar } from '../lib/calendar-source.js'
7
8
  import { parsePrepOccurrence } from '../lib/prep-store.js'
8
9
  import type { PrepOccurrence } from '../../shared/prep.js'
9
10
 
@@ -34,7 +35,8 @@ export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined
34
35
  calendarId: pick('calendar_id', 'calendarId'),
35
36
  eventId: pick('event_id', 'eventId') ?? null,
36
37
  externalId: pick('external_id', 'externalId') ?? null,
37
- startsAt: pick('start_time', 'start', 'startsAt'),
38
+ // occurrence_start (EventKit's original occurrence date) first, so a moved occurrence keeps its draft.
39
+ startsAt: pick('occurrence_start', 'start_time', 'start', 'startsAt'),
38
40
  endsAt: pick('end_time', 'end', 'endsAt') ?? null,
39
41
  }) ?? undefined
40
42
  } catch { return undefined }
@@ -327,7 +329,8 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
327
329
  }
328
330
 
329
331
  try {
330
- const cal = await callPython(['calendar']) as {
332
+ const calRead = await readCalendar()
333
+ const cal = calRead.data as {
331
334
  today_remaining?: Array<{
332
335
  title?: string
333
336
  start_time?: string
@@ -338,10 +341,14 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
338
341
  minutes_until_next?: number
339
342
  } | null
340
343
 
341
- const read = calendarAnswerWasRead(pythonBridgeAvailable(), cal)
344
+ // The EventKit snapshot is a real read without the bridge; 'none' (denied or expired) never is.
345
+ const sourceReady = calRead.source === 'bridge' ? pythonBridgeAvailable() : calRead.source !== 'none'
346
+ const read = calendarAnswerWasRead(sourceReady, cal)
342
347
  const remaining = cal?.today_remaining ?? []
343
348
  const meaningful = remaining.find((evt) => {
344
349
  if (evt.is_all_day) return false
350
+ // Snapshot rows say whether they may become next/prep (declined, shared-calendar travel, holidays).
351
+ if ((evt as { prep_worthy?: unknown }).prep_worthy === false) return false
345
352
  return !isGenericNoiseEvent(evt.title || '')
346
353
  })
347
354
 
@@ -394,6 +401,19 @@ export function _resetWelcomeContextCachesForTests(): void {
394
401
  lastFailureLogAt.clear()
395
402
  }
396
403
 
404
+ /**
405
+ * PH plan P1: Today's calendar for Control Web and desktop. Behind the /api token gate. Selected calendars only, the
406
+ * next 3 events. `connected: false` means "Calendar not connected", never an empty day.
407
+ */
408
+ welcomeContextRouter.get('/calendar/today', async (_req, res) => {
409
+ try {
410
+ res.set('Cache-Control', 'no-store')
411
+ res.json(await calendarToday())
412
+ } catch {
413
+ res.status(503).json({ error: 'calendar_unavailable' })
414
+ }
415
+ })
416
+
397
417
  welcomeContextRouter.get('/welcome-context', async (req, res) => {
398
418
  const lat = parseCoord(req.query.lat, 'lat')
399
419
  const lon = parseCoord(req.query.lon, 'lon')
@@ -6,6 +6,7 @@ import { listBoard, listBoardWithDates, workBoardCapabilities, setTaskWorkStage,
6
6
  import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
7
7
  import type { MeetingDescriptor } from '../lib/work-review-store.js'
8
8
  import { isSafeDomainName } from '../lib/domains.js'
9
+ import { workSessionActivity, waitingSessionIds } from '../lib/work-session-activity.js'
9
10
  import { resolveSavedMeetingDetail } from './meetings.js'
10
11
  import type { MeetingDetail } from '../lib/meeting-store.js'
11
12
 
@@ -28,11 +29,13 @@ export interface WorkBoardDependencies {
28
29
  requests: () => Pick<WorkActivityCapabilities, 'requests' | 'requestsInbox' | 'requestsConsumerSeenAt'>
29
30
  /** 6.59.0: the bounded, shared board read (5 s). Defaults to one over `list`. */
30
31
  readBoard?: WorkBoardReader<TaskBoardRow>
32
+ /** 7.0.1: each linked session's newest activity (lib/work-session-activity.ts), for the open list's supersede rule. */
33
+ sessionActivity: (sessionIds: readonly string[]) => Promise<ReadonlyMap<string, number>>
31
34
  now: () => number
32
35
  }
33
36
  const defaults: WorkBoardDependencies = { journal: () => readWorkJournal(), list: listBoard, capabilities: workBoardCapabilities,
34
37
  stage: setTaskWorkStage, link: linkTaskMeeting, edit: editWorkTask, capture: captureWorkItem, captureCapabilities: workIntakeCapabilities, resolveMeeting: resolveSavedMeetingDetail,
35
- requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), now: () => Date.now() }
38
+ requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), sessionActivity: ids => workSessionActivity(ids), now: () => Date.now() }
36
39
  /** How old a board read the activity and open-work glances accept (the reader shares one read between them). */
37
40
  export const WORK_GLANCE_BOARD_AGE_MS = 10_000
38
41
 
@@ -82,7 +85,11 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
82
85
  if (!journal.available) return res.json({ version: 1, available: false, reason: journal.reason, capabilities, items: [], total: 0, truncated: false })
83
86
  const rows = await glanceBoard('open work')
84
87
  if (!rows) return res.json({ version: 1, available: false, reason: 'Work board is unavailable. Refresh before trying again.', capabilities, items: [], total: 0, truncated: false })
85
- return res.json({ version: 1, available: true, capabilities, ...projectOpenWork(journal, rows, deps.now()) })
88
+ // 7.0.1: a waiting item whose session moved on since its report is not waiting. Only those sessions are read; a
89
+ // read that fails keeps the list as it was.
90
+ const now = deps.now(), first = projectOpenWork(journal, rows, now), waiting = waitingSessionIds(first.items)
91
+ const sessionActivityMs = waiting.length ? await deps.sessionActivity(waiting).catch(() => undefined) : undefined
92
+ return res.json({ version: 1, available: true, capabilities, ...(sessionActivityMs?.size ? projectOpenWork(journal, rows, now, { sessionActivityMs }) : first) })
86
93
  })
87
94
  router.get('/work-board', async (_req, res) => {
88
95
  try {
@@ -186,20 +186,26 @@ select.field {
186
186
  padding: 4px calc(var(--gutter) + var(--safe-right)) calc(var(--safe-bottom) + 96px) calc(var(--gutter) + var(--safe-left));
187
187
  }
188
188
  .pane { max-width: 880px; }
189
+ /* 7.0.2 (A2, the Duo audit): the COS chat route ends at its composer (panes/messages.js sets ws-chat). The 96 px above
190
+ (and the 40 px from 600 px up) held room for a bottom bar that is gone; under the sticky composer it was blank page
191
+ that a focused box scrolled to and the closed keyboard left on screen. Two columns from 860 px keep a small margin. */
192
+ .workspace.ws-chat { padding-bottom: 0; }
193
+ @media (min-width: 860px) { .workspace.ws-chat { padding-bottom: calc(var(--safe-bottom) + 16px); } }
189
194
  .pane-head { margin: 8px 0 18px; }
190
195
  .pane-title { font: 500 40px/1.02 var(--display); letter-spacing: -0.015em; margin: 0; }
191
196
  .pane-sub { color: var(--sub); margin: 6px 0 0; font-size: 15px; }
192
197
 
193
- /* No bottom bar, on purpose. On the iPhone Duo, Safari owns the bottom edge on both screens
194
- (a floating address pill on the closed screen), and iOS 26 WebKit stops drawing the page under
198
+ /* No bottom bar, on purpose. On a phone the browser owns the bottom edge (Safari's floating
199
+ address pill, Comet's and Chrome's own bars), and iOS 26 WebKit stops drawing the page under
195
200
  its bar while any fixed element sits within ~60px of it. Navigation lives in the top bar
196
- (closed screen) or the left rail (open screen and up). */
201
+ (folded screen) or the left rail (unfolded screen and up). */
197
202
  .badge-dot { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--hot); }
198
203
 
199
204
  .rail { display: none; }
200
205
 
201
206
  /* ── Today ───────────────────────────────────────────────── */
202
207
  .today-date { color: var(--sub); font-size: 15px; margin: 6px 0 0; }
208
+ .today-weather { color: var(--sub); font-size: 15px; margin: 2px 0 0; }
203
209
  /* The session pet on Today: the pet COS Control shows, in the pose the Mac would pick. */
204
210
  .today-pet { display: flex; flex-direction: column; align-items: center; margin: 6px 0 14px; min-height: 166px; }
205
211
  .today-pet.hidden { display: none; }
@@ -287,6 +293,11 @@ select.field {
287
293
  .next-up .when { font: 500 24px/1 var(--display); white-space: nowrap; }
288
294
  .next-up .what { font-weight: 600; }
289
295
  .next-up .sub { color: var(--sub); font-size: 14px; }
296
+ .next-later { padding: 0 16px 14px; font-size: 14px; }
297
+ .next-later .later-row { display: flex; gap: 12px; padding: 3px 0; min-width: 0; }
298
+ .next-later .later-when { color: var(--sub); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 4.5em; }
299
+ .next-later .later-what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
300
+ .next-later .later-asof { color: var(--sub); margin-top: 4px; }
290
301
 
291
302
  /* ── Lists (sessions, meetings, messages) ────────────────── */
292
303
  .list { background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); }
@@ -421,11 +432,13 @@ select.field {
421
432
  .pane:not(#pane-today) .pane-head .pane-sub { margin-top: 0; }
422
433
  }
423
434
 
424
- /* ── Short screens: the closed Duo is ~678px tall with ~76px under Safari's pill, the open
425
- Duo ~626px. Height is the scarce axis, so the type steps down here, not by width. */
435
+ /* ── Short screens: the folded Duo is about 382x678 with the browser's own bar under that, and
436
+ landscape tablets and laptop windows are often under 720px tall. Height is the scarce axis, so
437
+ the type steps down here, not by width. */
426
438
  @media (max-height: 720px) {
427
439
  .pane-title { font-size: 34px; }
428
440
  .today-date { font-size: 14px; margin-top: 4px; }
441
+ .today-weather { font-size: 14px; }
429
442
  .ask-bar { min-height: 46px; margin-top: 12px; }
430
443
  .story { font-size: 21px; line-height: 1.3; margin: 18px 2px; }
431
444
  .run-panel { padding-top: 8px; }
@@ -436,8 +449,8 @@ select.field {
436
449
  .rec-banner { margin-top: 10px; padding: 8px 8px 8px 14px; }
437
450
  }
438
451
 
439
- /* ── Open Duo (~890x626), tablets, desktop: a labelled left rail costs width, which is the
440
- axis there is plenty of. */
452
+ /* ── Unfolded Duo (about 1430x1000 landscape, 1000x1430 portrait), tablets, desktop: a labelled
453
+ left rail costs width, which is the axis there is plenty of. */
441
454
  @media (min-width: 600px) {
442
455
  :root { --gutter: 24px; }
443
456
  .topbar { display: none; }
@@ -468,11 +481,11 @@ select.field {
468
481
  .run-title, .run-meta { grid-column: 2; }
469
482
  .composer { position: sticky; bottom: calc(var(--safe-bottom) + 12px); }
470
483
  .toast { top: auto; bottom: 24px; }
471
- .sheet-backdrop { padding-top: 8vh; }
484
+ .sheet-backdrop { padding-top: 8vh; padding-top: 8dvh; }
472
485
  .sheet { max-width: 620px; border-radius: 18px; padding: 16px 18px 18px; max-height: 80dvh; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25); }
473
486
  }
474
487
 
475
- /* Open Duo short screen: keep the rail's nine sections inside ~626px. */
488
+ /* Wide but short (a landscape tablet, a short window): keep the rail's nine sections inside 720px. */
476
489
  @media (min-width: 600px) and (max-height: 720px) {
477
490
  .pane:not(#pane-today) .pane-head .pane-title { display: none; }
478
491
  .pane:not(#pane-today) .pane-head { margin: 0 0 12px; }
@@ -504,6 +517,28 @@ select.field {
504
517
  .pane.reading .pane-head.with-ask { display: flex; }
505
518
  }
506
519
 
520
+ /* Duo tables: the divider between a list and its reader (app.js splitKit, W.splitKit), for Memories and Messages.
521
+ Hidden everywhere; each pane shows it only inside its own two-column breakpoint (panes/memories.css 760 px,
522
+ panes/cos-chat.css 860 px), so a phone never sees it. Sessions keeps its own copy of these rules (panes/sessions.css). */
523
+ .split-handle, .split-full-btn { display: none; }
524
+ .split-handle {
525
+ justify-content: center; align-items: center; position: sticky; top: calc(var(--safe-top) + 12px); align-self: start;
526
+ height: calc(100dvh - var(--safe-top) - 24px); cursor: col-resize; touch-action: none; user-select: none; -webkit-user-select: none;
527
+ -webkit-touch-callout: none; outline: none; z-index: 2;
528
+ }
529
+ /* The line is the split's own gap; the grip shows where to take it. */
530
+ .split-handle::after { content: ''; width: 4px; height: 36px; border-radius: 2px; background: var(--panel-border); transition: background 120ms ease, height 120ms ease; }
531
+ .split-handle:hover::after, .split-handle:focus-visible::after, .split-dragging > .split-handle::after { background: var(--accent); height: 52px; }
532
+ .split-handle:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 6px; }
533
+ /* A finger gets at least a 44 px target, 13 px over each pane's edge; a mouse keeps the divider's own column. */
534
+ @media (pointer: coarse) { .split-handle::before { content: ''; position: absolute; top: 0; bottom: 0; left: -13px; right: -13px; } }
535
+ /* While dragging, nothing in the panes selects or takes the pointer. */
536
+ .split-dragging { user-select: none; -webkit-user-select: none; cursor: col-resize; }
537
+ .split-dragging > :not(.split-handle) { pointer-events: none; }
538
+ .split-full-btn { margin-left: auto; flex: none; }
539
+ @media (pointer: coarse) { .split-full-btn { min-height: 44px; } }
540
+ @media (prefers-reduced-motion: reduce) { .split-handle::after { transition: none; } }
541
+
507
542
  /* ── Desktop ─────────────────────────────────────────────── */
508
543
  @media (min-width: 1100px) and (min-height: 721px) {
509
544
  :root { --gutter: 40px; }
@@ -538,3 +573,38 @@ select.field {
538
573
  .live-prose { margin-top: 8px; font-size: 15px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 14em; overflow: auto; color: var(--run-title); }
539
574
  #session-status .link-btn { min-height: 0; padding: 0; }
540
575
  .turn.user.pending { opacity: 0.72; }
576
+
577
+ /* ── Touch screens (6.99.30) ────────────────────────────── */
578
+ /* Every field that types is 16 px on a touch screen, at every width: under 16 px a phone browser zooms the page in on
579
+ focus (the unfolded Duo did it on Sessions' selects, Search, Find and the model picker, 600 px and up). A floor, not
580
+ a size: no field anywhere is set above 16 px. !important because each pane sets its own compact size with more
581
+ specific selectors; this one rule beats them all only on touch. */
582
+ @media (any-pointer: coarse) {
583
+ input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']):not([type='file']):not([type='button']):not([type='submit']):not([type='reset']):not([type='image']):not([type='hidden']),
584
+ select, textarea { font-size: 16px !important; }
585
+ }
586
+ /* Folded phones: every button and field is at least 44 px tall (design QA: .cx-btn came in six heights from 30 px). A
587
+ minimum only, so it grows the compact ones and leaves the taller ones; the per-pane phone heights that fought it are
588
+ gone. */
589
+ /* 7.0.2 (A8): a short touch screen at any width too (the unfolded Duo, 951x539 in Comet, is touch first). */
590
+ @media (max-width: 599px), (pointer: coarse) and (max-height: 720px) {
591
+ .cx-btn, .field { min-height: 44px !important; }
592
+ }
593
+ /* Touch screens: the small controls the canary measured under 44 px (nav 30, switches 30, a chip's remove and Retry 36,
594
+ camera and clip 38, Send 38, the question strip's steps 36, the calendar's days 34) reach 44. Floors only (min-*),
595
+ !important because each pane sizes its own with more specific rules. A text switch keeps its look and grows only its
596
+ hit area, so its gold rule stays under the word. */
597
+ @media (any-pointer: coarse) {
598
+ .nav-btn, .btn.small, .cc-chip-retry, .sq-step, .mt-day { min-height: 44px !important; }
599
+ .cc-chip-x, .cc-attach, .cc-camera { min-width: 44px !important; min-height: 44px !important; }
600
+ .cx-switch button::before { content: ''; position: absolute; inset: -7px -4px; }
601
+ }
602
+ /* 7.0.2 (A8): a short touch screen 600 px wide or more (the unfolded Duo, 951x539) keeps the phone Ask sheet: full width
603
+ from the top, no centred desktop card with 8 % of a 539 px screen spent above it (app.js isPhone says phone too). */
604
+ @media (min-width: 600px) and (pointer: coarse) and (max-height: 720px) {
605
+ .sheet-backdrop { padding-top: 0; }
606
+ .sheet { max-width: none; border-radius: 0 0 20px 20px; padding: calc(var(--safe-top) + 10px) var(--gutter) 14px; max-height: calc(100dvh - 90px); box-shadow: none; }
607
+ }
608
+ /* 6.99.30 (QA): an element with the hidden attribute is hidden, whatever display its classes give it ("Retry image"
609
+ showed under loaded images: .link-btn's inline-flex beat [hidden]). */
610
+ [hidden] { display: none !important; }