@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
@@ -16,11 +16,33 @@
16
16
 
17
17
  import { readSwitch } from './env-switch.js'
18
18
 
19
- /** `COS_QUESTION_FULL_HOLD`: hold a question for the whole hook wait. Default OFF. Read per request. */
19
+ /** `COS_QUESTION_FULL_HOLD` set explicitly ON (`1`, `true`, `on`). Unset is the default below, never this. Read per request. */
20
20
  export function questionFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
21
21
  return readSwitch(env.COS_QUESTION_FULL_HOLD, false)
22
22
  }
23
23
 
24
+ /**
25
+ * X1, 7.0.34 (Miles 2026-11-13 15:02, "I want the 8 hour question hold ON. please keep it on by default."): the
26
+ * question hold has THREE states, decided in this order:
27
+ * 1. `COS_QUESTION_FULL_HOLD` set to on (`1`, `true`, `on`) or off (`0`, `false`, `off`): that, source `env`.
28
+ * 2. Else the server-side setting Control's Settings row writes (`lib/question-hold-setting.ts`), source `setting`.
29
+ * 3. Else `default`: ON BY DEFAULT, PENDING PER REQUEST. A question is held for the whole wait only when ITS OWN
30
+ * hook stamped a wait longer than DEFAULT_HOLD_MIN_STAMP_S, which only the split question block Reinstall hooks
31
+ * writes ever sends. Until a Mac reinstalls, every request reads exactly as 7.0.33 with the switch off.
32
+ * The env value wins over the setting: it is what the launcher or the person at the terminal set on purpose, and
33
+ * every release since 7.0.16 documents it as the off switch (rollback included). A Settings click must never undo it.
34
+ */
35
+ export type QuestionHoldMode = 'on' | 'off' | 'default'
36
+ export type QuestionHoldSource = 'env' | 'setting' | 'default'
37
+ export type QuestionHoldSetting = 'on' | 'off'
38
+
39
+ export function questionHoldMode(env: NodeJS.ProcessEnv, setting: QuestionHoldSetting | null = null): { mode: QuestionHoldMode; source: QuestionHoldSource } {
40
+ if (readSwitch(env.COS_QUESTION_FULL_HOLD, false)) return { mode: 'on', source: 'env' }
41
+ if (!readSwitch(env.COS_QUESTION_FULL_HOLD, true)) return { mode: 'off', source: 'env' }
42
+ if (setting === 'on' || setting === 'off') return { mode: setting, source: 'setting' }
43
+ return { mode: 'default', source: 'default' }
44
+ }
45
+
24
46
  /** `COS_APPROVAL_FULL_HOLD`: hold a tool approval for the whole hook wait while the desk stays idle. Default OFF. */
25
47
  export function approvalFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
26
48
  return readSwitch(env.COS_APPROVAL_FULL_HOLD, false)
@@ -45,10 +67,16 @@ export function questionRowText(question: unknown): string {
45
67
  return question.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
46
68
  }
47
69
 
48
- export interface FullHoldSwitches { questions: boolean; approvals: boolean }
70
+ /**
71
+ * `questions`: the question hold is ON outright (env or setting). `questionsByDefault`: it is the 7.0.34 default,
72
+ * which holds a question only when its own hook stamped a long wait (`defaultHoldTakesStamp`). Both false: off.
73
+ * A caller that predates 7.0.34 and passes only `questions` and `approvals` reads as off, exactly as before.
74
+ */
75
+ export interface FullHoldSwitches { questions: boolean; approvals: boolean; questionsByDefault?: boolean }
49
76
 
50
- export function fullHoldSwitches(env: NodeJS.ProcessEnv): FullHoldSwitches {
51
- return { questions: questionFullHoldEnabled(env), approvals: approvalFullHoldEnabled(env) }
77
+ export function fullHoldSwitches(env: NodeJS.ProcessEnv, setting: QuestionHoldSetting | null = null): FullHoldSwitches {
78
+ const q = questionHoldMode(env, setting)
79
+ return { questions: q.mode === 'on', approvals: approvalFullHoldEnabled(env), questionsByDefault: q.mode === 'default' }
52
80
  }
53
81
 
54
82
  /**
@@ -107,9 +135,37 @@ export function engineProvenForFullHold(engine: EngineFacts | null | undefined):
107
135
  return version !== null && versionAtLeast(version, floor)
108
136
  }
109
137
 
110
- /** The PermissionRequest wait the installer writes for a kind under the full hold. */
138
+ /** The PermissionRequest wait the installer writes for an APPROVAL under the full hold (7.0.16; unchanged). */
111
139
  export const FULL_HOLD_HOOK_WAIT_S = 3600
112
140
 
141
+ /**
142
+ * X1 (Miles 2026-11-12 17:42, "8 hours"): how long a fully held QUESTION stays answerable from the web and the
143
+ * glasses. Everything below is derived from this one number:
144
+ * the question block's `timeout` = FULL_HOLD_QUESTION_WAIT_S = 8 h x 60 min x 60 s = 28,800 s
145
+ * the longest stamp the script takes = 28,800 (5 digits; `bin/hooks/cos-session-hook`, pinned by a test)
146
+ * the longest stamp the broker trusts = HOOK_WAIT_STAMP_MAX_S = 28,800
147
+ * the script's curl gives up at = 28,800 - 5 = 28,795 s
148
+ * the broker's deadline = holdCeilingS = 28,800 - HOOK_REPLY_MARGIN_S (10) = 28,790 s
149
+ * so the deadline stays under the hook's own wait, as every release since 6.52.0 keeps it. Approvals keep
150
+ * FULL_HOLD_HOOK_WAIT_S. The Mac's own dialog has no limit past this: only the remote answer ends.
151
+ */
152
+ export const FULL_HOLD_QUESTION_WAIT_HOURS = 8
153
+ export const FULL_HOLD_QUESTION_WAIT_S = FULL_HOLD_QUESTION_WAIT_HOURS * 60 * 60
154
+ /** The question block 7.0.16 to 7.0.31 wrote: an install from then reads as an earlier wait (`priorWaitOnly`), still halt-ready. */
155
+ export const FULL_HOLD_QUESTION_WAIT_PRIOR_S = 3600
156
+
157
+ /**
158
+ * X1, 7.0.34: under the default, a question is held for the whole wait only when its hook stamped MORE than this.
159
+ * The one block 6.60.0 to 7.0.33 writes stamps 630 and the 7.0.16 to 7.0.31 question block 3600, so neither does;
160
+ * only the split question block Reinstall hooks writes under 7.0.32 or later (28,800) does. The gate is the
161
+ * invocation's own stamp, so a session still running on hooks it loaded before Reinstall keeps 7.0.33's behaviour.
162
+ */
163
+ export const DEFAULT_HOLD_MIN_STAMP_S = FULL_HOLD_QUESTION_WAIT_PRIOR_S
164
+
165
+ export function defaultHoldTakesStamp(hookWaitS: number | null | undefined): boolean {
166
+ return typeof hookWaitS === 'number' && Number.isFinite(hookWaitS) && hookWaitS > DEFAULT_HOLD_MIN_STAMP_S
167
+ }
168
+
113
169
  /**
114
170
  * The matcher of the non-question PermissionRequest block once the hook is split (canary C9:
115
171
  * honoured on both engines; a question runs only the `AskUserQuestion` block, everything else
@@ -64,7 +64,8 @@ export const PAIRING_REASON_STATUS: Record<PairingReason, number> = {
64
64
  }
65
65
 
66
66
  export const PAIRING_REASON_MESSAGE: Record<PairingReason, string> = {
67
- bad_request: 'That pairing request was not understood. Scan the code again.',
67
+ // 7.0.32 (Miles 11/13 06:34): the glasses pair by the typed code only, so no pairing answer says scan.
68
+ bad_request: 'That pairing request was not understood. Check the code in COS Control and type it again.',
68
69
  not_loopback: 'Pairing codes are made only by COS Control on this Mac.',
69
70
  not_allowed_network: 'Connect over Tailscale, or turn on "Allow pairing on this Wi-Fi" in COS Control.',
70
71
  denied: 'Pairing was denied on the Mac.',
@@ -140,7 +140,7 @@ import { randomBytes } from 'node:crypto'
140
140
  import { boundForRedaction } from './activity-preview.js'
141
141
  import { inferHookProvider, toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
142
142
  import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signal-store.js'
143
- import { WEB_ANSWER_TOUCH_MAX_MS, WEB_CONFIRM_TTL_MS, engineProvenForFullHold, type EngineFacts, type FullHoldSwitches } from './full-hold.js'
143
+ import { FULL_HOLD_QUESTION_WAIT_S, WEB_ANSWER_TOUCH_MAX_MS, WEB_CONFIRM_TTL_MS, defaultHoldTakesStamp, engineProvenForFullHold, type EngineFacts, type FullHoldSwitches } from './full-hold.js'
144
144
 
145
145
  // ---------------------------------------------------------------------------
146
146
  // Configuration
@@ -228,7 +228,7 @@ export const LEGACY_HOOK_WAIT_S = 130
228
228
  */
229
229
  export const HOOK_REPLY_MARGIN_S = 10
230
230
  /** A `hookWaitS` stamp outside this range is not one the installer writes: no stamp. */
231
- export const HOOK_WAIT_STAMP_MAX_S = 3_600
231
+ export const HOOK_WAIT_STAMP_MAX_S = FULL_HOLD_QUESTION_WAIT_S
232
232
 
233
233
  /**
234
234
  * 6.60.0: the longest a request may be held, in seconds, from the hook's own start.
@@ -748,6 +748,46 @@ export type BrokerItemKind = 'question' | 'approval'
748
748
  /** 6.99.27: the door an answer came through: the Even app with the API token, or COS Control Web's browser session. */
749
749
  export type AnswerSurface = 'app' | 'web'
750
750
 
751
+ /**
752
+ * X1 (the one queue, 7.0.32 candidate): which surface answered, as the queue says it ("Answered on the glasses").
753
+ * `web` is the browser door. The token door names its client when it says so (`X-Cos-Client` or `?client=`:
754
+ * glasses, phone, mac_app); otherwise `cos` (a COS surface that did not say which). `mac` is the Mac's own dialog.
755
+ */
756
+ export type QueueSurface = 'mac' | 'web' | 'glasses' | 'phone' | 'mac_app' | 'cos'
757
+ const CLIENT_SURFACES: ReadonlySet<string> = new Set(['glasses', 'phone', 'mac_app'])
758
+
759
+ export function answerSurfaceOf(by: AnswerSurface, client: unknown): QueueSurface {
760
+ if (by === 'web') return 'web'
761
+ const said = typeof client === 'string' ? client.trim().toLowerCase().replace(/-/g, '_') : ''
762
+ return CLIENT_SURFACES.has(said) ? said as QueueSurface : 'cos'
763
+ }
764
+
765
+ /**
766
+ * X1: an answered entry stays in the queue this long, saying where it was answered. The same window the waiting list
767
+ * already keeps an answered question for (WAITING_ANSWERED_KEEP_MS), so a question's answered entry is dropped by the
768
+ * list's own prune; an approval's by the check in `queueSources`.
769
+ */
770
+ export const QUEUE_ANSWERED_KEEP_MS = 120_000
771
+
772
+ /** X1: one waiting thing the broker knows of, for the queue (`lib/question-queue.ts` builds the wire entries). */
773
+ export interface BrokerQueueSource {
774
+ key: string
775
+ sessionId: string
776
+ provider: 'claude' | 'codex'
777
+ kind: 'question' | 'approval'
778
+ tool: string
779
+ questions: BrokerQuestion[]
780
+ approval: ApprovalCard | null
781
+ askedAt: number
782
+ /** `here`: a COS surface can answer it now (`heldId`); `mac`: only the Mac's own dialog can. */
783
+ where: 'here' | 'mac'
784
+ heldId: string | null
785
+ deadlineAt: number | null
786
+ state: 'waiting' | 'answered'
787
+ answeredBy: QueueSurface | null
788
+ answeredAt: number | null
789
+ }
790
+
751
791
  export type BrokerResolution = 'answered' | 'handed_to_desk' | 'expired' | 'hook_gone' | 'retracted' | 'drained' | 'no_client'
752
792
  export type BrokerFastPath =
753
793
  | 'broker_off' | 'draining' | 'malformed' | 'cursor' | 'approvals_off' | 'unsupported_tool'
@@ -942,7 +982,7 @@ interface BrokerItem {
942
982
  deadlineAt: number
943
983
  state: 'pending' | BrokerResolution
944
984
  settledAt: number | null
945
- answer: { clientAnswerId: string; chosen: Chosen } | null
985
+ answer: { clientAnswerId: string; chosen: Chosen; surface?: QueueSurface } | null
946
986
  /** The clientAnswerId of the "Leave for the Mac" that settled it, so its retry replays. */
947
987
  handedBackBy: string | null
948
988
  channel: HookReplyChannel | null
@@ -1287,20 +1327,31 @@ export class PermissionBroker {
1287
1327
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
1288
1328
  }
1289
1329
 
1290
- /** X1: the two switches now; both off when absent or unreadable. */
1330
+ /** X1: the two switches now; both off when absent or unreadable. 7.0.34: plus the question default. */
1291
1331
  private fullHoldSwitches(): FullHoldSwitches {
1292
1332
  try {
1293
1333
  const on = this.deps.fullHold?.()
1294
- return { questions: on?.questions === true, approvals: on?.approvals === true }
1334
+ return { questions: on?.questions === true, approvals: on?.approvals === true, questionsByDefault: on?.questionsByDefault === true }
1295
1335
  } catch {
1296
- return { questions: false, approvals: false }
1336
+ return { questions: false, approvals: false, questionsByDefault: false }
1297
1337
  }
1298
1338
  }
1299
1339
 
1300
- /** X1: either switch is on (the web door then needs a touch, and an approval a confirm). */
1340
+ /**
1341
+ * X1: either switch is on (the web door then needs a touch, and an approval a confirm, and the `waiting` view
1342
+ * shows). 7.0.34: under the question DEFAULT this is on only once this Mac's hooks carry the long question block,
1343
+ * the same thing that makes its questions eligible (`defaultHoldTakesStamp` of the installed AskUserQuestion
1344
+ * block). Before that a Mac reads exactly as 7.0.33 with the switch off: no `waiting`, the same web door.
1345
+ */
1301
1346
  private anyFullHoldOn(): boolean {
1302
1347
  const on = this.fullHoldSwitches()
1303
- return on.questions || on.approvals
1348
+ if (on.questions || on.approvals) return true
1349
+ return on.questionsByDefault === true && defaultHoldTakesStamp(this.installedHookTimeoutS('question'))
1350
+ }
1351
+
1352
+ /** X1, 7.0.34: a question of this kind may be fully held under the switches now: on, or the default with a long stamp. */
1353
+ private questionHoldAllows(on: FullHoldSwitches, hookWaitS: number | null | undefined): boolean {
1354
+ return on.questions || (on.questionsByDefault === true && defaultHoldTakesStamp(hookWaitS))
1304
1355
  }
1305
1356
 
1306
1357
  /**
@@ -1312,7 +1363,7 @@ export class PermissionBroker {
1312
1363
  */
1313
1364
  private fullHoldEligible(isQuestion: boolean, askHold: boolean, background: boolean, facts: Omit<PermissionRequestFacts, 'fingerprint'>): boolean {
1314
1365
  const on = this.fullHoldSwitches()
1315
- if (isQuestion ? !on.questions : !on.approvals) return false
1366
+ if (isQuestion ? !this.questionHoldAllows(on, facts.hookWaitS) : !on.approvals) return false
1316
1367
  if (background || facts.provider === 'codex') return false
1317
1368
  if (isQuestion && !askHold) return false
1318
1369
  if (facts.hookWaitS === null) return false
@@ -1485,7 +1536,10 @@ export class PermissionBroker {
1485
1536
  ...(webOnly ? { webOnly: true as const, webPresenceAgeMs: Math.round(presence.webAgeMs ?? 0) } : {}),
1486
1537
  ...(full ? { fullHold: true as const } : {}),
1487
1538
  }
1488
- return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || (idle !== null && idle >= this.deskIdleThreshold()), ...(webOnly ? { webOnly: true } : {}), ...(full ? { fullHold: true, enginePid } : {}), hold: holdFacts } }
1539
+ // X1 (Miles 11/12 17:42, "At once, on all three"): a fully held question is on the web and the glasses the moment
1540
+ // it is asked, beside the Mac's own dialog; the first answer wins. Only a full-hold item (its switch on, engine
1541
+ // proven): with the switches off, a stamped question still waits for the desk to be idle, exactly as 7.0.31.
1542
+ return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || full || (idle !== null && idle >= this.deskIdleThreshold()), ...(webOnly ? { webOnly: true } : {}), ...(full ? { fullHold: true, enginePid } : {}), hold: holdFacts } }
1489
1543
  }
1490
1544
 
1491
1545
  /** Hold the hook's response until exactly one resolution. Returns the item id. */
@@ -1675,7 +1729,7 @@ export class PermissionBroker {
1675
1729
  * clientAnswerId) replays its 200. `handBack` wins over any answer fields in the same body:
1676
1730
  * it is the safe direction, since it only shows the dialog that would have shown anyway.
1677
1731
  */
1678
- answer(id: string, body: unknown, by: AnswerSurface = 'app'): AnswerResult {
1732
+ answer(id: string, body: unknown, by: AnswerSurface = 'app', client?: unknown): AnswerResult {
1679
1733
  const item = BROKER_ID_RE.test(id) ? this.items.get(id) : undefined
1680
1734
  if (!item) return { status: 404, body: { error: 'not_found' } }
1681
1735
  const input = isRecord(body) ? body : {}
@@ -1700,7 +1754,9 @@ export class PermissionBroker {
1700
1754
  if (handBack) return this.handBack(item, clientAnswerId)
1701
1755
  // Q1 O1: while a switch is on, an item no device is shown yet (the desk-idle latch has not fired) is
1702
1756
  // not answerable by its id, from any door. A hand-back stays allowed: it only shows the Mac's dialog.
1703
- if (!item.lensVisible && (this.anyFullHoldOn() || item.fullHold)) {
1757
+ // X1 (at once): a full-hold item is shown the moment it is parked, so only a stamped question the full hold
1758
+ // did not take can be unshown here.
1759
+ if (!item.lensVisible && this.anyFullHoldOn()) {
1704
1760
  this.log(`not_shown ${item.id} kind=${item.kind}`)
1705
1761
  return { status: 409, body: { error: 'not_shown' } }
1706
1762
  }
@@ -1740,7 +1796,7 @@ export class PermissionBroker {
1740
1796
  }
1741
1797
  item.state = 'answered'
1742
1798
  item.settledAt = this.deps.now()
1743
- item.answer = { clientAnswerId, chosen }
1799
+ item.answer = { clientAnswerId, chosen, surface: answerSurfaceOf(by, client) }
1744
1800
  item.channel = null
1745
1801
  item.toolInput = {}
1746
1802
  item.questions = null
@@ -1918,6 +1974,11 @@ export class PermissionBroker {
1918
1974
 
1919
1975
  waitingList(): WaitingQuestionView[] {
1920
1976
  if (!this.extendedQuestionsView()) return []
1977
+ return this.waitingViews()
1978
+ }
1979
+
1980
+ /** X1: the waiting views, whatever the switches (the queue needs them; `waitingList` is gated as before). */
1981
+ private waitingViews(): WaitingQuestionView[] {
1921
1982
  const now = this.deps.now()
1922
1983
  this.pruneWaiting(now)
1923
1984
  const answerable = (item: BrokerItem): boolean => item.state === 'pending' && item.lensVisible && now < item.deadlineAt
@@ -1962,6 +2023,60 @@ export class PermissionBroker {
1962
2023
  return views.sort((a, b) => a.askedAt.localeCompare(b.askedAt))
1963
2024
  }
1964
2025
 
2026
+ /**
2027
+ * X1 (the one queue): every question and approval a live session waits on that this server knows of, whatever
2028
+ * the switches, plus what was answered in the last QUEUE_ANSWERED_KEEP_MS with where. Questions come from the
2029
+ * waiting entries (held or at the Mac) and from settled questions the Mac still waits on that no entry speaks
2030
+ * for (asked before a restart); approvals from pending items and settled ones the Mac still waits on. Text is
2031
+ * kept only while it waits, as everywhere in this file. Turn-end asks are added by the queue builder.
2032
+ */
2033
+ queueSources(): BrokerQueueSource[] {
2034
+ const now = this.deps.now()
2035
+ this.prune()
2036
+ const out: BrokerQueueSource[] = []
2037
+ const views = this.waitingViews()
2038
+ const settledSurface = (match: (item: BrokerItem) => boolean): QueueSurface | null => {
2039
+ for (const item of this.items.values()) if (item.state === 'answered' && item.answer && item.answer.clientAnswerId !== 'cos-cancel' && match(item)) return item.answer.surface ?? 'cos'
2040
+ return null
2041
+ }
2042
+ for (const v of views) {
2043
+ if (v.state === 'answered') {
2044
+ // `waitingViews` lists an answered question only within WAITING_ANSWERED_KEEP_MS (= QUEUE_ANSWERED_KEEP_MS).
2045
+ const answeredAt = v.answeredAt ? Date.parse(v.answeredAt) : null
2046
+ if (answeredAt === null) continue
2047
+ const by: QueueSurface = v.answeredBy === 'cos'
2048
+ ? settledSurface(item => item.kind === 'question' && item.sessionId === v.sessionId && (!!v.toolUseId && item.toolUseId === v.toolUseId)) ?? 'cos'
2049
+ : 'mac'
2050
+ out.push({ key: v.key, sessionId: v.sessionId, provider: v.provider, kind: 'question', tool: ASK_USER_QUESTION_TOOL, questions: [], approval: null, askedAt: Date.parse(v.askedAt), where: 'mac', heldId: null, deadlineAt: null, state: 'answered', answeredBy: by, answeredAt })
2051
+ continue
2052
+ }
2053
+ out.push({ key: v.key, sessionId: v.sessionId, provider: v.provider, kind: 'question', tool: ASK_USER_QUESTION_TOOL, questions: v.questions, approval: null, askedAt: Date.parse(v.askedAt), where: v.answerable ? 'here' : 'mac', heldId: v.heldId, deadlineAt: v.deadlineAt ? Date.parse(v.deadlineAt) : null, state: 'waiting', answeredBy: null, answeredAt: null })
2054
+ }
2055
+ const listedQuestion = (item: BrokerItem): boolean => views.some(v => v.heldId === item.id || (v.sessionId === item.sessionId && !!v.toolUseId && v.toolUseId === item.toolUseId))
2056
+ for (const item of this.items.values()) {
2057
+ const key = `${item.sessionId}:${item.toolUseId ?? item.id}`
2058
+ const base = { key, sessionId: item.sessionId, provider: item.provider, kind: item.kind, tool: item.toolName, askedAt: item.requestedAt }
2059
+ if (item.kind === 'question') {
2060
+ // A question the Mac still waits on that no waiting entry speaks for (asked before a restart): no words.
2061
+ if (item.state !== 'pending' && item.waitingAtMac && !listedQuestion(item)) {
2062
+ out.push({ ...base, questions: [], approval: null, where: 'mac', heldId: null, deadlineAt: null, state: 'waiting', answeredBy: null, answeredAt: null })
2063
+ }
2064
+ continue
2065
+ }
2066
+ if (item.state === 'pending') {
2067
+ if (!item.lensVisible || now >= item.deadlineAt) continue
2068
+ out.push({ ...base, questions: [], approval: item.approval, where: 'here', heldId: item.id, deadlineAt: item.deadlineAt, state: 'waiting', answeredBy: null, answeredAt: null })
2069
+ } else if (item.waitingAtMac) {
2070
+ out.push({ ...base, questions: [], approval: null, where: 'mac', heldId: null, deadlineAt: null, state: 'waiting', answeredBy: null, answeredAt: null })
2071
+ } else if (item.settledAt !== null && now - item.settledAt <= QUEUE_ANSWERED_KEEP_MS && (item.state === 'answered' || item.state === 'retracted')) {
2072
+ const by: QueueSurface | null = item.state === 'retracted' ? 'mac' : item.answer?.clientAnswerId === 'cos-cancel' ? null : (item.answer?.surface ?? 'cos')
2073
+ if (by === null) continue
2074
+ out.push({ ...base, questions: [], approval: null, where: 'mac', heldId: null, deadlineAt: null, state: 'answered', answeredBy: by, answeredAt: item.settledAt })
2075
+ }
2076
+ }
2077
+ return out
2078
+ }
2079
+
1965
2080
  /** X1: what a web page must send with an answer right now (`webAnswer` on the questions route). */
1966
2081
  webAnswerRules(): { touchRequired: boolean; touchMaxMs: number; confirmApprovals: boolean } {
1967
2082
  const on = this.anyFullHoldOn()
@@ -2102,7 +2217,8 @@ export class PermissionBroker {
2102
2217
  // The switches are read only while one is held, so with both off nothing here runs.
2103
2218
  if ([...this.items.values()].some(item => item.state === 'pending' && item.fullHold)) {
2104
2219
  const on = this.fullHoldSwitches()
2105
- this.settleAll('handed_to_desk', item => item.fullHold && (item.kind === 'question' ? !on.questions : !on.approvals))
2220
+ // 7.0.34: a question held under the default stays held while the default (or on) still stands; off hands it back.
2221
+ this.settleAll('handed_to_desk', item => item.fullHold && (item.kind === 'question' ? !(on.questions || on.questionsByDefault === true) : !on.approvals))
2106
2222
  if (this.pendingCount() === 0) return
2107
2223
  // X1 round 3: a held item whose settling hook event never arrived (a lost spool event) must not sit
2108
2224
  // to its deadline. Its own engine's registry record says whether the session moved on: the record is
@@ -0,0 +1,38 @@
1
+ // X1, 7.0.34: the server-side question-hold setting that Control's Settings row writes
2
+ // (`PUT /api/session-hooks/question-hold`). One small file in the COS home: `{"mode":"on"}` or `{"mode":"off"}`.
3
+ // No file means the default (lib/full-hold.ts `questionHoldMode`). `COS_QUESTION_FULL_HOLD` in the environment
4
+ // always wins over this file; the route says so (`source: "env"`) rather than writing something that cannot apply.
5
+ //
6
+ // Read on every call, never cached: the broker reads it per question and the status per refresh, and a stale
7
+ // value after a Settings click would be a hold the person just turned off. The file is a few bytes.
8
+
9
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { atomicWriteFileSync } from './atomic-fs.js'
12
+ import { cosGlassesHome } from './claude-hooks-installer.js'
13
+ import type { QuestionHoldSetting } from './full-hold.js'
14
+
15
+ export const QUESTION_HOLD_SETTING_FILE = 'question-hold.json'
16
+
17
+ export function questionHoldSettingPath(home: string = cosGlassesHome()): string {
18
+ return join(home, QUESTION_HOLD_SETTING_FILE)
19
+ }
20
+
21
+ /** `on`, `off`, or null for no setting (the default). An unreadable or malformed file is no setting. */
22
+ export function readQuestionHoldSetting(path: string = questionHoldSettingPath()): QuestionHoldSetting | null {
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { mode?: unknown }
25
+ return parsed && (parsed.mode === 'on' || parsed.mode === 'off') ? parsed.mode : null
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /** `on` or `off` writes the file; `default` removes it. */
32
+ export function writeQuestionHoldSetting(mode: QuestionHoldSetting | 'default', path: string = questionHoldSettingPath()): void {
33
+ if (mode === 'default') {
34
+ if (existsSync(path)) rmSync(path, { force: true })
35
+ return
36
+ }
37
+ atomicWriteFileSync(path, JSON.stringify({ mode }) + '\n', { mode: 0o600 })
38
+ }
@@ -0,0 +1,111 @@
1
+ // X1 (Miles 2026-11-12 17:31, "a consistent way ... to access active questions that are in waiting and in queue"):
2
+ // ONE list of everything a session waits on him for, oldest first, built here once and served additively on
3
+ // GET /api/session-questions as `queue` and `queueCount`, so Control Web, the glasses and the Mac app list and
4
+ // count the same things. Present whatever the full-hold switches say: it is also how he finds a question that
5
+ // only the Mac's own dialog can answer.
6
+ //
7
+ // WHAT IS IN IT: questions COS holds (answerable here) or that wait at the Mac's dialog; tool approvals held or at
8
+ // the Mac (listed so they are found; answering them keeps every existing rule, `where` says which door); turn-end
9
+ // asks a row shows; and, for QUEUE_ANSWERED_KEEP_MS, what was just answered and on which surface, so a card can say
10
+ // "Answered on the glasses" instead of vanishing.
11
+ //
12
+ // PRIVACY: the only words are `line` (a question's first 80 characters, cleaned; an approval's existing summary;
13
+ // a turn-end ask's 80 characters) and a turn-end ask's reply labels, the same text the questions route, the
14
+ // session rows and the cards already carry to the same authenticated clients (the API token or the paired web
15
+ // session). With COS_WAITING_QUESTION_WORDS=0 a question's line is ''. Nothing here is logged.
16
+
17
+ import { questionRowText } from './full-hold.js'
18
+ import type { BrokerQueueSource, QueueSurface } from './permission-broker.js'
19
+ import type { AnsweredTurnAsk, QueueTurnAsk } from './turn-end-ask.js'
20
+
21
+ export const QUEUE_LINE_MAX = 80
22
+ /** Entries past this are dropped, oldest answered first, then oldest waiting. */
23
+ export const QUEUE_MAX = 64
24
+
25
+ export type QueueKind = 'question' | 'approval' | 'turn_end'
26
+
27
+ export interface QueueEntry {
28
+ key: string
29
+ sessionId: string
30
+ /** The session's title as the session list last showed it, or null (the client uses its own). */
31
+ title: string | null
32
+ provider: 'claude' | 'codex'
33
+ kind: QueueKind
34
+ /** One line: the question, the approval's summary, or the turn-end ask. '' when words are off or unknown. */
35
+ line: string
36
+ askedAt: string
37
+ /** `here`: answer it from COS now (`heldId` for questions and approvals; a turn-end ask by a reply). `mac`: only the Mac's dialog. */
38
+ where: 'here' | 'mac'
39
+ heldId: string | null
40
+ deadlineAt: string | null
41
+ /** A turn-end ask's reply labels (0 to 2); [] otherwise. */
42
+ choices: string[]
43
+ state: 'waiting' | 'answered'
44
+ answeredBy: QueueSurface | null
45
+ answeredAt: string | null
46
+ }
47
+
48
+ export interface QuestionQueue { queue: QueueEntry[]; queueCount: number }
49
+
50
+ export interface QueueInput {
51
+ broker: readonly BrokerQueueSource[]
52
+ turnEnd: readonly QueueTurnAsk[]
53
+ turnEndAnswered: readonly AnsweredTurnAsk[]
54
+ titleFor(sessionId: string): string | null
55
+ /** COS_WAITING_QUESTION_WORDS. */
56
+ words: boolean
57
+ }
58
+
59
+ function clip(text: string, max = QUEUE_LINE_MAX): string {
60
+ const t = questionRowText(text).replace(/\s+/g, ' ').trim()
61
+ return t.length > max ? t.slice(0, max - 1) + '…' : t
62
+ }
63
+
64
+ const iso = (ms: number | null): string | null => ms === null || !Number.isFinite(ms) ? null : new Date(ms).toISOString()
65
+
66
+ function safeTitle(titleFor: QueueInput['titleFor'], sessionId: string): string | null {
67
+ try { return titleFor(sessionId) } catch { return null }
68
+ }
69
+
70
+ export function buildQuestionQueue(input: QueueInput): QuestionQueue {
71
+ const entries: QueueEntry[] = []
72
+ for (const s of input.broker) {
73
+ const line = s.kind === 'question'
74
+ ? (input.words && s.questions.length > 0 ? clip(s.questions[0]!.question) : '')
75
+ : (s.approval ? clip(s.approval.summary) : '')
76
+ entries.push({
77
+ key: s.key, sessionId: s.sessionId, title: safeTitle(input.titleFor, s.sessionId), provider: s.provider, kind: s.kind,
78
+ line, askedAt: iso(s.askedAt)!, where: s.where, heldId: s.heldId, deadlineAt: iso(s.deadlineAt), choices: [],
79
+ state: s.state, answeredBy: s.answeredBy, answeredAt: iso(s.answeredAt),
80
+ })
81
+ }
82
+ for (const a of input.turnEnd) {
83
+ entries.push({
84
+ key: `${a.sessionId}:turn_end:${a.stoppedAt}`, sessionId: a.sessionId, title: safeTitle(input.titleFor, a.sessionId), provider: 'claude',
85
+ kind: 'turn_end', line: clip(a.ask), askedAt: iso(a.stoppedAt)!, where: 'here', heldId: null, deadlineAt: null,
86
+ choices: [...a.choices], state: 'waiting', answeredBy: null, answeredAt: null,
87
+ })
88
+ }
89
+ for (const a of input.turnEndAnswered) {
90
+ entries.push({
91
+ key: `${a.sessionId}:turn_end:${a.stoppedAt}`, sessionId: a.sessionId, title: safeTitle(input.titleFor, a.sessionId), provider: 'claude',
92
+ kind: 'turn_end', line: clip(a.ask), askedAt: iso(a.stoppedAt)!, where: 'here', heldId: null, deadlineAt: null,
93
+ choices: [], state: 'answered', answeredBy: a.by, answeredAt: iso(a.answeredAt),
94
+ })
95
+ }
96
+ // One entry per key (a waiting and an answered copy of the same ask: the answered one wins).
97
+ const byKey = new Map<string, QueueEntry>()
98
+ for (const e of entries) {
99
+ const had = byKey.get(e.key)
100
+ if (!had || (had.state === 'waiting' && e.state === 'answered')) byKey.set(e.key, e)
101
+ }
102
+ let queue = [...byKey.values()].sort((a, b) => a.askedAt.localeCompare(b.askedAt) || a.key.localeCompare(b.key))
103
+ if (queue.length > QUEUE_MAX) {
104
+ const waiting = queue.filter(e => e.state === 'waiting')
105
+ const answered = queue.filter(e => e.state === 'answered')
106
+ const keepAnswered = Math.max(0, QUEUE_MAX - waiting.length)
107
+ const keep = new Set([...waiting.slice(-QUEUE_MAX), ...answered.slice(answered.length - keepAnswered)])
108
+ queue = queue.filter(e => keep.has(e))
109
+ }
110
+ return { queue, queueCount: queue.filter(e => e.state === 'waiting').length }
111
+ }
@@ -23,7 +23,7 @@ import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatu
23
23
  import { codexHooksHealthField, cursorObserverHealthField, type CodexHooksHealth, type CursorObserverHealth } from './provider-observe.js'
24
24
  import { readHidIdleSeconds } from './permission-broker.js'
25
25
  import { waitingQuestionWordsEnabled } from './full-hold.js'
26
- import { TurnEndAskTracker, turnEndAsksEnabled, type TurnEndAskCounters } from './turn-end-ask.js'
26
+ import { TURN_ASK_MAX_AGE_MS, TurnEndAskTracker, turnEndAsksEnabled, type AnsweredTurnAsk, type QueueTurnAsk, type TurnEndAskCounters } from './turn-end-ask.js'
27
27
 
28
28
  export function sessionHooksEnabled(): boolean {
29
29
  const raw = process.env.COS_SESSION_HOOKS
@@ -44,6 +44,11 @@ export function deskIdleSeconds(): number {
44
44
  /** Replay this much ledger history into the store at boot. */
45
45
  export const LEDGER_REPLAY_WINDOW_MS = 6 * 60 * 60_000
46
46
 
47
+ /** X1: how much further back than the store the turn-end ask tracker replays: its age-out minus the store's window (2 h). */
48
+ export function turnAskReplayExtraMs(): number {
49
+ return Math.max(0, TURN_ASK_MAX_AGE_MS - LEDGER_REPLAY_WINDOW_MS)
50
+ }
51
+
47
52
  // A hook's ppid is the claude process itself: macOS `sh -c '<script> <Event>'` execs the
48
53
  // single command, and every recorded session (fixtures, 2026-09-15) shows one ppid across all
49
54
  // of its events. The `ps` parent lookup below is the fallback for a shell that does not exec;
@@ -288,12 +293,22 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
288
293
  }) : null
289
294
  const tracker = turnEndAskTracker
290
295
  const unsubscribeTurnAsk = tracker ? sessionSignalStore.subscribe((signal, env, child) => tracker.observe(env, child, signal)) : () => {}
291
- const replay = ledger.replay(since, (env, _key, child, entrypoint) => {
296
+ // X1: a turn-end ask stays answerable TURN_ASK_MAX_AGE_MS (8 h) but the store replays LEDGER_REPLAY_WINDOW_MS (6 h),
297
+ // so an ask asked 6 to 8 h before a restart was lost. The tracker alone reads back to its own age-out: rows older
298
+ // than the store's window go to the tracker only, never into the store, so every other signal replays as before.
299
+ const trackerSince = tracker ? since - turnAskReplayExtraMs() : since
300
+ let trackerOnly = 0
301
+ const replay = ledger.replay(trackerSince, (env, _key, child, entrypoint) => {
292
302
  if (!enabled) return
303
+ if (env.ts < since) {
304
+ trackerOnly++
305
+ tracker?.observe(env, child, { entrypoint })
306
+ return
307
+ }
293
308
  sessionSignalStore.apply(env, child)
294
309
  if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
295
310
  })
296
- replayed = { rows: replay.rows, applied: replay.applied }
311
+ replayed = { rows: replay.rows, applied: replay.applied - trackerOnly }
297
312
  ingester = startSpoolIngester({
298
313
  dir,
299
314
  ledger,
@@ -359,6 +374,8 @@ export interface SessionHooksHealth {
359
374
  /** The install state word; `installed` below is its boolean. */
360
375
  state: HookStatus['state']
361
376
  installed: boolean
377
+ /** X1, 7.0.34: the question hold for this Mac (additive). */
378
+ questionWait?: HookStatus['questionWait']
362
379
  scriptSha: string | null
363
380
  /**
364
381
  * 6.60.0: our PermissionRequest hook's `timeout` in the Claude settings file (630 after
@@ -418,6 +435,8 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
418
435
  installed: status.installed,
419
436
  scriptSha: status.scriptSha,
420
437
  permissionHookTimeoutS: status.permissionHookTimeoutS,
438
+ // X1, 7.0.34: additive. `state` is untouched; Control 0.5.394 and Control Web read this to offer the long wait.
439
+ ...(status.questionWait ? { questionWait: status.questionWait } : {}),
421
440
  priorWaitOnly: status.priorWaitOnly,
422
441
  tokenPresent: status.tokenPresent,
423
442
  lastEventAt: newest ? new Date(newest).toISOString() : null,
@@ -515,6 +534,12 @@ export function turnEndAskStats(): (TurnEndAskCounters & { tracked: number }) |
515
534
  return turnEndAskTracker ? turnEndAskTracker.stats() : null
516
535
  }
517
536
 
537
+ /** X1 (the one queue): the turn-end asks a row shows now, and those answered in the last two minutes. Empty when off. */
538
+ export function turnEndAsksForQueue(): { asks: QueueTurnAsk[]; answered: AnsweredTurnAsk[] } {
539
+ if (!turnEndAskTracker) return { asks: [], answered: [] }
540
+ return { asks: turnEndAskTracker.visibleAsks(), answered: turnEndAskTracker.recentlyAnswered() }
541
+ }
542
+
518
543
  /** Tests only: the tracker the runtime would build, installed directly. */
519
544
  export function __setTurnEndAskTrackerForTests(tracker: TurnEndAskTracker | null): void {
520
545
  turnEndAskTracker?.stop()
@@ -0,0 +1,40 @@
1
+ // X1 (the one queue, 7.0.32 candidate): the last title COS showed for each session, so the questions route can name
2
+ // the asking session without reading a transcript on a 3 s poll. Filled by the session list route from the rows it
3
+ // already built; read by `lib/question-queue.ts`. Memory only, bounded; a session not listed yet reads null and a
4
+ // client falls back to its own list. The titles are the list's own (the same authenticated clients already get them).
5
+
6
+ /** At most this many sessions are remembered; past it the oldest noted go first. */
7
+ export const SESSION_TITLE_CACHE_MAX = 400
8
+ const TITLE_MAX = 120
9
+
10
+ const titles = new Map<string, string>()
11
+
12
+ /** Remember a session's title (lower-cased id). Blank titles are ignored. */
13
+ export function noteSessionTitle(sessionId: unknown, title: unknown): void {
14
+ if (typeof sessionId !== 'string' || !sessionId || typeof title !== 'string') return
15
+ const text = title.replace(/\s+/g, ' ').trim()
16
+ if (!text) return
17
+ const key = sessionId.toLowerCase()
18
+ titles.delete(key)
19
+ titles.set(key, text.length > TITLE_MAX ? text.slice(0, TITLE_MAX - 1) + '…' : text)
20
+ while (titles.size > SESSION_TITLE_CACHE_MAX) {
21
+ const oldest = titles.keys().next().value
22
+ if (oldest === undefined) break
23
+ titles.delete(oldest)
24
+ }
25
+ }
26
+
27
+ /** The last title COS listed for this session, or null. Takes a full id or the registry's eight-character form. */
28
+ export function sessionTitleFor(sessionId: string): string | null {
29
+ const key = sessionId.toLowerCase()
30
+ const exact = titles.get(key)
31
+ if (exact !== undefined) return exact
32
+ if (key.length >= 8 && key.length < 36) {
33
+ for (const [id, title] of titles) if (id.startsWith(key)) return title
34
+ }
35
+ return null
36
+ }
37
+
38
+ export function __resetSessionTitlesForTests(): void {
39
+ titles.clear()
40
+ }