@gotcos/glasses-server 6.99.33 → 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 (76) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +417 -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 +40 -1
  56. package/server/web-control/app.js +1309 -56
  57. package/server/web-control/control.css +25 -0
  58. package/server/web-control/index.html +7 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +42 -3
  61. package/server/web-control/panes/cos-chat.js +267 -15
  62. package/server/web-control/panes/meetings.css +18 -2
  63. package/server/web-control/panes/meetings.js +24 -8
  64. package/server/web-control/panes/memories.css +17 -2
  65. package/server/web-control/panes/memories.js +127 -42
  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.js +28 -8
  69. package/server/web-control/panes/reader-media.js +409 -23
  70. package/server/web-control/panes/sessions.css +32 -3
  71. package/server/web-control/panes/settings.js +96 -5
  72. package/server/web-control/panes/today-actions.css +11 -0
  73. package/server/web-control/panes/today-actions.js +45 -12
  74. package/server/web-control/panes/work.css +10 -0
  75. package/server/web-control/panes/work.js +10 -2
  76. package/server/web-control/shell.js +17 -14
@@ -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".
@@ -1,4 +1,5 @@
1
1
  import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
2
+ import { calendarStatus } from '../lib/calendar-source.js'
2
3
  import { TURN_FILE_MAX_BYTES } from '../lib/turn-file-store.js'
3
4
  import { dropItCapability } from '../lib/drop-settings.js'
4
5
  import { PREP_CAPABILITY } from '../../shared/prep.js'
@@ -56,6 +57,10 @@ import {
56
57
  isOllamaProviderReady,
57
58
  } from '../lib/ollama-catalog.js'
58
59
  import { isMediaProcessingReady } from '../lib/image-safety.js'
60
+ import { DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
61
+ import { closedEnvDefaultModel } from '../lib/key-profile-routing.js'
62
+ import { getSessionBinding, getSessionModel } from '../lib/conversation.js'
63
+ import { isProfilePreference } from '../../shared/key-preference.js'
59
64
  import {
60
65
  MAX_OTHER_MEDIA_BYTES,
61
66
  MAX_VIDEO_MEDIA_BYTES,
@@ -625,6 +630,32 @@ function crashLoopParkingHealth(): { enabled: boolean; parked: number } {
625
630
  }
626
631
  }
627
632
 
633
+ /**
634
+ * 7.0.2 (A7): what "Default" in a model picker resolves to, in model-router's own order
635
+ * (lib/model-router.ts, the resolvedModel line): the request's model, then the session's
636
+ * preference, then COS_G2_DEFAULT_MODEL, then DEFAULT_MODEL. A picker on "Default" sends no
637
+ * model, so the request step only names a model a caller passes in. `source` says which step won.
638
+ * A slot name only: never a key profile (closedEnvDefaultModel and getSessionModel are closed).
639
+ */
640
+ export type DefaultModelSource = 'request' | 'session' | 'profile' | 'env' | 'built-in'
641
+ /** `preference` is null only for `profile`: the session is bound to a key profile, never named here (plan 5.2). */
642
+ export function resolveDefaultModel(input: { requested?: ModelPreference | null; sessionModel?: ModelPreference | null; sessionProfile?: boolean; env?: string | undefined }): { preference: ModelPreference | null; source: DefaultModelSource } {
643
+ if (input.requested) return { preference: input.requested, source: 'request' }
644
+ if (input.sessionModel) return { preference: input.sessionModel, source: 'session' }
645
+ // model-router routes a session bound to a profile through that profile when the request names no model.
646
+ if (input.sessionProfile) return { preference: null, source: 'profile' }
647
+ const env = closedEnvDefaultModel(input.env)
648
+ if (env) return { preference: env, source: 'env' }
649
+ return { preference: DEFAULT_MODEL, source: 'built-in' }
650
+ }
651
+ // A session id is only looked up in the in-memory session map (an unknown id reads null), so nothing else is checked.
652
+ function defaultModelField(sessionQuery: unknown): { preference: ModelPreference | null; source: DefaultModelSource } {
653
+ const sid = typeof sessionQuery === 'string' && sessionQuery.length <= 128 ? sessionQuery : ''
654
+ const sessionModel = sid ? getSessionModel(sid) : null
655
+ const sessionProfile = !!sid && !sessionModel && isProfilePreference(getSessionBinding(sid))
656
+ return resolveDefaultModel({ sessionModel, sessionProfile, env: process.env.COS_G2_DEFAULT_MODEL })
657
+ }
658
+
628
659
  // Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
629
660
  // global /api middleware; ?refresh=1 forces discovery.
630
661
  healthRouter.get('/models', async (req, res) => {
@@ -656,6 +687,8 @@ healthRouter.get('/models', async (req, res) => {
656
687
  res.json({
657
688
  ...catalog,
658
689
  ...threadAttachHealthFields(threadAttach),
690
+ // 7.0.2 (A7): what a picker's "Default" resolves to (?sessionId= adds that session's preference).
691
+ defaultModel: defaultModelField(req.query.sessionId),
659
692
  // 6.62.0 (plans 3.5, 3.11): the phone reads THIS surface, so the live-Continue counters
660
693
  // and the per-provider action contract are published here too, same helpers as health.
661
694
  ...continueLiveHealthFields(),
@@ -680,6 +713,8 @@ healthRouter.get('/models', async (req, res) => {
680
713
  ollamaReady: isOllamaProviderReady(),
681
714
  // Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
682
715
  ...keyProfilesModelsField(),
716
+ // PH plan P1: where the calendar comes from. Counts only, never titles; not on public /api/health.
717
+ calendar: calendarStatus(),
683
718
  serverInstanceId: getServerInstanceId(),
684
719
  // Minted here rather than on a route of its own: this response is already
685
720
  // authenticated, and the mint is stateless, so it costs nothing and stores