@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
@@ -79,13 +79,22 @@ import {
79
79
  } from '../lib/occupancy-probes.js'
80
80
  import type { OccupancyDirs } from '../lib/thread-occupancy.js'
81
81
  import { parseTurnsParam, readRecentSessionTurns, type RecentTurnsRead } from '../lib/agent-session-turns.js'
82
+ import { redactLocalPathsDeep } from '../lib/local-path-redact.js'
83
+ import { isNativeMacCaller } from '../lib/native-mac-caller.js'
82
84
  import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
83
85
  import { sessionActFactsFor, sessionActFields } from '../lib/session-act-fields.js'
84
86
  import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
85
87
 
86
- import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
88
+ import { agentSessionOutputImages, sharedFileHeaders } from '../lib/agent-session-output-images.js'
89
+ import { previewErrorCode } from '../lib/preview-error-code.js'
90
+ import { resetPreviewLogThrottle, warnPreviewThrottled } from '../lib/preview-log-throttle.js'
91
+ import { sessionFilesSetting, withoutPreviews } from '../lib/session-files-setting.js'
92
+ import { withoutLinkMarkers } from '../lib/session-links.js'
93
+ import { createSessionPreviewRouter } from './session-preview.js'
87
94
 
88
95
  export const agentSessionsRouter = Router()
96
+ // Preview engine phase 1: GET /api/preview/agent-sessions/... and /api/preview-folders/settings (routes/session-preview.ts).
97
+ agentSessionsRouter.use(createSessionPreviewRouter())
89
98
 
90
99
  function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
91
100
  const parsed = Number(value)
@@ -567,7 +576,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
567
576
  const queuedOf = await allQueuedWaitingLookup(now)
568
577
  const codexRoots = codexSessionRootsOf(roots)
569
578
  const subagents = await readCodexSubagentsAcross(sessions.filter(row => row.provider === 'codex').map(row => row.session_id), codexRoots, now, codexRoots.length > 1 ? keyedCodexRootOf() : undefined)
570
- res.json({
579
+ // 7.0.4 S1: no absolute Mac path in any string a list row carries (titles, first prompts, summaries).
580
+ res.json(redactLocalPathsDeep({
571
581
  sessions: sessions.map((row, index) => {
572
582
  const derived = derivedById.get(row.session_id)
573
583
  const signal = signalFor(row.session_id)
@@ -592,7 +602,7 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
592
602
  // client ignores this key. Zero means the walk found nothing to hide, not
593
603
  // that the caps are off.
594
604
  dropped,
595
- })
605
+ }))
596
606
  } catch (error) {
597
607
  console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
598
608
  res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
@@ -631,30 +641,59 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
631
641
  remember: false,
632
642
  })
633
643
  }
634
- res.json({
644
+ // 7.0.4 S1: search hits carry snippets of the same words.
645
+ res.json(redactLocalPathsDeep({
635
646
  ...result,
636
647
  hits: result.hits.map(hit => toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit))),
637
- })
648
+ }))
638
649
  } catch (error) {
639
650
  console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
640
651
  res.status(500).json({ error: 'Failed to search agent sessions', reason: 'agent_sessions_search_failed' })
641
652
  }
642
653
  })
643
654
 
655
+ /** 7.0.4 S2: the 403 every local-file route answers for a file whose name says it may hold secrets. */
656
+ export const SECRET_FILE_DENIED = 'secret_file_denied'
657
+ /** 7.0.4 QA: the 503 a preview answers when the Mac is already reading its limit of previews (not unreachable). */
658
+ export const PREVIEWS_BUSY = 'previews_busy'
659
+
660
+ type PreviewKind = 'output_image' | 'tool_image' | 'sent_file'
661
+ /**
662
+ * 7.0.4 QA: every preview refusal and failure is logged with its kind and a reason code, never a path or a session's
663
+ * words. A burst of the same reason (a page of thumbnails while the reader is full) logs once per 10 seconds, with the
664
+ * count it held back (lib/preview-log-throttle.ts, shared with the preview engine routes since 7.0.7).
665
+ */
666
+ export function logPreviewOutcome(kind: PreviewKind, reason: string, now = Date.now()): void {
667
+ warnPreviewThrottled(kind + ':' + reason, `[agent-sessions] preview ${kind} refused reason=${reason}`, now)
668
+ }
669
+ /** Tests only: forget the burst window, so each case logs. */
670
+ export { resetPreviewLogThrottle }
671
+ /** The answer for a preview read that did not succeed, logged (a missing id is ordinary and is not). */
672
+ function previewRefusal(res: import('express').Response, kind: PreviewKind, status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'busy'): void {
673
+ const noun = kind === 'sent_file' ? 'file' : 'image'
674
+ if (status !== 'not_found') logPreviewOutcome(kind, status === 'secret' ? SECRET_FILE_DENIED : status)
675
+ if (status === 'secret') { res.status(403).json({ error: SECRET_FILE_DENIED }); return }
676
+ if (status === 'not_found') { res.status(404).json({ error: `${noun}_not_found` }); return }
677
+ if (status === 'too_large') { res.status(413).json({ error: `${noun}_too_large` }); return }
678
+ if (status === 'busy') { res.set('Retry-After', '2'); res.status(503).json({ error: PREVIEWS_BUSY }); return }
679
+ res.status(503).json({ error: `${noun}_unavailable` })
680
+ }
681
+
644
682
  // Authenticated by index.ts's shared /api gate. Neither the request nor the response
645
683
  // carries a filesystem path; the resolver reauthorizes exact transcript membership.
646
684
  agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-images/:imageId/content', async (req, res) => {
647
685
  res.set('Cache-Control', 'private, no-store')
648
686
  res.set('X-Content-Type-Options', 'nosniff')
687
+ // 6.99.35: "Show files and images your sessions make" off answers before anything is read.
688
+ if (!sessionFilesSetting.read().enabled) { res.status(403).json({ error: 'previews_off' }); return }
649
689
  const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
650
690
  if (!provider || (req.query.variant !== undefined && req.query.variant !== 'thumb' && req.query.variant !== 'phone')) {
651
691
  res.status(404).json({ error: 'image_not_found' }); return
652
692
  }
653
693
  try {
654
694
  const content = await agentSessionOutputImages.content(provider, String(req.params.sessionId), String(req.params.imageId), req.query.variant === 'thumb' ? 'thumb' : 'phone')
655
- if (content.status !== 'ok') {
656
- res.status(content.status === 'not_found' ? 404 : 503).json({ error: content.status === 'not_found' ? 'image_not_found' : 'image_unavailable' }); return
657
- }
695
+ // 7.0.4 S2: the id was authorized, and the file's name says it may hold secrets (lib/secret-path-guard.ts).
696
+ if (content.status !== 'ok') { previewRefusal(res, 'output_image', content.status === 'expired' ? 'unavailable' : content.status); return }
658
697
  res.type('image/jpeg')
659
698
  // `send` defaults to `dotfiles: 'ignore'` and applies it to the WHOLE absolute path
660
699
  // when no `root` is given, so a media root under `~/.cos-glasses/data` 404'd on
@@ -666,8 +705,77 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-images/:ima
666
705
  if (!res.headersSent) res.status(503).json({ error: 'image_unavailable' })
667
706
  else res.destroy()
668
707
  })
669
- } catch {
670
- console.warn('[agent-sessions] output image unavailable')
708
+ } catch (error) {
709
+ logPreviewOutcome('output_image', 'error_' + previewErrorCode(error))
710
+ res.status(503).json({ error: 'image_unavailable' })
711
+ }
712
+ })
713
+
714
+ // 6.99.35: "Show files and images your sessions make" (lib/session-files-setting.ts), Control Web Settings, Privacy.
715
+ // Default on; COS_WEB_SESSION_FILES=0 on the Mac forces it off and the answer says so (source: 'env').
716
+ agentSessionsRouter.get('/session-files/settings', (_req, res) => {
717
+ res.set('Cache-Control', 'private, no-store')
718
+ try { res.json(sessionFilesSetting.read()) } catch { res.status(500).json({ error: 'session_files_settings_unavailable' }) }
719
+ })
720
+ agentSessionsRouter.post('/session-files/settings', (req, res) => {
721
+ const body = req.body
722
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'enabled') || typeof body.enabled !== 'boolean') {
723
+ res.status(400).json({ error: { code: 'invalid_session_files_settings', message: 'Send { enabled: true } or { enabled: false }.' } }); return
724
+ }
725
+ try { res.json(sessionFilesSetting.set(body.enabled)) } catch { res.status(500).json({ error: 'session_files_settings_unavailable' }) }
726
+ })
727
+
728
+ // 6.99.35: a file a Claude session sent with SendUserFile. Same gate, same re-authorization, no path
729
+ // in the request or the response. HTML is an attachment of type octet-stream with a sandbox CSP, never
730
+ // same-origin HTML; a PDF may open inline.
731
+ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-files/:fileId/content', async (req, res) => {
732
+ res.set('Cache-Control', 'private, no-store')
733
+ res.set('X-Content-Type-Options', 'nosniff')
734
+ if (!sessionFilesSetting.read().enabled) { res.status(403).json({ error: 'previews_off' }); return }
735
+ const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
736
+ if (!provider) { res.status(404).json({ error: 'file_not_found' }); return }
737
+ try {
738
+ const content = await agentSessionOutputImages.fileContent(provider, String(req.params.sessionId), String(req.params.fileId))
739
+ if (content.status !== 'ok') { previewRefusal(res, 'sent_file', content.status); return }
740
+ res.set(sharedFileHeaders(content.name, content.kind))
741
+ res.status(200).end(content.bytes)
742
+ } catch (error) {
743
+ logPreviewOutcome('sent_file', 'error_' + previewErrorCode(error))
744
+ res.status(503).json({ error: 'file_unavailable' })
745
+ }
746
+ })
747
+
748
+ // Images a session looked at (tool results such as a Read of a PNG or a browser screenshot) and images attached
749
+ // to its prompts. Same gate and switch as output images, same rule: no path in the request or the response. Embedded
750
+ // bytes are served as the image type they ARE (png, jpeg, webp or gif, sniffed, never the declared type), with a
751
+ // sandbox CSP; a path-only Read comes back through the output-image normalizer as a JPEG.
752
+ export const TOOL_IMAGE_CSP = "sandbox; default-src 'none'"
753
+ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/tool-images/:imageId/content', async (req, res) => {
754
+ res.set('Cache-Control', 'private, no-store')
755
+ res.set('X-Content-Type-Options', 'nosniff')
756
+ if (!sessionFilesSetting.read().enabled) { res.status(403).json({ error: 'previews_off' }); return }
757
+ const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
758
+ if (!provider) { res.status(404).json({ error: 'image_not_found' }); return }
759
+ try {
760
+ const content = await agentSessionOutputImages.toolImageContent(provider, String(req.params.sessionId), String(req.params.imageId))
761
+ if (content.status !== 'ok') { previewRefusal(res, 'tool_image', content.status); return }
762
+ res.set('Content-Security-Policy', TOOL_IMAGE_CSP)
763
+ res.set('Cross-Origin-Resource-Policy', 'same-origin')
764
+ if ('path' in content) {
765
+ res.type('image/jpeg')
766
+ // dotfiles 'allow': the media root lives under ~/.cos-glasses/data (see the output-images route above).
767
+ res.sendFile(content.path, { dotfiles: 'allow' }, err => {
768
+ if (!err) return
769
+ if (!res.headersSent) res.status(503).json({ error: 'image_unavailable' })
770
+ else res.destroy()
771
+ })
772
+ return
773
+ }
774
+ res.set('Content-Type', content.type)
775
+ res.set('Content-Disposition', 'inline')
776
+ res.status(200).end(content.bytes)
777
+ } catch (error) {
778
+ logPreviewOutcome('tool_image', 'error_' + previewErrorCode(error))
671
779
  res.status(503).json({ error: 'image_unavailable' })
672
780
  }
673
781
  })
@@ -717,6 +825,8 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
717
825
  try {
718
826
  const images = await agentSessionOutputImages.context(provider, sessionId, found)
719
827
  recentTurns = await readRecentSessionTurns(provider, found, turnsWanted, undefined, images.project)
828
+ // 6.99.35: previews off sends one mark per message instead of image and file names.
829
+ if (recentTurns && !sessionFilesSetting.read().enabled) recentTurns = { ...recentTurns, turns: withoutPreviews(recentTurns.turns) }
720
830
  } catch (error) {
721
831
  console.warn(`[agent-sessions] recent turns failed provider=${provider}: ${error instanceof Error ? error.message : error}`)
722
832
  }
@@ -754,7 +864,10 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
754
864
  }
755
865
  const signal = signalFor(parsed.session_id)
756
866
  const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now, activityClockMs(st.mtimeMs, activity))
757
- res.json({
867
+ // 7.0.4 S1: every string the detail sends (the reply, the digest, the first prompt, each turn's words, tool
868
+ // labels, file and image names) has its absolute local paths turned into base names (lib/local-path-redact.ts).
869
+ // A walk over the whole value rather than a field list, so a field added later is covered too.
870
+ const body = redactLocalPathsDeep({
758
871
  ...withRunning({ session_id: parsed.session_id }, running, hint),
759
872
  ...derivedRowFields(derived),
760
873
  ...queuedTurnsFields((await allQueuedWaitingLookup(Date.now()))(provider, parsed.session_id)),
@@ -816,6 +929,28 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
816
929
  noteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
817
930
  }))[0]),
818
931
  })
932
+ // 7.0.4 W1: COS Control's helper on this Mac matches a Work prompt it sent against the session's user turns by
933
+ // their opening words, and it holds the prompt unredacted. Only the user turns' words in `recent_turns` (the one
934
+ // field it matches on) are sent as written, and only to that privileged local caller: a direct loopback request
935
+ // that passed the header-token gate with no browser or proxy header (lib/native-mac-caller.ts). Every browser
936
+ // (cookie or token), phone, LAN, tailnet and proxied request gets the redacted body. The answer is already
937
+ // `private, no-store`; Vary names every header the choice reads, so no cache can serve one as the other.
938
+ if (turnsWanted !== null) res.vary('Cookie').vary('X-COS-Token').vary('Origin').vary('Referer').vary('User-Agent').vary('Sec-Fetch-Mode').vary('Sec-Fetch-Site').vary('Sec-Fetch-Dest')
939
+ // 7.0.7 final QA: link-card markers (`[words](cos-file:N)`) go only to a client that asks for them with `cards=1`
940
+ // (Control Web from 7.0.7: the reader and Today's session sheet). Everyone else gets each card's words, as 7.0.4
941
+ // showed them: a Control Web tab opened before the update (it has no reload-on-version), COS Control (no build
942
+ // renders markers; its own cards come from outputLinks), the lens and any older client. outputLinks is unchanged.
943
+ const native = recentTurns && Array.isArray(body.recent_turns) ? isNativeMacCaller(req, res) : false
944
+ const cardsWanted = req.query.cards === '1'
945
+ // The native helper always gets words, whatever it asks for (no Control build draws markers).
946
+ if (recentTurns && Array.isArray(body.recent_turns) && (native || !cardsWanted)) {
947
+ body.recent_turns = body.recent_turns.map((turn, index) => {
948
+ const original = recentTurns!.turns[index]
949
+ if (native && original && original.role === 'user' && turn.role === 'user') return { ...turn, text: original.text }
950
+ return typeof turn.text === 'string' && turn.text.includes('](cos-file:') ? { ...turn, text: withoutLinkMarkers(turn.text) } : turn
951
+ })
952
+ }
953
+ res.json(body)
819
954
  } catch (error) {
820
955
  console.error(`[agent-sessions] detail failed: ${error instanceof Error ? error.message : error}`)
821
956
  res.status(500).json({ error: 'Failed to read agent session', reason: 'agent_sessions_read_failed' })
@@ -99,8 +99,11 @@ export function createAskWorkPlanRouter(given: Partial<AskWorkPlanDeps> = {}) {
99
99
  model: output.model ?? `claude:${planner} (requested)`, effort })
100
100
  } catch (error) {
101
101
  if (error instanceof AskProcessError && error.terminationUnconfirmed) {
102
- // Match Meeting Ask's orphan policy: no second process or maintenance drain until recovery.
102
+ // No second process or maintenance drain until the runner proves that the
103
+ // original child and group have terminated. Missing/failed proof stays held.
103
104
  release = false
105
+ const heldLease = lease
106
+ void error.terminationConfirmed?.then(() => { heldLease?.release(); running = false }).catch(() => { /* remain fail-closed */ })
104
107
  console.warn('[ask-work-plan] provider termination unconfirmed; admission and maintenance lease retained')
105
108
  fail(new AskWorkPlanError('provider_termination_unconfirmed', 503))
106
109
  } else fail(error)
@@ -95,11 +95,15 @@ export async function readClaudePeerRecords(
95
95
  dir: string,
96
96
  probes: PeerProbes = realProbes,
97
97
  showNames = claudeSessionNamesVisible(),
98
+ /** 7.0.1 live events: a folder that exists but cannot be read (permissions, a transient error) throws instead of
99
+ * answering "nothing alive", so the caller keeps its last good reading. A missing folder is still empty. */
100
+ throwWhenUnreadable = false,
98
101
  ): Promise<ClaudePeerRecord[]> {
99
102
  let names: string[]
100
103
  try {
101
104
  names = await readdir(dir)
102
- } catch {
105
+ } catch (error) {
106
+ if (throwWhenUnreadable && (error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
103
107
  // No directory means Claude Code has never run here, or the path is wrong.
104
108
  // An empty presence list is the honest answer; the route reports `enabled`
105
109
  // separately so this cannot be mistaken for "the feature is off".
@@ -18,8 +18,9 @@
18
18
  * POST /drops/:id/move { dest, target?: { id, label, domain? } } DropAnswer
19
19
  * POST /drops/:id/ask { clientJobId, generation } DropAnswer
20
20
  * POST /drops/:id/sent DropAnswer
21
- * GET /inbox/summary { unread, waiting, lastLine, lastAt, version }
21
+ * GET /inbox/summary { unread, waiting, asked, yourMove, lastLine, lastAt, version }
22
22
  * GET /inbox/feed ?after&limit { items, version, more }
23
+ * GET /inbox/archive { items, total, waitingExpiryDays, version } (7.0.9)
23
24
  * POST /inbox/read { through } { unread, waiting, version }
24
25
  * POST /inbox/action { itemId, action: 'choose:<id>' | 'undo' } DropAnswer
25
26
  * POST /inbox/action { itemId, action: 'dismiss' } { unread, waiting, version }
@@ -211,9 +212,13 @@ export function createDropsRouter(deps: DropsRouterDependencies): Router {
211
212
  try { await service.syncAsks() } catch { /* the chat still reads */ }
212
213
  try { return res.json(service.feed({ after, limit })) } catch (e) { return send(res, e) }
213
214
  }))
215
+ // 7.0.9: the older questions (asked WAITING_EXPIRY_DAYS or more ago and still unanswered), newest first.
216
+ router.get('/inbox/archive', (_req, res) => withService(res, async service => {
217
+ try { return res.json(service.archive()) } catch (e) { return send(res, e) }
218
+ }))
214
219
  router.post('/inbox/read', (req, res) => withService(res, async service => {
215
220
  if (!isRecord(req.body) || typeof req.body.through !== 'string') return bad(res, 'invalid_read')
216
- let out: { unread: number; waiting: number; version: number }
221
+ let out: { unread: number; waiting: number; asked: number; version: number }
217
222
  try { out = service.markRead(req.body.through) } catch (e) { return send(res, e) }
218
223
  // The same `waiting` the summary gives, Work's Your move included.
219
224
  const yourMove = await boundedYourMove(deps.yourMove)
@@ -42,5 +42,40 @@ export function createG2TurnsRouter(deps: {
42
42
  res.status(500).json({ found: false, error: 'lookup_failed' })
43
43
  }
44
44
  })
45
+ // 6.99.31 (plan §4.5): POST /api/g2-turns/:turnId/consume {session, provider}. One server-enforced
46
+ // winner per web-key authorization (keys turn:<turnId> and web:<aud>:<authId>, one write), so two receiving sessions
47
+ // cannot both act on one signed message. X-Cos-Token only. A token holder can pre-spend: that is
48
+ // denial of service, not forgery. Not a route while COS_WEB_KEY_AUTHORITY is off.
49
+ router.post('/g2-turns/:turnId/consume', async (req, res, next) => {
50
+ const authority = deps.provenance.webKey
51
+ if (!authority || !authority.enabled()) return next()
52
+ res.set('Cache-Control', 'private, no-store')
53
+ let expected = ''
54
+ try { expected = deps.apiToken ? deps.apiToken() : '' } catch { expected = '' }
55
+ if (!expected || !timingSafeTokenEqual(req.headers['x-cos-token'], expected)) return void res.status(401).json({ consumed: false, error: 'token_required' })
56
+ const turnId = String(req.params.turnId ?? '')
57
+ if (!G2_TURN_ID_RE.test(turnId)) return void res.status(400).json({ consumed: false, error: 'invalid_turn_id' })
58
+ const b = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>
59
+ try {
60
+ const answer = await describeG2Turn(deps.provenance, turnId, now())
61
+ if (!answer) return void res.status(404).json({ consumed: false, turnId, error: 'not_found' })
62
+ if (answer.authority !== 'web-key' || answer.verifiedOrigin !== true || typeof answer.authId !== 'string') {
63
+ return void res.status(409).json({ consumed: false, turnId, error: 'not_web_key_verified', reason: answer.reason ?? null })
64
+ }
65
+ const target = answer.target as { provider?: string; sessionId?: string } | undefined
66
+ if (typeof b.session !== 'string' || typeof b.provider !== 'string' || target?.sessionId !== b.session || target?.provider !== b.provider) {
67
+ return void res.status(409).json({ consumed: false, turnId, error: 'destination_mismatch' })
68
+ }
69
+ // Review B3: one namespace. The turn id and the web authorization are spent in ONE write.
70
+ const keys = Array.isArray(answer.consumeKeys) ? (answer.consumeKeys as unknown[]).filter((k): k is string => typeof k === 'string') : []
71
+ if (keys.length !== 2) return void res.status(409).json({ consumed: false, turnId, error: 'not_web_key_verified' })
72
+ const spent = authority.nonces.spend(keys, b.session, b.provider)
73
+ if (!spent.ok) return void res.status(spent.reason === 'already_consumed' ? 409 : 503).json({ consumed: false, turnId, error: spent.reason })
74
+ res.json({ consumed: true, turnId, authId: answer.authId, keys })
75
+ } catch (error) {
76
+ console.error(`[g2-turns] consume failed: ${error instanceof Error ? error.message : error}`)
77
+ res.status(500).json({ consumed: false, error: 'consume_failed' })
78
+ }
79
+ })
45
80
  return router
46
81
  }
@@ -1,4 +1,6 @@
1
+ import { API_AUTH_BY_LOCAL } from '../lib/api-auth.js'
1
2
  import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
3
+ import { calendarStatus } from '../lib/calendar-source.js'
2
4
  import { TURN_FILE_MAX_BYTES } from '../lib/turn-file-store.js'
3
5
  import { dropItCapability } from '../lib/drop-settings.js'
4
6
  import { PREP_CAPABILITY } from '../../shared/prep.js'
@@ -9,6 +11,7 @@ import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapa
9
11
  import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
10
12
  import { taskBridgeAvailable } from '../lib/task-bridge.js'
11
13
  import { Router } from 'express'
14
+ import { webKeyAuthorityEnabled } from '../lib/web-key-authority.js'
12
15
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
13
16
  import { claudeSessionsEnabled } from './claude-sessions.js'
14
17
  import { statSync } from 'node:fs'
@@ -56,6 +59,10 @@ import {
56
59
  isOllamaProviderReady,
57
60
  } from '../lib/ollama-catalog.js'
58
61
  import { isMediaProcessingReady } from '../lib/image-safety.js'
62
+ import { DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
63
+ import { closedEnvDefaultModel } from '../lib/key-profile-routing.js'
64
+ import { getSessionBinding, getSessionModel } from '../lib/conversation.js'
65
+ import { isProfilePreference } from '../../shared/key-preference.js'
59
66
  import {
60
67
  MAX_OTHER_MEDIA_BYTES,
61
68
  MAX_VIDEO_MEDIA_BYTES,
@@ -455,6 +462,9 @@ healthRouter.get('/health', async (_req, res) => {
455
462
  // 6.99.27: a session turn also takes ANY file up to this size (lib/turn-file-store.ts), sent to /api/media/file with
456
463
  // X-COS-Purpose: session-turn. 0 with attach off; absent on an older server, which takes only what the media store reads.
457
464
  threadTurnFileBytes: threadAttach.enabled ? TURN_FILE_MAX_BYTES : 0,
465
+ // 6.99.31 (Phase 3): the phone key signer exists (COS_WEB_KEY_AUTHORITY on), so Control Web may offer
466
+ // "Approve on this phone". Absent with the flag off: the composer is exactly 6.99.29's.
467
+ ...(webKeyAuthorityEnabled() ? { webKeySigner: 1 } : {}),
458
468
  ...sessionHooksHealthFields(),
459
469
  // 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
460
470
  // how many are held (that is on the authenticated questions route).
@@ -625,6 +635,32 @@ function crashLoopParkingHealth(): { enabled: boolean; parked: number } {
625
635
  }
626
636
  }
627
637
 
638
+ /**
639
+ * 7.0.2 (A7): what "Default" in a model picker resolves to, in model-router's own order
640
+ * (lib/model-router.ts, the resolvedModel line): the request's model, then the session's
641
+ * preference, then COS_G2_DEFAULT_MODEL, then DEFAULT_MODEL. A picker on "Default" sends no
642
+ * model, so the request step only names a model a caller passes in. `source` says which step won.
643
+ * A slot name only: never a key profile (closedEnvDefaultModel and getSessionModel are closed).
644
+ */
645
+ export type DefaultModelSource = 'request' | 'session' | 'profile' | 'env' | 'built-in'
646
+ /** `preference` is null only for `profile`: the session is bound to a key profile, never named here (plan 5.2). */
647
+ export function resolveDefaultModel(input: { requested?: ModelPreference | null; sessionModel?: ModelPreference | null; sessionProfile?: boolean; env?: string | undefined }): { preference: ModelPreference | null; source: DefaultModelSource } {
648
+ if (input.requested) return { preference: input.requested, source: 'request' }
649
+ if (input.sessionModel) return { preference: input.sessionModel, source: 'session' }
650
+ // model-router routes a session bound to a profile through that profile when the request names no model.
651
+ if (input.sessionProfile) return { preference: null, source: 'profile' }
652
+ const env = closedEnvDefaultModel(input.env)
653
+ if (env) return { preference: env, source: 'env' }
654
+ return { preference: DEFAULT_MODEL, source: 'built-in' }
655
+ }
656
+ // A session id is only looked up in the in-memory session map (an unknown id reads null), so nothing else is checked.
657
+ function defaultModelField(sessionQuery: unknown): { preference: ModelPreference | null; source: DefaultModelSource } {
658
+ const sid = typeof sessionQuery === 'string' && sessionQuery.length <= 128 ? sessionQuery : ''
659
+ const sessionModel = sid ? getSessionModel(sid) : null
660
+ const sessionProfile = !!sid && !sessionModel && isProfilePreference(getSessionBinding(sid))
661
+ return resolveDefaultModel({ sessionModel, sessionProfile, env: process.env.COS_G2_DEFAULT_MODEL })
662
+ }
663
+
628
664
  // Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
629
665
  // global /api middleware; ?refresh=1 forces discovery.
630
666
  healthRouter.get('/models', async (req, res) => {
@@ -653,9 +689,16 @@ healthRouter.get('/models', async (req, res) => {
653
689
  // mintDisplayTicket refuses an empty key, because a ticket signed with '' can
654
690
  // never verify. Omit the field rather than publish a dead capability.
655
691
  const apiToken = process.env.COS_API_TOKEN ?? ''
692
+ // Final review S8 (Q1 and the lead, 2026-11-10 02:20): only a caller the API token let in (the glasses, the phone app,
693
+ // COS Control) gets a display-stream ticket. A browser signed in by cookie never does: Control Web never uses one, and
694
+ // a ticket's socket is authorized once at connect, so it would outlive "Sign out every browser". Fails closed: a
695
+ // request the gate did not mark as the token door gets none.
696
+ const ticketCaller = res.locals?.[API_AUTH_BY_LOCAL] === 'token'
656
697
  res.json({
657
698
  ...catalog,
658
699
  ...threadAttachHealthFields(threadAttach),
700
+ // 7.0.2 (A7): what a picker's "Default" resolves to (?sessionId= adds that session's preference).
701
+ defaultModel: defaultModelField(req.query.sessionId),
659
702
  // 6.62.0 (plans 3.5, 3.11): the phone reads THIS surface, so the live-Continue counters
660
703
  // and the per-provider action contract are published here too, same helpers as health.
661
704
  ...continueLiveHealthFields(),
@@ -680,6 +723,8 @@ healthRouter.get('/models', async (req, res) => {
680
723
  ollamaReady: isOllamaProviderReady(),
681
724
  // Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
682
725
  ...keyProfilesModelsField(),
726
+ // PH plan P1: where the calendar comes from. Counts only, never titles; not on public /api/health.
727
+ calendar: calendarStatus(),
683
728
  serverInstanceId: getServerInstanceId(),
684
729
  // Minted here rather than on a route of its own: this response is already
685
730
  // authenticated, and the mint is stateless, so it costs nothing and stores
@@ -716,7 +761,7 @@ healthRouter.get('/models', async (req, res) => {
716
761
  // 6.8.441 lens fifteen minutes into a meeting. The TTL therefore bounds how
717
762
  // long a LEAKED URL can open a new socket, not how long an open socket lives.
718
763
  // Changing that is a breaking change that needs a client version gate first.
719
- ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
764
+ ...(apiToken && ticketCaller ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
720
765
  capabilities: {
721
766
  meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
722
767
  // 6.91.0: learnedStyle ("Your style", enabled = the server switch) and polishOptions ("Previous polish").