@gotcos/glasses-server 7.0.30 → 7.0.34

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 (34) hide show
  1. package/.env.example +10 -8
  2. package/CHANGELOG.md +207 -0
  3. package/bin/hooks/cos-session-hook +3 -2
  4. package/docs/pairing-fixture.json +1 -1
  5. package/package.json +3 -1
  6. package/server/index.ts +4 -2
  7. package/server/lib/api-auth.ts +3 -2
  8. package/server/lib/archive.ts +4 -2
  9. package/server/lib/claude-hooks-installer.ts +101 -15
  10. package/server/lib/codex-hooks-installer.ts +4 -1
  11. package/server/lib/full-hold.ts +61 -5
  12. package/server/lib/glasses-pairing.ts +2 -1
  13. package/server/lib/permission-broker.ts +130 -14
  14. package/server/lib/question-hold-setting.ts +38 -0
  15. package/server/lib/question-queue.ts +111 -0
  16. package/server/lib/session-hooks-runtime.ts +28 -3
  17. package/server/lib/session-title-cache.ts +40 -0
  18. package/server/lib/turn-end-ask.ts +51 -1
  19. package/server/lib/web-session-auth.ts +17 -0
  20. package/server/routes/agent-sessions.ts +3 -0
  21. package/server/routes/permission-broker.ts +31 -2
  22. package/server/routes/session-hooks.ts +23 -0
  23. package/server/routes/sessions.ts +3 -0
  24. package/server/web-control/.manifest.json +10 -10
  25. package/server/web-control/app.css +26 -3
  26. package/server/web-control/app.js +381 -36
  27. package/server/web-control/control.css +5 -1
  28. package/server/web-control/index.html +7 -5
  29. package/server/web-control/panes/cos-chat.css +21 -0
  30. package/server/web-control/panes/memories.js +18 -9
  31. package/server/web-control/panes/messages.js +15 -3
  32. package/server/web-control/panes/questions.js +6 -1
  33. package/server/web-control/panes/settings.js +18 -10
  34. package/server/web-control/shell.js +8 -13
@@ -107,6 +107,28 @@ export function projectedTurnAsk(value: unknown): TurnAskCapture | null {
107
107
  /** What a row shows (`deriveSessionState` input). */
108
108
  export interface VisibleTurnAsk { ask: string; choices: string[]; stoppedAt: number }
109
109
 
110
+ /** X1 (the one queue): a turn-end ask the queue lists, with its session. */
111
+ export interface QueueTurnAsk extends VisibleTurnAsk { sessionId: string }
112
+
113
+ /** X1: a turn-end ask answered in the last TURN_ASK_ANSWERED_KEEP_MS, and from where (never the reply's words). */
114
+ export interface AnsweredTurnAsk { sessionId: string; ask: string; stoppedAt: number; answeredAt: number; by: TurnAskAnswerSurface }
115
+ export type TurnAskAnswerSurface = 'mac' | 'web' | 'glasses' | 'mac_app' | 'cos'
116
+ export const TURN_ASK_ANSWERED_KEEP_MS = 120_000
117
+
118
+ /**
119
+ * X1: where a reply that cleared a turn-end ask came from, read from the words COS itself puts on it (contract
120
+ * v1.2: "Answer sent from <SURFACE> through COS, not typed in this session"; a dictated reply carries the
121
+ * `[COS-G2 turn` footer). A COS-spawned Continue child is COS. Anything else was typed at the Mac. Only the
122
+ * surface is kept.
123
+ */
124
+ export function turnAskAnswerSurface(prompt: unknown, child: boolean): TurnAskAnswerSurface {
125
+ const text = typeof prompt === 'string' ? prompt.slice(0, 2_000) : ''
126
+ if (text.includes('Answer sent from COS Control Web through COS')) return 'web'
127
+ if (text.includes('Answer sent from COS Control on the Mac through COS')) return 'mac_app'
128
+ if (text.includes('[COS-G2 turn')) return 'glasses'
129
+ return child ? 'cos' : 'mac'
130
+ }
131
+
110
132
  interface AskState {
111
133
  sessionId: string
112
134
  promptId: string | null
@@ -140,6 +162,8 @@ export class TurnEndAskTracker {
140
162
  private readonly asks = new Map<string, AskState>()
141
163
  /** The newest Stop per session, for a wrap_up whose (async) PostToolUse lands after it. */
142
164
  private readonly lastStop = new Map<string, { at: number; promptId: string | null }>()
165
+ /** X1: asks answered in the last TURN_ASK_ANSWERED_KEEP_MS that a surface had shown, newest last. */
166
+ private readonly answered: AnsweredTurnAsk[] = []
143
167
  private poll: ReturnType<typeof setTimeout> | null = null
144
168
  private pollInFlight = false
145
169
  private stopped = false
@@ -162,7 +186,14 @@ export class TurnEndAskTracker {
162
186
  const current = this.asks.get(sessionId)
163
187
  // Any prompt, from the desk or a COS-spawned Continue child, is a reply: the question is answered.
164
188
  if (env.event === 'UserPromptSubmit') {
165
- if (current) { this.asks.delete(sessionId); this.counters.clearedByReply++ }
189
+ if (current) {
190
+ this.asks.delete(sessionId)
191
+ this.counters.clearedByReply++
192
+ if (current.stoppedAt !== null && current.visibleAt !== null) {
193
+ this.answered.push({ sessionId, ask: current.capture.ask, stoppedAt: current.stoppedAt, answeredAt: env.ts, by: turnAskAnswerSurface(p.prompt, child) })
194
+ if (this.answered.length > MAX_TURN_ASKS) this.answered.splice(0, this.answered.length - MAX_TURN_ASKS)
195
+ }
196
+ }
166
197
  return
167
198
  }
168
199
  // A COS-spawned child's other events are not the session's own turn.
@@ -234,6 +265,25 @@ export class TurnEndAskTracker {
234
265
  return { ask: state.capture.ask, choices: [...state.capture.choices], stoppedAt: state.stoppedAt }
235
266
  }
236
267
 
268
+ /** X1 (the one queue): every ask a row would show now, oldest stop first. */
269
+ visibleAsks(now = this.deps.now()): QueueTurnAsk[] {
270
+ if (!this.deps.enabled()) return []
271
+ const out: QueueTurnAsk[] = []
272
+ for (const state of [...this.asks.values()]) {
273
+ const shown = this.visibleAsk(state.sessionId, now)
274
+ if (shown) out.push({ sessionId: state.sessionId, ...shown })
275
+ }
276
+ return out.sort((a, b) => a.stoppedAt - b.stoppedAt)
277
+ }
278
+
279
+ /** X1: asks answered within TURN_ASK_ANSWERED_KEEP_MS (by when the reply arrived), oldest first. */
280
+ recentlyAnswered(now = this.deps.now()): AnsweredTurnAsk[] {
281
+ if (!this.deps.enabled()) return []
282
+ // Kept in the order replies arrived, so dropping from the front drops exactly the ones past the window.
283
+ while (this.answered.length > 0 && now - this.answered[0]!.answeredAt > TURN_ASK_ANSWERED_KEEP_MS) this.answered.shift()
284
+ return this.answered.map(a => ({ ...a }))
285
+ }
286
+
237
287
  /** The session's row ended: nothing it asked can still be answered. */
238
288
  sessionRowEnded(sessionId: string): void {
239
289
  const key = sessionId.toLowerCase()
@@ -444,6 +444,8 @@ export function classifyWebLogin(
444
444
  }
445
445
  if (codeStore && typeof input.code === 'string' && input.code.trim().length > 0) {
446
446
  if (codeStore.consume(input.code)) return { ok: true, kind: 'code' }
447
+ // The glasses' 14-character code: by shape only, never looked up (see looksLikeGlassesPairingCode).
448
+ if (looksLikeGlassesPairingCode(input.code)) return { ok: false, reason: 'glasses_code', length: GLASSES_PAIRING_CODE_TYPED_LENGTH }
447
449
  const code = normalizeDeviceCode(input.code)
448
450
  let glasses = false
449
451
  if (code && isGlassesCode) {
@@ -506,6 +508,21 @@ export function normalizeDeviceCode(input: unknown): string | null {
506
508
  return clean
507
509
  }
508
510
 
511
+ /**
512
+ * 7.0.32 (O1 00:13, fix 4 of round 2): the code the glasses pair with is 14 characters of the same alphabet. Typed into
513
+ * the browser's sign-in box, it is recognised by its SHAPE only (the same cleaning as normalizeDeviceCode, then exactly
514
+ * 14 characters of the alphabet), never looked up, so a 14-character guess learns nothing about the live glasses code.
515
+ * No browser sign-in code has 14 characters, so nothing valid changes meaning. The 8-character lookup stays as it is.
516
+ */
517
+ export const GLASSES_PAIRING_CODE_TYPED_LENGTH = 14
518
+ export function looksLikeGlassesPairingCode(input: unknown): boolean {
519
+ if (typeof input !== 'string') return false
520
+ const clean = input.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
521
+ if (clean.length !== GLASSES_PAIRING_CODE_TYPED_LENGTH) return false
522
+ for (const ch of clean) if (!DEVICE_CODE_ALPHABET.includes(ch)) return false
523
+ return true
524
+ }
525
+
509
526
  /**
510
527
  * Short sign-in codes for another device (the phone), minted by a browser or app that is
511
528
  * already signed in. Same shape as glasses pairing: 8 Crockford characters (40 bits), five
@@ -94,6 +94,7 @@ import { createSessionPreviewRouter } from './session-preview.js'
94
94
  import { organizerKey, sessionOrganizer, type OrganizerRowFields } from '../lib/session-organizer.js'
95
95
  import { listOlderSessions, shortRowFor, OLDER_DEFAULT_DAYS, OLDER_DEFAULT_LIMIT, OLDER_MAX_DAYS, OLDER_MAX_LIMIT, OLDER_MIN_DAYS } from '../lib/agent-session-older.js'
96
96
  import { cachedListModel, warmListModels } from '../lib/list-model-memo.js'
97
+ import { noteSessionTitle } from '../lib/session-title-cache.js'
97
98
  import { cosStartedSessionIds } from '../lib/cos-started-sessions.js'
98
99
 
99
100
  /** Step 2a: the organizer, or null when it is unavailable or empty (the list is then exactly today's). */
@@ -649,6 +650,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
649
650
  }))
650
651
  // Step 2a: read the models this answer lacked, in the background, for a later poll. Never awaited.
651
652
  warmListModels(sessions)
653
+ // X1 (the one queue): the titles this list showed, so the questions route can name a waiting session.
654
+ for (const entry of sessions) noteSessionTitle(entry.session_id, entry.display_label)
652
655
  } catch (error) {
653
656
  console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
654
657
  res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
@@ -50,7 +50,10 @@
50
50
 
51
51
  import express, { Router, type NextFunction, type Request, type Response } from 'express'
52
52
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
53
- import { turnEndAskStats } from '../lib/session-hooks-runtime.js'
53
+ import { turnEndAskStats, turnEndAsksForQueue } from '../lib/session-hooks-runtime.js'
54
+ import { buildQuestionQueue, type QuestionQueue } from '../lib/question-queue.js'
55
+ import { sessionTitleFor } from '../lib/session-title-cache.js'
56
+ import { waitingQuestionWordsEnabled } from '../lib/full-hold.js'
54
57
  import {
55
58
  ANSWERING_CLIENTS,
56
59
  CLIENT_LIVE_WINDOW_MS,
@@ -163,6 +166,9 @@ export interface SessionQuestionsRouterDeps {
163
166
  * rules: `isWebSessionAuthorized` in production). Absent: only the token opens the list, as before.
164
167
  */
165
168
  webSessionAuthorized?: (req: Request) => boolean
169
+ /** X1 (the one queue): the turn-end asks and the session titles the queue reads. Default to the runtime's and the list's. */
170
+ turnEndQueue?: () => ReturnType<typeof turnEndAsksForQueue>
171
+ titleFor?: (sessionId: string) => string | null
166
172
  /** 7.0.18 (X1): the turn-end ask counters (lib/turn-end-ask.ts), null while COS_TURN_END_ASKS is off. Defaults to the runtime's. */
167
173
  turnEndAskStats?: () => Record<string, number> | null
168
174
  /** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
@@ -274,6 +280,26 @@ export function requestFromThisMac(
274
280
  return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
275
281
  }
276
282
 
283
+ /**
284
+ * X1 (the one queue, Miles 11/12 17:31): `queue` and `queueCount`, always present (whatever the full-hold switches),
285
+ * appended after every existing key so a client that reads the 7.0.31 keys reads exactly what it read before. A
286
+ * failure to build it never costs the rest of the response: the keys are then an empty queue.
287
+ */
288
+ export function questionQueueFields(deps: SessionQuestionsRouterDeps): QuestionQueue {
289
+ try {
290
+ const turnEnd = (deps.turnEndQueue ?? turnEndAsksForQueue)()
291
+ return buildQuestionQueue({
292
+ broker: deps.broker.queueSources(),
293
+ turnEnd: turnEnd.asks,
294
+ turnEndAnswered: turnEnd.answered,
295
+ titleFor: deps.titleFor ?? sessionTitleFor,
296
+ words: waitingQuestionWordsEnabled(process.env),
297
+ })
298
+ } catch {
299
+ return { queue: [], queueCount: 0 }
300
+ }
301
+ }
302
+
277
303
  function turnEndAskCounters(deps: SessionQuestionsRouterDeps): { turnEndAsks?: Record<string, number> } {
278
304
  let counters: Record<string, number> | null = null
279
305
  try { counters = (deps.turnEndAskStats ?? turnEndAskStats)() } catch { counters = null }
@@ -341,6 +367,7 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
341
367
  // 7.0.18 (X1), additive and only while COS_TURN_END_ASKS is on: how many turn-end asks armed, were shown,
342
368
  // were answered, were overtaken by a new turn, or aged out. Counts only, never words.
343
369
  ...turnEndAskCounters(deps),
370
+ ...questionQueueFields(deps),
344
371
  })
345
372
  })
346
373
  router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
@@ -350,7 +377,9 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
350
377
  let expected = ''
351
378
  try { expected = deps.apiToken() } catch { expected = '' }
352
379
  const by = timingSafeTokenEqual(req.headers['x-cos-token'], expected) ? 'app' : 'web'
353
- const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by)
380
+ // X1 (the one queue): the answering client may name itself (glasses, phone, mac_app) so others can say where.
381
+ const client = req.get('x-cos-client') ?? (typeof req.query.client === 'string' ? req.query.client : undefined)
382
+ const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by, client)
354
383
  res.status(result.status).json(result.body)
355
384
  })
356
385
  // X1: the web door's confirm step for an approval (lib/permission-broker.ts `confirm`). The /api gate let the
@@ -12,6 +12,9 @@ import { refreshAfterInstall } from '../lib/provider-observe.js'
12
12
  import { Router } from 'express'
13
13
  import { installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
14
14
  import { deskIdleSeconds, invalidateHookStatus, sessionHooksHealthFields, sessionSignalStore } from '../lib/session-hooks-runtime.js'
15
+ import { API_AUTH_BY_LOCAL } from '../lib/api-auth.js'
16
+ import { questionHoldMode } from '../lib/full-hold.js'
17
+ import { writeQuestionHoldSetting } from '../lib/question-hold-setting.js'
15
18
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
16
19
 
17
20
  /** The registry entrypoints a person sits at; everything else (`sdk-cli`, unknown) may be a job. */
@@ -69,6 +72,26 @@ export function createSessionHooksRouter(options: { port: number; codex?: Sessio
69
72
  res.json({ ok: true, ...sessionHooksHealthFields() })
70
73
  })
71
74
 
75
+ // X1, 7.0.34: the question hold's server-side setting, for Control's Settings row (Control 0.5.394). Body
76
+ // `{"mode":"on"|"off"|"default"}`; `default` removes the setting. The Mac app only: a browser session is refused
77
+ // (Control Web shows the state read-only). `COS_QUESTION_FULL_HOLD` in the environment wins over the setting, so
78
+ // while it is set nothing is written and the answer says so (409 `set_by_env`). Turning the hold on or off here
79
+ // changes what Reinstall hooks writes; the status's `state` then says whether a Reinstall is due.
80
+ router.put('/session-hooks/question-hold', (req, res) => {
81
+ res.set('Cache-Control', 'private, no-store')
82
+ if (res.locals[API_AUTH_BY_LOCAL] !== 'token') { res.status(403).json({ ok: false, reason: 'web_read_only' }); return }
83
+ const mode = req.body && typeof req.body === 'object' ? (req.body as { mode?: unknown }).mode : undefined
84
+ if (mode !== 'on' && mode !== 'off' && mode !== 'default') { res.status(400).json({ ok: false, reason: 'bad_mode' }); return }
85
+ if (questionHoldMode(process.env).source === 'env') {
86
+ res.status(409).json({ ok: false, reason: 'set_by_env', questionWait: sessionHooksHealthFields().sessionHooks.questionWait ?? null })
87
+ return
88
+ }
89
+ try { writeQuestionHoldSetting(mode) } catch { res.status(500).json({ ok: false, reason: 'write_failed' }); return }
90
+ invalidateHookStatus()
91
+ const hooks = sessionHooksHealthFields().sessionHooks
92
+ res.json({ ok: true, questionWait: hooks.questionWait ?? null, state: hooks.state })
93
+ })
94
+
72
95
  router.post('/session-hooks/install', (req, res) => {
73
96
  const dryRun = req.query.dryRun === '1' || (req.body && typeof req.body === 'object' && (req.body as { dryRun?: unknown }).dryRun === true)
74
97
  const result = installClaudeHooks({ port: options.port, deskIdleSeconds: deskIdleSeconds(), dryRun })
@@ -309,6 +309,7 @@ sessionsRouter.get('/sessions/today/all-messages', async (req, res) => {
309
309
  query: string
310
310
  text: string
311
311
  timestamp: number
312
+ queryTimestamp?: number
312
313
  chatIndex: number
313
314
  sessionId: string
314
315
  source: 'live'
@@ -341,6 +342,8 @@ sessionsRouter.get('/sessions/today/all-messages', async (req, res) => {
341
342
  query: ex.content,
342
343
  text: next.content,
343
344
  timestamp: next.timestamp,
345
+ // 7.0.33 A2: when the question was asked, beside when COS answered (timestamp), for a time under each.
346
+ ...(Number.isFinite(ex.timestamp) ? { queryTimestamp: ex.timestamp } : {}),
344
347
  chatIndex: -1,
345
348
  sessionId: session.id,
346
349
  source: 'live',
@@ -2,34 +2,34 @@
2
2
  "v": 1,
3
3
  "algorithm": "sha256",
4
4
  "files": {
5
- "/app.css": "bc5e29be841629261889aebfd439a3b126a50041c4e079c9866eac93a3552fe1",
6
- "/app.js": "eb9e4b98ddf4982644c57ce7de11fb9a606e245b5e2169bb25adbe825c05773d",
5
+ "/app.css": "f2d9c657580959c8a49ce5dd947c027aded0f4e69e4367b8247f092c0ccb0c8f",
6
+ "/app.js": "c5740309a27ba9b1513be466e687ec483311045c2f0088ffb3d5507afb208db2",
7
7
  "/apple-touch-icon.png": "04ec7eca0bf6f53d89900d146ac32d4826759d6258bcdbd1ccdcc517f26f532c",
8
- "/control.css": "f5b7a2e0f800cc07072581b7e817fcd2b54bdb1d91f4662a3cdc9d80a7bc32ff",
8
+ "/control.css": "6943c7a0573ae85e1fbdbda36e1765ea4ee5d5fd9205690253570f89b49d7fd3",
9
9
  "/fonts/DMSans.ttf": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d",
10
10
  "/fonts/Fraunces.ttf": "177ff6c0f14e5550a3c624247cd1189611d4eb65d000b14944c63d967958abbb",
11
11
  "/fonts/JetBrainsMono.ttf": "48715a42ec242c21e9f02692891e147d022299a52e48d5e413e1a942193ffeda",
12
- "/index.html": "c3a88479e5b9efa323129c5a425dada70dc7f0f27a0c787c1bc225c661f20a7b",
12
+ "/index.html": "d9d1cede8b6ef2a3680f5b62e24fffcbf65e2eff3c33de31a69263f53d78520c",
13
13
  "/mark.svg": "8e380d33d393027df192eb1afa08f15a380c2070c72d68f27311b49d9e94ca18",
14
14
  "/memories/d3.min.js": "f2094bbf6141b359722c4fe454eb6c4b0f0e42cc10cc7af921fc158fceb86539",
15
15
  "/memories/graph-explorer.css": "ec128de59811f45c71b6bededd737989309205e903951eb8211ade7664f87486",
16
16
  "/memories/graph-explorer.js": "98bde3df25f42022b098d047d2c8e7a22b972c1d817a18a24a0ab075d77871d3",
17
17
  "/memories/memory-workspace.js": "013f6c8c72c3474768f8c374edccde0e3656fc243e56528e258c3c29acca64f5",
18
18
  "/memory-review.js": "f27fe519f7ad7eccc3b44124fae724aa51e2eb3cb060d2f1795600e0e41b074f",
19
- "/panes/cos-chat.css": "079af47855641bf21aae75c34727f7c7be4357e0dde4ed78414d0e60776b71d4",
19
+ "/panes/cos-chat.css": "6edd123904869ff1d04093ffa8e45cf40863ac532054ce703eb44a59a2f395c4",
20
20
  "/panes/cos-chat.js": "b94fca907500be21f777c5d5fa23b7415be48dcbd41d9fa30a4295ed1e3415d3",
21
21
  "/panes/dictation.js": "e9434b353a1a3ab3f21d4cef5b2d5f927a733f6af5f58285fda6a78472761f91",
22
22
  "/panes/meetings.css": "635470e4d5f5665b046eab7382734c9772df569069c9b1eb10cfde6151014bfa",
23
23
  "/panes/meetings.js": "e057f4dae697138111a4c07e8ec6c2a0502b5d93023cf491460bc7bf20172c85",
24
24
  "/panes/memories.css": "36731d6fd21ad1516dfeccf34fe5b2d3d267d21e40b47704055a0685334b579e",
25
- "/panes/memories.js": "66341dd5126c40275979d1c40f8db72e1f8229ac9634a4bb72b70140db277f2a",
26
- "/panes/messages.js": "7262ad82b6337a610b11ffff0677663e9317d1fe537f9e4161ae3af1ddef35ef",
25
+ "/panes/memories.js": "47b9a06b329c3ec7c21902e6d8ec2fc7fe9013fffcfe936b54037be162c82958",
26
+ "/panes/messages.js": "5c43d171cb026ff98cc316821f631992288909570ec6abbd9bcc4dc1dc046bca",
27
27
  "/panes/preview.js": "79f1aecb27f48d584ed4cf37c7f4f423f0f9b45dd6c2fb24a9d88c72625d8680",
28
28
  "/panes/questions.css": "5c5a9b32ce32386b508ce326b17d40ecacbb0f2e049df7f4fe2a0009bd3b7908",
29
- "/panes/questions.js": "228e8cd0a9651a290cbb380db487af677a2fc7e93a64b1e33c9411cd8b013478",
29
+ "/panes/questions.js": "60115b6f5d5380d6dac1ceb50e6a56bd6d10f8d49fb64429fbe73d6890270111",
30
30
  "/panes/reader-media.js": "4cf892a855aa2d0dce6978e69296cd49b8036d8fc62f12773b3fcadc210b2ef0",
31
31
  "/panes/sessions.css": "654e0509ab2b699b9ea92b6d9e2f7522146df1f0b6355915bef03caaf63addc4",
32
- "/panes/settings.js": "315458c5911f31592853800418dde6d87e9e61bbe96f90c77450c94f87d01951",
32
+ "/panes/settings.js": "cc2ec51f397383be0b6f5078e6632c35dab8bf12ed3555df20c7cf2ec8ae8f63",
33
33
  "/panes/speakers.css": "49e70dae4266151b14b0057f2e43de169de225b8f3ac30343c81cf532735a487",
34
34
  "/panes/speakers.js": "e4fbcc147c44f8bf798e0694e0c8b152d5cde82dc46823641c8e8d37714b8fba",
35
35
  "/panes/threads.js": "45fd7b56480be53225e91193cfa4c41a4ee7167890f10560e439bc7d2461747f",
@@ -37,7 +37,7 @@
37
37
  "/panes/today-actions.js": "c767b62b21a9f1debffd6e4ff4558891222b573535de1d78849b602030bb09bd",
38
38
  "/panes/work.css": "da50355dac690f712d1f6331f70ee5eadbf5982d37508648ae7786afc99ac7b1",
39
39
  "/panes/work.js": "913c56084a6df463cc83358ce360da30d529466f1c583683fd3a4fd5eafe2d30",
40
- "/shell.js": "cc7d0d771712607d4ba49eb1cfc83c50c7fcae2203f1c386778fee43d1803a81",
40
+ "/shell.js": "9bef169c42d8130cbca8c012c3667a9b92cacaa8673b4cef47d6fc7cfc0096ef",
41
41
  "/theme.js": "e3e6422eaac43979d03149da114f5b83757b7361dd7b309cdf76be36a596044b",
42
42
  "/vendor/qrcode-encoder.js": "61d87cc7445cc66b862cff913de4571634746ea62bcd50d0f592be14f6d1ce46"
43
43
  }
@@ -225,6 +225,26 @@ select.field {
225
225
  .pet-caption { color: var(--sub); font-size: 14px; }
226
226
  /* A still pet (gallery pick, Cloud Puff, single image). Small pixel art scales without blur. */
227
227
  .pet-still { height: 140px; width: auto; max-width: 220px; object-fit: contain; }
228
+ /* 7.0.33 A13, Anand's mock (slide 9): in a phone's top bar, the pet in a small round button left of Ask, keeping its own
229
+ animation; a tap opens a card under it with the pet, its line, Change pet and Hide. The card floats, so it has the
230
+ mock's soft shadow and no border. */
231
+ .topbar .today-pet.in-bar { position: relative; min-height: 0; margin: 0 6px 0 auto; flex-direction: row; }
232
+ .today-pet.in-bar > .pet-btn { width: 36px; height: 36px; border-radius: 50%; justify-content: center; overflow: hidden;
233
+ background: color-mix(in srgb, var(--accent) 16%, var(--page)); border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent); }
234
+ .today-pet.in-bar > .pet-btn .pet-still { height: 30px; max-width: 30px; }
235
+ .today-pet.in-bar > .pet-btn .pet-sprite { zoom: 0.21; }
236
+ .today-pet.in-bar > .pet-btn .pet-caption, .today-pet.in-bar > .pet-change { display: none; }
237
+ .pet-card { position: absolute; top: calc(100% + 8px); right: -40px; width: min(250px, calc(100vw - 32px)); padding: 14px; text-align: left;
238
+ background: var(--panel); border-radius: 16px; box-shadow: 0 8px 28px rgba(40, 30, 15, 0.16); z-index: 30; }
239
+ .pet-card-head { display: flex; align-items: center; gap: 12px; width: 100%; background: none; border: 0; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; }
240
+ .pet-card-head[aria-disabled="true"] { cursor: default; }
241
+ .pet-card-pet { flex: none; width: 44px; display: grid; place-items: center; }
242
+ .pet-card-pet .pet-still { height: 48px; max-width: 44px; }
243
+ .pet-card-pet .pet-sprite { zoom: 0.34; }
244
+ .pet-card-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
245
+ .pet-card-title { color: var(--ink); font-weight: 600; font-size: 16px; }
246
+ .pet-card-sub { color: var(--sub); font-size: 14px; line-height: 1.35; }
247
+ .pet-card-acts { display: flex; gap: 8px; margin-top: 12px; }
228
248
  .pet-still.pixel { image-rendering: pixelated; }
229
249
  .pet-change { background: none; border: 0; padding: 6px 10px; margin-top: 2px; color: var(--sub); font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
230
250
  .pet-change:focus-visible { outline: 2px solid var(--sub); outline-offset: 2px; border-radius: 6px; }
@@ -492,6 +512,9 @@ select.field {
492
512
  .pane-head.with-ask { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
493
513
  .pane-head.with-ask .ask-bar { max-width: 400px; margin-top: 0; }
494
514
  .ask-bar .kbd { display: inline-block; }
515
+ /* 7.0.33 A9 (Anand round 2, slide 29): ⌘K is a keyboard's shortcut. A touch screen with no mouse (the open Duo, an
516
+ iPad held alone) does not show it. */
517
+ @media (hover: none) and (pointer: coarse) { .ask-bar .kbd { display: none; } }
495
518
  .pane-title { font-size: 44px; }
496
519
  .story { font-size: 26px; }
497
520
  .run-row { grid-template-columns: 118px minmax(0, 1fr); gap: 2px 14px; align-items: start; }
@@ -704,8 +727,8 @@ html { touch-action: manipulation; }
704
727
  /* The focus ring sits inside the button: the rail clips anything past its edge. */
705
728
  html.short-screen .rail :focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; border-radius: 12px; }
706
729
  /* One way to switch panes on a screen: the rail. On a list the top bar keeps the pane's name, without the caret and
707
- its menu (app.js makes the button inert); on a reader its Back moves to the rail and the left of the top bar is
708
- empty. */
709
- html.short-screen .section-btn .caret, html.short-screen #topbar-back { display: none !important; }
730
+ its menu (app.js makes the button inert). On a reader (7.0.33 A12) the top bar shows Back with the name of where it
731
+ goes, and stays put while you read; the rail's foot keeps its own Back within the thumb's reach. */
732
+ html.short-screen .section-btn .caret { display: none !important; }
710
733
  html.short-screen .section-btn { cursor: default; }
711
734
  }