@gotcos/glasses-server 6.99.33 → 7.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -140,6 +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
144
 
144
145
  // ---------------------------------------------------------------------------
145
146
  // Configuration
@@ -825,6 +826,31 @@ export interface PermissionBrokerDeps {
825
826
  pollMs?: number
826
827
  newId?: () => string
827
828
  log?: (line: string) => void
829
+ /**
830
+ * X1: the two full-hold switches (`COS_QUESTION_FULL_HOLD`, `COS_APPROVAL_FULL_HOLD`), read per
831
+ * request and per tick. Absent or throwing: both off, and nothing below reads the registry.
832
+ */
833
+ fullHold?(): FullHoldSwitches
834
+ /**
835
+ * X1 (Q1 O2): the facts of the engine that ran THIS hook: its own registry record (the hook's
836
+ * `ppid`), only when that record names the session, and where it runs (`lib/full-hold-engine.ts`).
837
+ * Read only while a switch is on and the request is otherwise eligible. Absent: not proven.
838
+ */
839
+ engineFor?(sessionId: string, ppid: number | null): EngineFacts | null | Promise<EngineFacts | null>
840
+ /**
841
+ * X1: our PermissionRequest `timeout` for this kind once the hook is split (question block vs the
842
+ * rest); the one number while it is not. Read only by the full hold. Absent: `installedHookTimeoutS`.
843
+ */
844
+ installedHookTimeoutForKindS?(kind: BrokerItemKind): number | null
845
+ /**
846
+ * X1 round 3: the status of the engine process that asked (`lib/full-hold-engine.ts`
847
+ * `registryStatusForPid`): null when its record is gone or names another session. Read each tick for
848
+ * full-hold items only, so a held item whose settling hook event was lost does not sit to its
849
+ * deadline. Absent: the tick check is off.
850
+ */
851
+ engineStatus?(sessionId: string, pid: number): { status: string | null; statusUpdatedAt: number | null } | null
852
+ /** X1: a new confirm id (tests). Absent: `pc_` and 24 random hex. */
853
+ newConfirmId?: () => string
828
854
  }
829
855
 
830
856
  /** 6.60.0: how a parked request's deadline was decided, logged once per request. Numbers only. */
@@ -853,6 +879,8 @@ export interface HoldFacts {
853
879
  webOnly?: true
854
880
  /** 6.99.26: the age of that touch, present only with `webOnly`. */
855
881
  webPresenceAgeMs?: number
882
+ /** X1: held for the whole hook wait (`fullHold`); absent for every other request. */
883
+ fullHold?: true
856
884
  }
857
885
 
858
886
  /**
@@ -886,6 +914,10 @@ export interface ParkRequest {
886
914
  lensVisible?: boolean
887
915
  /** 6.99.26: admitted for COS Control Web's recent touch alone (see THE AWAY HOLD). */
888
916
  webOnly?: boolean
917
+ /** X1: held for the whole hook wait under a full-hold switch (`lib/full-hold.ts`). */
918
+ fullHold?: boolean
919
+ /** X1 round 3: the engine process that asked (the hook's ppid), for the registry tick check. */
920
+ enginePid?: number | null
889
921
  hold?: HoldFacts
890
922
  }
891
923
 
@@ -923,6 +955,10 @@ interface BrokerItem {
923
955
  webOnly: boolean
924
956
  toolUseId: string | null
925
957
  questionFingerprint: string
958
+ /** X1: held for the whole hook wait (`ParkRequest.fullHold`). */
959
+ fullHold: boolean
960
+ /** X1 round 3: the engine process that asked, for full-hold items; null otherwise. */
961
+ enginePid: number | null
926
962
  /**
927
963
  * 6.60.0: settled with `{}` written to the hook (handed to the desk, expired, no client,
928
964
  * drained), so the Mac's own dialog is showing, and the session has not moved past the
@@ -948,6 +984,56 @@ export interface SessionQuestionView {
948
984
  awayHold: boolean
949
985
  /** 6.60.0: handed to the Mac's own dialog and still waiting there. Always false for a pending item. */
950
986
  waitingAtMac: boolean
987
+ /** X1: the engine's tool_use id, when the hook sent one: the key that matches a `waiting` entry. Absent when unknown. */
988
+ toolUseId?: string
989
+ /** X1: held for the whole hook wait. Present (true) only then. */
990
+ fullHold?: true
991
+ }
992
+
993
+ /**
994
+ * X1: a question a live session waits on, as the questions route lists it (`waiting`). In memory
995
+ * only, from the live PreToolUse event (the ledger keeps no question text); never logged.
996
+ * - `held`: a pending broker item a COS surface can answer now (`heldId`);
997
+ * - `at_mac`: only the Mac's own prompt can answer it;
998
+ * - `answered`: the session moved past it (kept WAITING_ANSWERED_KEEP_MS, words dropped).
999
+ */
1000
+ export type WaitingQuestionState = 'held' | 'at_mac' | 'answered'
1001
+ export interface WaitingQuestionView {
1002
+ key: string
1003
+ sessionId: string
1004
+ provider: 'claude' | 'codex'
1005
+ toolUseId: string | null
1006
+ state: WaitingQuestionState
1007
+ answerable: boolean
1008
+ heldId: string | null
1009
+ questions: BrokerQuestion[]
1010
+ askedAt: string
1011
+ deadlineAt: string | null
1012
+ answeredAt: string | null
1013
+ answeredBy: 'mac' | 'cos' | null
1014
+ }
1015
+
1016
+ /** X1: an answered `waiting` entry is listed this long, then dropped. */
1017
+ export const WAITING_ANSWERED_KEEP_MS = 120_000
1018
+ /** X1: a `waiting` entry older than this is dropped, answered or not (a lost PostToolUse). */
1019
+ export const WAITING_MAX_AGE_MS = 24 * 60 * 60_000
1020
+ /** X1: at most this many `waiting` entries; past it the oldest go first. */
1021
+ export const MAX_WAITING = 64
1022
+ /** X1: at most this many unexpired web confirm ids at once. */
1023
+ export const MAX_CONFIRMS = 64
1024
+ export const CONFIRM_ID_RE = /^pc_[0-9a-f]{24}$/
1025
+
1026
+ interface WaitingEntry {
1027
+ key: string
1028
+ sessionId: string
1029
+ provider: 'claude' | 'codex'
1030
+ toolUseId: string | null
1031
+ questionFingerprint: string
1032
+ questions: BrokerQuestion[] | null
1033
+ askedAt: number
1034
+ state: 'waiting' | 'answered'
1035
+ answeredAt: number | null
1036
+ answeredBy: 'mac' | 'cos' | null
951
1037
  }
952
1038
 
953
1039
  /**
@@ -1071,6 +1157,14 @@ function sessionMovedPast(item: Pick<BrokerItem, 'kind' | 'fingerprint' | 'toolN
1071
1157
  return false
1072
1158
  }
1073
1159
 
1160
+ /**
1161
+ * X1: a web answer's own touch: the whole number of ms since the page's last real touch or key while
1162
+ * it was in front, at most WEB_ANSWER_TOUCH_MAX_MS. Anything else is no touch.
1163
+ */
1164
+ export function validWebTouch(value: unknown): boolean {
1165
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= WEB_ANSWER_TOUCH_MAX_MS
1166
+ }
1167
+
1074
1168
  /** A presence age from a dep: a finite number of ms at or above 0, else null (absent, throwing or nonsense). */
1075
1169
  function readAgeMs(read: () => number | null): number | null {
1076
1170
  let ageMs: number | null = null
@@ -1089,6 +1183,10 @@ export class PermissionBroker {
1089
1183
  private readonly fastPaths: Record<string, number> = {}
1090
1184
  private lastFastPath: BrokerFastPath | null = null
1091
1185
  private lastFastPathAt: number | null = null
1186
+ /** X1: questions live sessions wait on (`waiting` on the questions route). In memory only. */
1187
+ private readonly waiting = new Map<string, WaitingEntry>()
1188
+ /** X1: unexpired web confirm ids for approvals: single use, bound to one item and one decision. */
1189
+ private readonly confirms = new Map<string, { itemId: string; decision: 'allow' | 'deny'; expiresAt: number }>()
1092
1190
 
1093
1191
  /**
1094
1192
  * 6.60.0 (QA round 1, Ghost Hunter W1): the server cannot know about a waiting-at-the-Mac
@@ -1182,12 +1280,65 @@ export class PermissionBroker {
1182
1280
  }
1183
1281
 
1184
1282
  /** 6.60.0: the installed hook timeout, or null when absent or unreadable. */
1185
- private installedHookTimeoutS(): number | null {
1283
+ private installedHookTimeoutS(kind?: BrokerItemKind): number | null {
1186
1284
  let value: number | null = null
1187
- try { value = this.deps.installedHookTimeoutS?.() ?? null } catch { value = null }
1285
+ // X1: the full hold reads its own kind's block (`installedHookTimeoutForKindS`); everything else the one number, as before.
1286
+ try { value = (kind !== undefined && this.deps.installedHookTimeoutForKindS ? this.deps.installedHookTimeoutForKindS(kind) : this.deps.installedHookTimeoutS?.()) ?? null } catch { value = null }
1188
1287
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
1189
1288
  }
1190
1289
 
1290
+ /** X1: the two switches now; both off when absent or unreadable. */
1291
+ private fullHoldSwitches(): FullHoldSwitches {
1292
+ try {
1293
+ const on = this.deps.fullHold?.()
1294
+ return { questions: on?.questions === true, approvals: on?.approvals === true }
1295
+ } catch {
1296
+ return { questions: false, approvals: false }
1297
+ }
1298
+ }
1299
+
1300
+ /** X1: either switch is on (the web door then needs a touch, and an approval a confirm). */
1301
+ private anyFullHoldOn(): boolean {
1302
+ const on = this.fullHoldSwitches()
1303
+ return on.questions || on.approvals
1304
+ }
1305
+
1306
+ /**
1307
+ * X1: whether THIS request is held for the whole hook wait: its kind's switch is on, it is a
1308
+ * Claude request that is not a background or child one, a question carries the 6.71.7 stamp (so
1309
+ * `hook-ask-hold` 0 still rolls questions back), the hook stamped its own wait (an earlier script
1310
+ * or block keeps the old clamp), and the engine is proven to keep its dialog up while the hook
1311
+ * waits (`engineProvenForFullHold`). The registry is read only when a switch is on.
1312
+ */
1313
+ private fullHoldEligible(isQuestion: boolean, askHold: boolean, background: boolean, facts: Omit<PermissionRequestFacts, 'fingerprint'>): boolean {
1314
+ const on = this.fullHoldSwitches()
1315
+ if (isQuestion ? !on.questions : !on.approvals) return false
1316
+ if (background || facts.provider === 'codex') return false
1317
+ if (isQuestion && !askHold) return false
1318
+ if (facts.hookWaitS === null) return false
1319
+ return true
1320
+ }
1321
+
1322
+ /** The engine probe, for an eligible request only: the hook's own process, proven or not. */
1323
+ private async fullHoldEngineProven(sessionId: string, body: unknown): Promise<number | null | false> {
1324
+ const raw = isRecord(body) ? body.ppid : undefined
1325
+ const ppid = typeof raw === 'number' && Number.isInteger(raw) && raw > 1 ? raw : null
1326
+ let engine: EngineFacts | null = null
1327
+ try { engine = (await this.deps.engineFor?.(sessionId, ppid)) ?? null } catch { engine = null }
1328
+ // Proven: the engine's pid (for the registry tick check); not proven: false.
1329
+ return engineProvenForFullHold(engine) ? ppid : false
1330
+ }
1331
+
1332
+ /**
1333
+ * X1: the full hold's deadline: the hook's own whole wait, `holdCeilingS` of this kind's installed
1334
+ * timeout and this invocation's stamp (10 s inside it, so `{}` lands before the hook gives up).
1335
+ */
1336
+ private fullHoldMs(hookWaitS: number | null, kind: BrokerItemKind): { holdMs: number; awayHoldMs: number; installedHookTimeoutS: number | null; ceilingS: number } {
1337
+ const installedHookTimeoutS = this.installedHookTimeoutS(kind)
1338
+ const ceilingS = holdCeilingS(installedHookTimeoutS, hookWaitS)
1339
+ return { holdMs: ceilingS * 1000, awayHoldMs: 0, installedHookTimeoutS, ceilingS }
1340
+ }
1341
+
1191
1342
  /**
1192
1343
  * 6.60.0: how long a request arriving now, from a hook that stamped `hookWaitS`, may be held:
1193
1344
  * the away hold when it applies (never shorter than the base timeout), else the base
@@ -1255,6 +1406,10 @@ export class PermissionBroker {
1255
1406
  let background = false
1256
1407
  try { background = this.deps.isBackgroundRequest?.(body) === true } catch { background = true }
1257
1408
  const askHold = isQuestion && facts.askHold === true && !background
1409
+ // Awaited only for an eligible request, so with the switches off nothing here yields or reads.
1410
+ const probe = this.fullHoldEligible(isQuestion, askHold, background, facts) ? await this.fullHoldEngineProven(facts.sessionId, body) : false
1411
+ const full = probe !== false
1412
+ const enginePid = probe === false ? null : probe
1258
1413
  const now = this.deps.now()
1259
1414
  const presence = this.presence(now)
1260
1415
  let holdPollAt: number | null = null
@@ -1271,18 +1426,18 @@ export class PermissionBroker {
1271
1426
  const deviceAdmits = askHold ? clientLive || presence.recent : clientLive || deviceAway
1272
1427
  // 6.99.26 (QA round 1): a recent touch on COS Control Web admits only what nothing else would, for the away
1273
1428
  // hold alone. A request a glasses or phone client admits keeps its 6.99.25 deadline, flag and hand-back.
1274
- const webOnly = !deviceAdmits && switchOn && ceilingAllowsAway && presence.webRecent
1429
+ const webOnly = !full && !deviceAdmits && switchOn && ceilingAllowsAway && presence.webRecent
1275
1430
  const away = deviceAway || webOnly
1276
1431
  const notAwayReason: NotAwayReason | null = away ? null
1277
1432
  : !switchOn ? 'hold_off'
1278
1433
  : !ceilingAllowsAway ? 'ceiling_at_base'
1279
1434
  : presence.ageMs === null ? 'no_hold_client'
1280
1435
  : 'stale_presence'
1281
- if (askHold && !deviceAdmits && !webOnly) return this.noteFastPath('no_client', { presence, clientLive })
1282
- const hold = this.holdMs(facts.hookWaitS, away)
1436
+ if (!full && askHold && !deviceAdmits && !webOnly) return this.noteFastPath('no_client', { presence, clientLive })
1437
+ const hold = full ? this.fullHoldMs(facts.hookWaitS, isQuestion ? 'question' : 'approval') : this.holdMs(facts.hookWaitS, away)
1283
1438
  // With it, a request is held whether or not a client is polling right now, so a lens or
1284
1439
  // phone that comes back can still answer it. Without it, no client is `{}`, as in 6.59.0.
1285
- if (!clientLive && !away) return this.noteFastPath('no_client', { notAway: notAwayReason!, presence, clientLive })
1440
+ if (!full && !clientLive && !away) return this.noteFastPath('no_client', { notAway: notAwayReason!, presence, clientLive })
1286
1441
  // The deadline counts from when the HOOK started, when its stamp is sane: a request the
1287
1442
  // server read late must still be answered before the hook's curl gives up.
1288
1443
  const requestedAt = facts.hookStartedAtMs !== null && facts.hookStartedAtMs <= now ? facts.hookStartedAtMs : now
@@ -1328,8 +1483,9 @@ export class PermissionBroker {
1328
1483
  recentWindowS: Math.round(presence.windowMs / 1000),
1329
1484
  notAwayReason,
1330
1485
  ...(webOnly ? { webOnly: true as const, webPresenceAgeMs: Math.round(presence.webAgeMs ?? 0) } : {}),
1486
+ ...(full ? { fullHold: true as const } : {}),
1331
1487
  }
1332
- return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || (idle !== null && idle >= this.deskIdleThreshold()), ...(webOnly ? { webOnly: true } : {}), hold: holdFacts } }
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 } }
1333
1489
  }
1334
1490
 
1335
1491
  /** Hold the hook's response until exactly one resolution. Returns the item id. */
@@ -1364,6 +1520,8 @@ export class PermissionBroker {
1364
1520
  lensVisible: request.lensVisible !== false,
1365
1521
  toolUseId: facts.toolUseId ?? null,
1366
1522
  questionFingerprint: toolFingerprint(ASK_USER_QUESTION_TOOL, { questions: facts.toolInput.questions }),
1523
+ fullHold: request.fullHold === true,
1524
+ enginePid: request.fullHold === true && typeof request.enginePid === 'number' ? request.enginePid : null,
1367
1525
  waitingAtMac: false,
1368
1526
  }
1369
1527
  this.items.set(id, item)
@@ -1376,7 +1534,7 @@ export class PermissionBroker {
1376
1534
  this.ensurePolling()
1377
1535
  // 6.60.0: the numbers the deadline came from, once per request (never text).
1378
1536
  const hold = request.hold ? ` hold=${JSON.stringify(request.hold)}` : ''
1379
- this.log(`parked ${id} kind=${item.kind} session=${item.sessionId.slice(0, 8)} deadline_s=${Math.round((request.deadlineAt - now) / 1000)} away_hold=${item.awayHold} pending=${this.pendingCount()}${hold}`)
1537
+ this.log(`parked ${id} kind=${item.kind} session=${item.sessionId.slice(0, 8)} deadline_s=${Math.round((request.deadlineAt - now) / 1000)} away_hold=${item.awayHold}${item.fullHold ? ' full_hold=true' : ''} pending=${this.pendingCount()}${hold}`)
1380
1538
  return id
1381
1539
  }
1382
1540
 
@@ -1416,6 +1574,7 @@ export class PermissionBroker {
1416
1574
  /** The pending items, oldest first, for `GET /api/session-questions`. */
1417
1575
  list(): SessionQuestionView[] {
1418
1576
  const now = this.deps.now()
1577
+ const extended = this.extendedQuestionsView()
1419
1578
  const views: SessionQuestionView[] = []
1420
1579
  for (const item of [...this.items.values()]) {
1421
1580
  if (item.state !== 'pending') continue
@@ -1435,6 +1594,10 @@ export class PermissionBroker {
1435
1594
  answerable: true,
1436
1595
  awayHold: item.awayHold,
1437
1596
  waitingAtMac: false,
1597
+ // X1, additive: present only when known or true, so a view without them reads as before.
1598
+ // Q1 F1: only while a full-hold switch is on, so with both off each item is 7.0.13's, key for key.
1599
+ ...(item.toolUseId && extended ? { toolUseId: item.toolUseId } : {}),
1600
+ ...(item.fullHold ? { fullHold: true as const } : {}),
1438
1601
  })
1439
1602
  }
1440
1603
  return views.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
@@ -1481,11 +1644,14 @@ export class PermissionBroker {
1481
1644
  let cleared = 0
1482
1645
  for (const item of this.items.values()) {
1483
1646
  if (item.sessionId !== wanted && !(wanted.length < 36 && item.sessionId.startsWith(wanted))) continue
1484
- if (item.askHold && item.state === 'pending') {
1647
+ if ((item.askHold || item.fullHold) && item.state === 'pending') {
1485
1648
  if (this.settle(item, 'retracted')) cleared++
1486
1649
  } else if (item.waitingAtMac) { item.waitingAtMac = false; cleared++ }
1487
1650
  }
1488
1651
  if (cleared > 0) this.log(`session row ended: ${cleared} no longer waiting at the Mac session=${wanted.slice(0, 8)}`)
1652
+ for (const entry of [...this.waiting.values()]) {
1653
+ if (entry.sessionId === wanted || (wanted.length < 36 && entry.sessionId.startsWith(wanted))) this.waiting.delete(entry.key)
1654
+ }
1489
1655
  return cleared
1490
1656
  }
1491
1657
 
@@ -1519,12 +1685,25 @@ export class PermissionBroker {
1519
1685
  return { status: 400, body: { error: 'invalid_answer', reason: 'client_answer_id' } }
1520
1686
  }
1521
1687
  const handBack = input.handBack === true
1688
+ // X1: while a full-hold switch is on, the web door answers (or hands back) only with a touch it
1689
+ // just had. The token door (the Even app, Control through its helper) is unchanged.
1690
+ // Q1 C1: an item held under the full hold keeps both web steps even if the switch reads off before the next tick.
1691
+ if (by === 'web' && (this.anyFullHoldOn() || item.fullHold) && !validWebTouch(input.presenceAgeMs)) {
1692
+ this.log(`web_touch_required ${item.id} kind=${item.kind}`)
1693
+ return { status: 403, body: { error: 'web_touch_required' } }
1694
+ }
1522
1695
  if (item.state !== 'pending') return this.settledReply(item, clientAnswerId, handBack)
1523
1696
  if (this.deps.now() >= item.deadlineAt) {
1524
1697
  this.settle(item, 'expired')
1525
1698
  return this.settledReply(item, clientAnswerId, handBack)
1526
1699
  }
1527
1700
  if (handBack) return this.handBack(item, clientAnswerId)
1701
+ // Q1 O1: while a switch is on, an item no device is shown yet (the desk-idle latch has not fired) is
1702
+ // 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)) {
1704
+ this.log(`not_shown ${item.id} kind=${item.kind}`)
1705
+ return { status: 409, body: { error: 'not_shown' } }
1706
+ }
1528
1707
  let chosen: Chosen
1529
1708
  let output: Record<string, unknown>
1530
1709
  if (item.kind === 'question') {
@@ -1540,6 +1719,15 @@ export class PermissionBroker {
1540
1719
  this.counters.invalidAnswer++
1541
1720
  return { status: 400, body: { error: 'invalid_answer', reason: 'decision' } }
1542
1721
  }
1722
+ // X1: while a full-hold switch is on, a web approval carries the confirm id this server gave the
1723
+ // page for this item and this decision (`confirm`), once. Tap, then confirm, enforced here.
1724
+ if (by === 'web' && (this.anyFullHoldOn() || item.fullHold)) {
1725
+ const refused = this.consumeConfirm(input.confirmId, item.id, input.decision)
1726
+ if (refused) {
1727
+ this.log(`${refused} ${item.id}`)
1728
+ return { status: 403, body: { error: refused } }
1729
+ }
1730
+ }
1543
1731
  chosen = { decision: input.decision }
1544
1732
  output = approvalHookOutput(input.decision)
1545
1733
  }
@@ -1569,6 +1757,217 @@ export class PermissionBroker {
1569
1757
  return { status: 200, body: { ok: true, id: item.id, kind: item.kind, ...chosen } }
1570
1758
  }
1571
1759
 
1760
+ /**
1761
+ * X1: `POST /api/session-questions/:id/confirm`, the web door's confirm step for an approval: the
1762
+ * page asks after the first tap and sends the id back with the answer. Single use, bound to this
1763
+ * item and this decision, valid WEB_CONFIRM_TTL_MS. Only for an approval, only from the web door,
1764
+ * only with a fresh touch. A settled or expired item answers as the answer route would.
1765
+ */
1766
+ confirm(id: string, body: unknown, by: AnswerSurface = 'app'): AnswerResult {
1767
+ const item = BROKER_ID_RE.test(id) ? this.items.get(id) : undefined
1768
+ // Q1 F2: with both switches off the route does not exist (404) and mints nothing, unless the item itself is
1769
+ // held under the full hold (Q1 C1: it still needs its confirm, so it must still be able to get one).
1770
+ if (!this.anyFullHoldOn() && !item?.fullHold) return { status: 404, body: { error: 'not_found' } }
1771
+ if (!item) return { status: 404, body: { error: 'not_found' } }
1772
+ const input = isRecord(body) ? body : {}
1773
+ if (item.kind !== 'approval') return { status: 400, body: { error: 'invalid_confirm', reason: 'kind' } }
1774
+ if (input.decision !== 'allow' && input.decision !== 'deny') return { status: 400, body: { error: 'invalid_confirm', reason: 'decision' } }
1775
+ if (by !== 'web') return { status: 400, body: { error: 'invalid_confirm', reason: 'surface' } }
1776
+ if (!validWebTouch(input.presenceAgeMs)) return { status: 403, body: { error: 'web_touch_required' } }
1777
+ if (item.state !== 'pending') return this.settledReply(item, '')
1778
+ const now = this.deps.now()
1779
+ if (now >= item.deadlineAt) {
1780
+ this.settle(item, 'expired')
1781
+ return this.settledReply(item, '')
1782
+ }
1783
+ this.pruneConfirms(now)
1784
+ while (this.confirms.size >= MAX_CONFIRMS) {
1785
+ const oldest = this.confirms.keys().next().value
1786
+ if (oldest === undefined) break
1787
+ this.confirms.delete(oldest)
1788
+ }
1789
+ const confirmId = this.deps.newConfirmId ? this.deps.newConfirmId() : `pc_${randomBytes(12).toString('hex')}`
1790
+ const expiresAt = now + WEB_CONFIRM_TTL_MS
1791
+ this.confirms.set(confirmId, { itemId: item.id, decision: input.decision, expiresAt })
1792
+ this.log(`confirm_issued ${item.id}`)
1793
+ return { status: 200, body: { confirmId, decision: input.decision, expiresAt: new Date(expiresAt).toISOString() } }
1794
+ }
1795
+
1796
+ /** X1: use a confirm id once. Null when it matches this item and decision; else why not. */
1797
+ private consumeConfirm(raw: unknown, itemId: string, decision: 'allow' | 'deny'): 'confirm_required' | 'confirm_invalid' | null {
1798
+ if (raw === undefined || raw === null || raw === '') return 'confirm_required'
1799
+ if (typeof raw !== 'string' || !CONFIRM_ID_RE.test(raw)) return 'confirm_invalid'
1800
+ const found = this.confirms.get(raw)
1801
+ if (!found) return 'confirm_invalid'
1802
+ if (this.deps.now() >= found.expiresAt) {
1803
+ this.confirms.delete(raw)
1804
+ return 'confirm_invalid'
1805
+ }
1806
+ if (found.itemId !== itemId || found.decision !== decision) return 'confirm_invalid'
1807
+ this.confirms.delete(raw)
1808
+ return null
1809
+ }
1810
+
1811
+ private pruneConfirms(now: number): void {
1812
+ for (const [key, value] of [...this.confirms.entries()]) {
1813
+ const item = this.items.get(value.itemId)
1814
+ if (now >= value.expiresAt || !item || item.state !== 'pending') this.confirms.delete(key)
1815
+ }
1816
+ }
1817
+
1818
+ /**
1819
+ * X1: keep the `waiting` list from the live hook events. A PreToolUse of AskUserQuestion adds the
1820
+ * question (its words parsed with `parseQuestions`; the ledger's projection has none, so a replay
1821
+ * adds nothing). Its PostToolUse marks it answered and drops the words; a failure or a denial drops
1822
+ * it; a turn boundary, an interrupt or a new session drops what the session still waited on.
1823
+ */
1824
+ private noteWaiting(env: HookEnvelope, toolName: string): void {
1825
+ const p = env.payload
1826
+ const toolUseId = typeof p.tool_use_id === 'string' && p.tool_use_id.length > 0 ? p.tool_use_id.slice(0, 128) : null
1827
+ if (toolName === ASK_USER_QUESTION_TOOL && env.event === 'PreToolUse') {
1828
+ if (!isRecord(p.tool_input)) return
1829
+ const questionFingerprint = toolFingerprint(ASK_USER_QUESTION_TOOL, { questions: p.tool_input.questions })
1830
+ const key = `${env.sessionId}:${toolUseId ?? `q${questionFingerprint.slice(0, 24)}`}`
1831
+ if (this.waiting.has(key)) return
1832
+ this.waiting.set(key, {
1833
+ key,
1834
+ sessionId: env.sessionId,
1835
+ provider: env.provider === 'codex' ? 'codex' : 'claude',
1836
+ toolUseId,
1837
+ questionFingerprint,
1838
+ questions: parseQuestions(p.tool_input),
1839
+ askedAt: env.ts,
1840
+ state: 'waiting',
1841
+ answeredAt: null,
1842
+ answeredBy: null,
1843
+ })
1844
+ this.pruneWaiting(this.deps.now())
1845
+ return
1846
+ }
1847
+ if (toolName === ASK_USER_QUESTION_TOOL && (env.event === 'PostToolUse' || env.event === 'PostToolUseFailure' || env.event === 'PermissionDenied')) {
1848
+ const entry = this.findWaiting(env.sessionId, toolUseId, p.tool_input)
1849
+ if (!entry || entry.state !== 'waiting') return
1850
+ if (env.event !== 'PostToolUse') { this.waiting.delete(entry.key); return }
1851
+ entry.state = 'answered'
1852
+ entry.answeredAt = env.ts
1853
+ entry.questions = null
1854
+ entry.answeredBy = this.answeredFromCos(entry) ? 'cos' : 'mac'
1855
+ return
1856
+ }
1857
+ if (env.event === 'Interrupt' || TURN_BOUNDARIES.has(env.event) || (env.event === 'SessionStart' && p.source !== 'compact')) {
1858
+ for (const entry of [...this.waiting.values()]) {
1859
+ if (entry.sessionId === env.sessionId && entry.state === 'waiting' && env.ts >= entry.askedAt) this.waiting.delete(entry.key)
1860
+ }
1861
+ }
1862
+ }
1863
+
1864
+ /** X1: the waiting entry an AskUserQuestion completion is for: by tool_use id, else by its questions. */
1865
+ private findWaiting(sessionId: string, toolUseId: string | null, toolInput: unknown): WaitingEntry | null {
1866
+ if (toolUseId) {
1867
+ for (const entry of this.waiting.values()) if (entry.sessionId === sessionId && entry.toolUseId === toolUseId) return entry
1868
+ return null
1869
+ }
1870
+ if (!isRecord(toolInput)) return null
1871
+ const fingerprint = toolFingerprint(ASK_USER_QUESTION_TOOL, { questions: toolInput.questions })
1872
+ let found: WaitingEntry | null = null
1873
+ for (const entry of this.waiting.values()) {
1874
+ if (entry.sessionId === sessionId && entry.state === 'waiting' && entry.questionFingerprint === fingerprint && (!found || entry.askedAt > found.askedAt)) found = entry
1875
+ }
1876
+ return found
1877
+ }
1878
+
1879
+ /** X1: the pending question item a waiting entry is (same session, then the tool_use id, else the questions). */
1880
+ private itemMatches(item: BrokerItem, entry: Pick<WaitingEntry, 'sessionId' | 'toolUseId' | 'questionFingerprint'>): boolean {
1881
+ if (item.kind !== 'question' || item.sessionId !== entry.sessionId) return false
1882
+ if (item.toolUseId && entry.toolUseId) return item.toolUseId === entry.toolUseId
1883
+ return item.questionFingerprint === entry.questionFingerprint
1884
+ }
1885
+
1886
+ private answeredFromCos(entry: WaitingEntry): boolean {
1887
+ for (const item of this.items.values()) {
1888
+ if (item.state === 'answered' && item.answer && item.answer.clientAnswerId !== 'cos-cancel' && this.itemMatches(item, entry)) return true
1889
+ }
1890
+ return false
1891
+ }
1892
+
1893
+ private pruneWaiting(now: number): void {
1894
+ for (const entry of [...this.waiting.values()]) {
1895
+ if (now - entry.askedAt > WAITING_MAX_AGE_MS) this.waiting.delete(entry.key)
1896
+ else if (entry.state === 'answered' && entry.answeredAt !== null && now - entry.answeredAt > WAITING_ANSWERED_KEEP_MS) this.waiting.delete(entry.key)
1897
+ }
1898
+ if (this.waiting.size > MAX_WAITING) {
1899
+ const oldest = [...this.waiting.values()].sort((a, b) => a.askedAt - b.askedAt)
1900
+ for (let i = 0; i < oldest.length - MAX_WAITING; i++) this.waiting.delete(oldest[i]!.key)
1901
+ }
1902
+ }
1903
+
1904
+ /**
1905
+ * X1: `waiting` on the questions route: every question a live session waits on that this server
1906
+ * saw asked, with its state, plus any held question whose PreToolUse the spool has not delivered
1907
+ * yet. Empty unless `extendedQuestionsView`. Held means a COS surface can answer it now
1908
+ * (the same items `list` shows); a stamped question still behind the desk latch reads `at_mac`.
1909
+ */
1910
+ /**
1911
+ * X1: whether the questions route carries `waiting`, `webAnswer` and each item's `toolUseId`: only
1912
+ * while a full-hold switch is on (lead 22:10; Q1 F1). With both off the route is 7.0.13's, key for key.
1913
+ * (`COS_WAITING_QUESTION_WORDS` is the rows' 80 characters, not this.)
1914
+ */
1915
+ extendedQuestionsView(): boolean {
1916
+ return this.anyFullHoldOn()
1917
+ }
1918
+
1919
+ waitingList(): WaitingQuestionView[] {
1920
+ if (!this.extendedQuestionsView()) return []
1921
+ const now = this.deps.now()
1922
+ this.pruneWaiting(now)
1923
+ const answerable = (item: BrokerItem): boolean => item.state === 'pending' && item.lensVisible && now < item.deadlineAt
1924
+ const used = new Set<string>()
1925
+ const views: WaitingQuestionView[] = []
1926
+ for (const entry of this.waiting.values()) {
1927
+ const held = entry.state === 'waiting' ? [...this.items.values()].find(item => answerable(item) && this.itemMatches(item, entry)) ?? null : null
1928
+ if (held) used.add(held.id)
1929
+ views.push({
1930
+ key: entry.key,
1931
+ sessionId: entry.sessionId,
1932
+ provider: entry.provider,
1933
+ toolUseId: entry.toolUseId,
1934
+ state: entry.state === 'answered' ? 'answered' : held ? 'held' : 'at_mac',
1935
+ answerable: !!held,
1936
+ heldId: held ? held.id : null,
1937
+ // Settled words are already gone from memory (`noteWaiting` drops them), so an answered entry lists none.
1938
+ questions: held?.questions ?? entry.questions ?? [],
1939
+ askedAt: new Date(entry.askedAt).toISOString(),
1940
+ deadlineAt: held ? new Date(held.deadlineAt).toISOString() : null,
1941
+ answeredAt: entry.answeredAt !== null ? new Date(entry.answeredAt).toISOString() : null,
1942
+ answeredBy: entry.answeredBy,
1943
+ })
1944
+ }
1945
+ for (const item of this.items.values()) {
1946
+ if (used.has(item.id) || item.kind !== 'question' || !answerable(item)) continue
1947
+ views.push({
1948
+ key: `${item.sessionId}:${item.toolUseId ?? item.id}`,
1949
+ sessionId: item.sessionId,
1950
+ provider: item.provider,
1951
+ toolUseId: item.toolUseId,
1952
+ state: 'held',
1953
+ answerable: true,
1954
+ heldId: item.id,
1955
+ questions: item.questions ?? [],
1956
+ askedAt: new Date(item.requestedAt).toISOString(),
1957
+ deadlineAt: new Date(item.deadlineAt).toISOString(),
1958
+ answeredAt: null,
1959
+ answeredBy: null,
1960
+ })
1961
+ }
1962
+ return views.sort((a, b) => a.askedAt.localeCompare(b.askedAt))
1963
+ }
1964
+
1965
+ /** X1: what a web page must send with an answer right now (`webAnswer` on the questions route). */
1966
+ webAnswerRules(): { touchRequired: boolean; touchMaxMs: number; confirmApprovals: boolean } {
1967
+ const on = this.anyFullHoldOn()
1968
+ return { touchRequired: on, touchMaxMs: WEB_ANSWER_TOUCH_MAX_MS, confirmApprovals: on }
1969
+ }
1970
+
1572
1971
  /**
1573
1972
  * 6.53.0: a desk run of this session was cancelled from COS. Every prompt the broker holds
1574
1973
  * for it is answered deny with `interrupt: true` (the away-from-desk case: without this the
@@ -1652,6 +2051,7 @@ export class PermissionBroker {
1652
2051
  const p = env.payload
1653
2052
  const toolName = typeof p.tool_name === 'string' ? p.tool_name : ''
1654
2053
  const fingerprint = typeof p.tool_fingerprint === 'string' ? p.tool_fingerprint : toolFingerprint(toolName, p.tool_input ?? null)
2054
+ try { this.noteWaiting(env, toolName) } catch { /* the list is advisory */ }
1655
2055
  for (const item of [...this.items.values()]) {
1656
2056
  if (item.sessionId !== env.sessionId) continue
1657
2057
  if (env.event === 'PermissionRequest' || env.event === 'PreToolUse') {
@@ -1698,6 +2098,20 @@ export class PermissionBroker {
1698
2098
  async tick(): Promise<void> {
1699
2099
  if (this.pendingCount() === 0) { this.stopPolling(); return }
1700
2100
  if (!this.deps.admissionsOpen()) { this.settleAll('drained'); return }
2101
+ // X1 kill switch: a full-hold item whose switch now reads off goes back to the Mac at once.
2102
+ // The switches are read only while one is held, so with both off nothing here runs.
2103
+ if ([...this.items.values()].some(item => item.state === 'pending' && item.fullHold)) {
2104
+ const on = this.fullHoldSwitches()
2105
+ this.settleAll('handed_to_desk', item => item.fullHold && (item.kind === 'question' ? !on.questions : !on.approvals))
2106
+ if (this.pendingCount() === 0) return
2107
+ // X1 round 3: a held item whose settling hook event never arrived (a lost spool event) must not sit
2108
+ // to its deadline. Its own engine's registry record says whether the session moved on: the record is
2109
+ // gone (the process ended), or its status left `waiting` after this request began. The engine writes
2110
+ // `waiting` just before the hook starts and leaves it within milliseconds of an answer (both engines,
2111
+ // measured), so a status written after the request began that is not `waiting` means it was answered.
2112
+ this.releaseMovedOn()
2113
+ if (this.pendingCount() === 0) return
2114
+ }
1701
2115
  const now = this.deps.now()
1702
2116
  const presence = this.presence(now)
1703
2117
  if (!(this.clientLive(now) || presence.holdLive)) {
@@ -1706,9 +2120,10 @@ export class PermissionBroker {
1706
2120
  // single glance must not hold a question 10 more minutes). The desk below still hands
1707
2121
  // it back. 6.99.26 (QA round 1): a recent web touch keeps only what it admitted; a glasses or
1708
2122
  // phone hold reads exactly as in 6.99.25.
1709
- this.settleAll('no_client', item => item.webOnly
2123
+ // X1: a full-hold item never needs a client live: it is answerable for the whole hook wait.
2124
+ this.settleAll('no_client', item => !item.fullHold && (item.webOnly
1710
2125
  ? !(presence.recent || presence.webRecent)
1711
- : !item.askHold && (!item.awayHold || !presence.recent))
2126
+ : !item.askHold && (!item.awayHold || !presence.recent)))
1712
2127
  if (this.pendingCount() === 0) return
1713
2128
  }
1714
2129
  if (![...this.items.values()].some(item => item.state === 'pending' && (!item.askHold || !item.lensVisible || item.webOnly))) return
@@ -1736,6 +2151,23 @@ export class PermissionBroker {
1736
2151
  this.settleAll('handed_to_desk', item => !item.askHold || item.webOnly)
1737
2152
  }
1738
2153
 
2154
+ /** X1 round 3: settle (`retracted`, so `{}`) every held full-hold item its engine's record says the session moved past. */
2155
+ private releaseMovedOn(): void {
2156
+ const read = this.deps.engineStatus
2157
+ if (!read) return
2158
+ for (const item of [...this.items.values()]) {
2159
+ // `enginePid` is set only on a full-hold item (`park`), so an item held under today's rules is never read.
2160
+ if (item.state !== 'pending' || item.enginePid === null) continue
2161
+ let record: { status: string | null; statusUpdatedAt: number | null } | null
2162
+ try { record = read(item.sessionId, item.enginePid) } catch { continue }
2163
+ const movedOn = record === null
2164
+ || (record.status !== 'waiting' && record.statusUpdatedAt !== null && record.statusUpdatedAt > item.requestedAt)
2165
+ if (!movedOn) continue
2166
+ this.log(`registry_moved_on ${item.id} kind=${item.kind} record=${record === null ? 'gone' : 'not_waiting'}`)
2167
+ this.settle(item, 'retracted')
2168
+ }
2169
+ }
2170
+
1739
2171
  private settleAll(resolution: 'handed_to_desk' | 'drained' | 'no_client', only: (item: BrokerItem) => boolean = () => true): void {
1740
2172
  for (const item of [...this.items.values()]) if (item.state === 'pending' && only(item)) this.settle(item, resolution)
1741
2173
  if (this.pendingCount() === 0) this.stopPolling()
@@ -34,7 +34,7 @@ const POLLED: ReadonlySet<PingSource> = new Set<PingSource>(['prep', 'followups'
34
34
  export const PING_STATE_LIMITS = { held: 200, consumed: 5_000, consumedDays: 30, fileBytes: 4_000_000 } as const
35
35
 
36
36
  export interface PingSnapshot {
37
- nextEvent?: { title?: unknown; time?: unknown } | null
37
+ nextEvent?: { title?: unknown; time?: unknown; occurrence?: unknown } | null
38
38
  intakeOpen?: readonly IntakeRow[] | null
39
39
  threads?: readonly ThreadRow[] | null
40
40
  signals?: readonly SignalRow[] | null