@gotcos/glasses-server 6.59.0 → 6.61.0

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.
@@ -30,7 +30,10 @@
30
30
  // dialog chooses a permission MODE, and "allow once" is not a plan approval;
31
31
  // - a client that can answer asked for the questions in the last 30 s: an authenticated
32
32
  // `GET /api/session-questions?client=glasses|phone` (lib/client-liveness). An app without
33
- // the question UI never calls it, so for it the broker is inert and every request is `{}`;
33
+ // the question UI never calls it, so for it the broker is inert and every request is `{}`.
34
+ // 6.60.0: or the away hold applies (THE AWAY HOLD, below). Only a poll WITHOUT `hold=1`
35
+ // counts here as before; a `hold=1` poll counts only while its wearer's presence is
36
+ // recent;
34
37
  // - the desk has been idle `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30),
35
38
  // read here with a bounded `ioreg`, not trusted from the script.
36
39
  // The cheap gates run first and the desk read last; the question card or the approval card
@@ -40,11 +43,64 @@
40
43
  // A parked item holds the hook's response open until EXACTLY ONE of these resolves it:
41
44
  // an answer from the lens or phone; a hand-back from either ("Leave for the Mac"): `{}`;
42
45
  // the desk becoming active (polled every 1.5 s, and two unreadable reads in a row count as
43
- // active): `{}`; the answering client going quiet for 30 s: `{}`; the deadline
44
- // (`COS_PERMISSION_BROKER_TIMEOUT_S`, default 110, clamped to 120 so it lands before the
45
- // hook's 125 s): `{}`; the hook hanging up: `hook_gone`; the session
46
+ // active): `{}`; the answering client going quiet for 30 s: `{}` (never an item the away
47
+ // hold holds, 6.60.0); the deadline (`COS_PERMISSION_BROKER_TIMEOUT_S`, default 110, clamped
48
+ // to 120 so it lands before the hook's 125 s; 6.60.0: the away hold, up to 600 s, never past
49
+ // `holdCeilingS`): `{}`; the hook hanging up: `hook_gone`; the session
46
50
  // moving on without us (its tool ran, was denied, or the turn ended): `{}`; a drain: `{}`.
47
51
  //
52
+ // ---------------------------------------------------------------------------
53
+ // THE AWAY HOLD (6.60.0, Miles 2026-09-30: "Yes, up to 10 min")
54
+ // ---------------------------------------------------------------------------
55
+ // A question that starts while Miles is away used to be answerable from the glasses only for
56
+ // the 110 s above, and not at all when no client was polling at that moment (`no_client`,
57
+ // `{}` at once). Now, while the desk is idle past the SAME threshold the gate above uses
58
+ // (`COS_PERMISSION_BROKER_DESK_IDLE_S`), a parked request is held up to the away hold
59
+ // (`COS_PERMISSION_BROKER_AWAY_HOLD_S`, default 600, clamped to [30, 600]; `0` turns it off,
60
+ // which turns the away hold off; a `hold=1` client still counts as live only with recent
61
+ // presence), but only when ALL of these hold:
62
+ // - the installed hook waits longer than the 6.59.0 broker could ever hold (its 120 s
63
+ // clamp, `AWAY_MIN_CEILING_S`). Before Install hooks it does not, and away semantics are
64
+ // off (QA round 1, User W6). A `hold=1` client still counts as live only with recent
65
+ // presence, so a lens untouched for 30 minutes gets no card (QA round 2, W-A);
66
+ // - a client that shows held questions reported its WEARER present recently (Miles,
67
+ // 2026-09-30 19:54: "Hold only if the glasses or phone were connected in the last 30
68
+ // min. A Claude Remote Control session with no glasses gets its question right away,
69
+ // as before."). The contract shared with COS Glasses 6.9.563 (lib/client-liveness): a
70
+ // poll `?client=glasses|phone&hold=1&presenceAgeMs=<int>`, the ms since a ring or temple
71
+ // input, a device status with wearing true, or a phone foreground touch. Recent means
72
+ // within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (default 1800; below 30 s, the 30 s
73
+ // live window). A connected lens nobody wears sends no presence; an older client sends
74
+ // no `hold=1` and keeps the 6.59.0 liveness, never the away hold. In memory: after a
75
+ // restart nothing is recent until such a poll.
76
+ // Held that way, a request stays answerable while no client is polling, so a lens or phone
77
+ // that comes back can still answer it. It is re-checked on every desk poll (QA round 1,
78
+ // Skeptic W2): once no client is live and the wearer's latest presence is older than the
79
+ // window, it goes to the Mac's dialog (`no_client`), so one glance never holds a question 10
80
+ // minutes. Desk activity still hands it back within one poll (1.5 s); a request that arrives
81
+ // while Miles is at the desk is still `{}` at once (`desk_active`); every path still ends.
82
+ // Every request answered `{}` is logged with why, the presence age and the window.
83
+ //
84
+ // NEVER past the hook's own wait. The hold is clamped to `holdCeilingS`: the PermissionRequest
85
+ // `timeout` in the Claude settings file (read, not assumed) and the wait the hook stamped on
86
+ // THIS request (`hookWaitS`, 6.60.0 script), whichever is less, minus 10 s; settings that
87
+ // cannot be read leave the stamp alone. A request with no stamp (the earlier script, or an
88
+ // earlier hook block) waited 130 s and its curl 125 s, so it is never held past 120 s.
89
+ // Canaries on Claude Code 2.1.286, 2026-09-30: a PermissionRequest hook configured for 630 s
90
+ // that slept 610 s was waited for and its allow applied (and 300 s / 200 s before it); one
91
+ // configured for 150 s was stopped at 150 s and the tool did not run. The script stops its
92
+ // own curl when it is stopped, so the broker sees the hook leave (`hook_gone`) at once.
93
+ //
94
+ // What a client sees: `deadlineAt` is the latest the request stays answerable (sooner if the
95
+ // wearer's presence lapses with no client live); each item says `answerable`, `awayHold` and
96
+ // `waitingAtMac`, and the questions route lists settled items (`settled`, codes only; every one
97
+ // the Mac still waits on, whatever the cap), with `serverStartedAt` and
98
+ // `waitingAtMacForgottenBefore` so a client can tell "forgot" from "moved past". `waitingAtMac`
99
+ // stays true until that request's own tool finishes or is denied, its turn ends, or its row
100
+ // reads ended: no hook marks the moment a dialog is approved, so an approved tool that is
101
+ // still running reads as waiting (the safe direction: never "not waiting" while a dialog may
102
+ // be up).
103
+ //
48
104
  // NEVER: a permission RULE. `updatedPermissions` and the request's suggestions are never
49
105
  // returned; the decision objects below are built from nothing but constants and the
50
106
  // original input. Question and command text is never logged and never goes on the display
@@ -89,6 +145,73 @@ export function permissionBrokerTimeoutMs(env: NodeJS.ProcessEnv): number {
89
145
  return Math.round(Math.min(BROKER_TIMEOUT_MAX_S, Math.max(BROKER_TIMEOUT_MIN_S, seconds)) * 1000)
90
146
  }
91
147
 
148
+ /** 6.60.0: the away hold. `COS_PERMISSION_BROKER_AWAY_HOLD_S`. */
149
+ export const AWAY_HOLD_DEFAULT_S = 600
150
+ /** Miles approved "up to 10 min" (2026-09-30). The installer's hook wait (630) is sized for it. */
151
+ export const AWAY_HOLD_MAX_S = 600
152
+ export const AWAY_HOLD_MIN_S = 30
153
+
154
+ /**
155
+ * `COS_PERMISSION_BROKER_AWAY_HOLD_S`, in ms: default 600 s, clamped to [30, 600]. `0`, `off`
156
+ * or `false` turns the away hold off (0): the 6.59.0 broker. Junk is the default.
157
+ */
158
+ export function permissionBrokerAwayHoldMs(env: NodeJS.ProcessEnv): number {
159
+ const raw = (env.COS_PERMISSION_BROKER_AWAY_HOLD_S ?? '').trim().toLowerCase()
160
+ if (raw === '0' || raw === 'off' || raw === 'false') return 0
161
+ const parsed = Number(raw)
162
+ const seconds = raw !== '' && Number.isFinite(parsed) && parsed > 0 ? parsed : AWAY_HOLD_DEFAULT_S
163
+ return Math.round(Math.min(AWAY_HOLD_MAX_S, Math.max(AWAY_HOLD_MIN_S, seconds)) * 1000)
164
+ }
165
+
166
+ /** 6.60.0: how recently a client must have polled for the away hold. */
167
+ export const AWAY_RECENT_CLIENT_DEFAULT_S = 1_800
168
+ /** A day: past that, "recently connected" no longer describes anyone. */
169
+ export const AWAY_RECENT_CLIENT_MAX_S = 86_400
170
+
171
+ /**
172
+ * `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`, in ms: default 1800 s (30 min), clamped to
173
+ * [0, 86400]. `0` means a client must be live right now (polled within the 30 s window).
174
+ * Junk or a negative number is the default.
175
+ */
176
+ export function permissionBrokerAwayRecentClientMs(env: NodeJS.ProcessEnv): number {
177
+ const raw = (env.COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S ?? '').trim()
178
+ const parsed = Number(raw)
179
+ const seconds = raw !== '' && Number.isFinite(parsed) && parsed >= 0 ? parsed : AWAY_RECENT_CLIENT_DEFAULT_S
180
+ return Math.round(Math.min(AWAY_RECENT_CLIENT_MAX_S, Math.max(0, seconds)) * 1000)
181
+ }
182
+
183
+ /** 6.60.0: Claude Code's wait on every PermissionRequest hook before 6.60.0 (its curl: 125). */
184
+ export const LEGACY_HOOK_WAIT_S = 130
185
+ /**
186
+ * 6.60.0: the broker answers this far inside the hook's own wait: 5 s for the script's curl
187
+ * to give up before Claude Code stops the hook, 5 s more so `{}` lands before curl gives up.
188
+ * The 130 / 125 / 120 chain every release since 6.52.0 has kept.
189
+ */
190
+ export const HOOK_REPLY_MARGIN_S = 10
191
+ /** A `hookWaitS` stamp outside this range is not one the installer writes: no stamp. */
192
+ export const HOOK_WAIT_STAMP_MAX_S = 3_600
193
+
194
+ /**
195
+ * 6.60.0: the longest a request may be held, in seconds, from the hook's own start.
196
+ * `installedHookTimeoutS` is our PermissionRequest `timeout` in the Claude settings file, or
197
+ * null when that cannot be read (missing, unparseable, a symlink, no block of ours).
198
+ * `hookWaitS` is the wait this invocation stamped (the 6.60.0 script passes its block's
199
+ * timeout); none means an earlier hook, 130. The lesser of the two, minus the margin. With
200
+ * the settings unreadable, the stamp alone minus the margin (QA round 1, N4), and an
201
+ * unstamped request the old safe clamp, 120: an unstamped request is never held past 120.
202
+ * Never negative: a ceiling at or below zero is `stale_hook`.
203
+ */
204
+ export function holdCeilingS(installedHookTimeoutS: number | null, hookWaitS: number | null): number {
205
+ const hasStamp = hookWaitS !== null && Number.isFinite(hookWaitS) && hookWaitS > 0
206
+ if (installedHookTimeoutS === null || !Number.isFinite(installedHookTimeoutS) || installedHookTimeoutS <= 0) {
207
+ // QA round 1 (N4): unreadable settings. The stamp is this invocation's own wait, so it
208
+ // still bounds the hold; with no stamp, the old safe clamp, never more than 120.
209
+ return hasStamp ? Math.max(0, hookWaitS! - HOOK_REPLY_MARGIN_S) : BROKER_TIMEOUT_MAX_S
210
+ }
211
+ const stamped = hasStamp ? hookWaitS! : LEGACY_HOOK_WAIT_S
212
+ return Math.max(0, Math.min(installedHookTimeoutS, stamped) - HOOK_REPLY_MARGIN_S)
213
+ }
214
+
92
215
  /**
93
216
  * A client must have polled `GET /api/session-questions?client=glasses|phone` this recently,
94
217
  * at admission AND while anything is held (QA round 1: 60 s let a phone that locked hold a
@@ -111,7 +234,20 @@ export const DESK_IDLE_MIN_S = 30
111
234
  export const MAX_PENDING = 32
112
235
  /** A settled item is kept this long so a late answer gets the right code and a retry its replay. */
113
236
  export const SETTLED_RETENTION_MS = 10 * 60_000
237
+ /**
238
+ * 6.60.0: a settled item still waiting at the Mac (`waitingAtMac`) is kept this long, so a
239
+ * client can keep saying the Mac is waiting until Miles is back. Bounded: an event the spool
240
+ * never delivered must not leave it waiting forever.
241
+ */
242
+ export const MAC_WAIT_RETENTION_MS = 60 * 60_000
243
+ /**
244
+ * 6.60.0 (QA round 1, Ghost Hunter W1): while the session's own row still waits on that very
245
+ * request (`stillWaitingAtMac`), it is kept up to this long instead of the hour.
246
+ */
247
+ export const MAC_WAIT_MAX_RETENTION_MS = 24 * 60 * 60_000
114
248
  export const MAX_SETTLED = 256
249
+ /** 6.60.0: at most this many settled items in a questions listing, newest first. */
250
+ export const SETTLED_LIST_MAX = 32
115
251
  /** Free text for "Other". */
116
252
  export const OTHER_TEXT_MAX = 2_000
117
253
  /** The approval card's short line: the command (or path, URL, query) as the lens shows it. */
@@ -159,6 +295,11 @@ export interface PermissionRequestFacts {
159
295
  fingerprint: string
160
296
  /** The envelope's `ts`: when the hook started, epoch ms from the same Mac clock. */
161
297
  hookStartedAtMs: number | null
298
+ /**
299
+ * 6.60.0: the envelope's `hookWaitS`, the hook block's own `timeout` as the 6.60.0 script
300
+ * received it (its second argument). Null for an earlier script or block: `holdCeilingS`.
301
+ */
302
+ hookWaitS: number | null
162
303
  }
163
304
 
164
305
  export type EnvelopeVerdict =
@@ -182,9 +323,11 @@ function readPermissionRequestEnvelope(body: unknown): EnvelopeRead {
182
323
  if (typeof p.tool_name !== 'string' || p.tool_name.trim().length === 0) return { ok: false, reason: 'malformed' }
183
324
  if (!isRecord(p.tool_input)) return { ok: false, reason: 'malformed' }
184
325
  const ts = typeof body.ts === 'number' && Number.isFinite(body.ts) ? body.ts : null
326
+ const wait = body.hookWaitS
327
+ const hookWaitS = typeof wait === 'number' && Number.isInteger(wait) && wait > 0 && wait <= HOOK_WAIT_STAMP_MAX_S ? wait : null
185
328
  return {
186
329
  ok: true,
187
- facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts },
330
+ facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts, hookWaitS },
188
331
  }
189
332
  }
190
333
 
@@ -568,23 +711,103 @@ export interface PermissionBrokerDeps {
568
711
  now(): number
569
712
  mode(): BrokerMode
570
713
  admissionsOpen(): boolean
571
- /** The newest counting `GET /api/session-questions`, or null (lib/client-liveness). */
714
+ /**
715
+ * The newest counting `GET /api/session-questions`, or null (lib/client-liveness). The
716
+ * 6.59.0 liveness reads it when `lastLegacyPollAt` is absent.
717
+ */
572
718
  lastQuestionsPollAt(): number | null
719
+ /**
720
+ * 6.60.0: the newest counting poll WITHOUT `hold=1`, the 6.59.0 liveness for every older
721
+ * client (`lastLegacyQuestionsPollAt`). Absent: `lastQuestionsPollAt`.
722
+ */
723
+ lastLegacyPollAt?(): number | null
724
+ /** 6.60.0: the newest counting poll WITH `hold=1` (`lastHoldQuestionsPollAt`). Absent: none. */
725
+ lastHoldPollAt?(): number | null
573
726
  readDeskIdleSeconds(): Promise<number | null>
574
727
  deskIdleSeconds(): number
575
728
  timeoutMs(): number
729
+ /** 6.60.0: the away hold in ms, 0 for off (`permissionBrokerAwayHoldMs`). Absent: off. */
730
+ awayHoldMs?(): number
731
+ /**
732
+ * 6.60.0: our PermissionRequest hook's `timeout` in the Claude settings file, or null when it
733
+ * cannot be read. Absent: null, the old safe clamp (`holdCeilingS`).
734
+ */
735
+ installedHookTimeoutS?(): number | null
736
+ /**
737
+ * 6.60.0: how long ago the newest QUALIFYING wearer presence was (a `hold=1` poll with a
738
+ * valid `presenceAgeMs`), or null when there is none since boot (`presenceAgeMs`,
739
+ * lib/client-liveness). Absent: null.
740
+ */
741
+ presenceAgeMs?(): number | null
742
+ /**
743
+ * 6.60.0: how recent that presence must be for the away hold, in ms
744
+ * (`permissionBrokerAwayRecentClientMs`), never below the 30 s live window. Absent: 30 s.
745
+ */
746
+ awayRecentClientMs?(): number
747
+ /**
748
+ * 6.60.0: the session's row still waits on exactly this request (its hook signal is
749
+ * waiting, on this fingerprint, and not ended). Keeps a waiting-at-the-Mac item past the
750
+ * hour. Absent: false.
751
+ */
752
+ stillWaitingAtMac?(sessionId: string, fingerprint: string): boolean
576
753
  signals?: BrokerSignalSink
577
754
  pollMs?: number
578
755
  newId?: () => string
579
756
  log?: (line: string) => void
580
757
  }
581
758
 
759
+ /** 6.60.0: how a parked request's deadline was decided, logged once per request. Numbers only. */
760
+ export interface HoldFacts {
761
+ /** Our PermissionRequest `timeout` in the Claude settings file, or null (unreadable). */
762
+ installedHookTimeoutS: number | null
763
+ /** The wait this hook invocation stamped, or null (an earlier script or block). */
764
+ hookWaitS: number | null
765
+ /** The away hold in seconds; 0 when it is off. */
766
+ awayHoldS: number
767
+ /** `holdCeilingS(installedHookTimeoutS, hookWaitS)`. */
768
+ ceilingS: number
769
+ /** The hold, from the hook's own start to the deadline, in seconds. */
770
+ effectiveDeadlineS: number
771
+ /** The desk's idle time at admission, in ms. */
772
+ deskIdleMs: number | null
773
+ /** A client that can answer was live at admission (6.59.0 liveness, or a `hold=1` client with recent presence). */
774
+ clientLive: boolean
775
+ /** 6.60.0: the age of the newest qualifying wearer presence; null for none since boot. */
776
+ presenceAgeMs: number | null
777
+ /** 6.60.0: how recent that presence had to be, in seconds. */
778
+ recentWindowS: number
779
+ /** 6.60.0: why this request is not away-held, or null when it is. */
780
+ notAwayReason: NotAwayReason | null
781
+ }
782
+
783
+ /**
784
+ * 6.60.0: why a request is not held by the away hold (logged once per decision):
785
+ * `hold_off` the switch is 0; `ceiling_at_base` the installed hook waits no longer than the
786
+ * 6.59.0 broker could hold (Install hooks not run); `no_hold_client` no `hold=1` client has
787
+ * reported wearer presence since boot; `stale_presence` its latest presence is older than the
788
+ * window; `desk_active` Miles is at the Mac.
789
+ */
790
+ export type NotAwayReason = 'hold_off' | 'ceiling_at_base' | 'no_hold_client' | 'stale_presence' | 'desk_active'
791
+
792
+ /**
793
+ * 6.60.0: the away hold needs a hook that waits longer than the 6.59.0 broker could ever hold
794
+ * (its 120 s clamp). At or below that, before Install hooks, away semantics are off entirely
795
+ * and the broker is 6.59.0 (QA round 1, W6).
796
+ */
797
+ export const AWAY_MIN_CEILING_S = BROKER_TIMEOUT_MAX_S
798
+
582
799
  export interface ParkRequest {
583
800
  facts: PermissionRequestFacts
584
801
  kind: BrokerItemKind
585
802
  questions: BrokerQuestion[] | null
586
803
  approval: ApprovalCard | null
587
804
  deadlineAt: number
805
+ /**
806
+ * 6.60.0: held by the away hold: a client going quiet does not hand it back (desk activity
807
+ * still does). False with the away hold off, or for a request built without it.
808
+ */
809
+ awayHold?: boolean
810
+ hold?: HoldFacts
588
811
  }
589
812
 
590
813
  export type BrokerAdmission = { ok: true; request: ParkRequest } | { ok: false; reason: BrokerFastPath }
@@ -611,6 +834,14 @@ interface BrokerItem {
611
834
  handedBackBy: string | null
612
835
  channel: HookReplyChannel | null
613
836
  timer: ReturnType<typeof setTimeout> | null
837
+ /** 6.60.0: held by the away hold (`ParkRequest.awayHold`). */
838
+ awayHold: boolean
839
+ /**
840
+ * 6.60.0: settled with `{}` written to the hook (handed to the desk, expired, no client,
841
+ * drained), so the Mac's own dialog is showing, and the session has not moved past the
842
+ * request since (`sessionMovedPast`).
843
+ */
844
+ waitingAtMac: boolean
614
845
  }
615
846
 
616
847
  export interface SessionQuestionView {
@@ -620,11 +851,46 @@ export interface SessionQuestionView {
620
851
  kind: BrokerItemKind
621
852
  tool: string
622
853
  createdAt: string
854
+ /** When it stops being answerable from here. 6.60.0: the away hold's deadline when held by it. */
623
855
  deadlineAt: string
624
856
  questions?: BrokerQuestion[]
625
857
  approval?: ApprovalCard
858
+ /** 6.60.0: an answer posted now still reaches the session. Always true for a pending item. */
859
+ answerable: boolean
860
+ /** 6.60.0: held by the away hold: it stays answerable while no client polls, until `deadlineAt` or the desk. */
861
+ awayHold: boolean
862
+ /** 6.60.0: handed to the Mac's own dialog and still waiting there. Always false for a pending item. */
863
+ waitingAtMac: boolean
626
864
  }
627
865
 
866
+ /**
867
+ * 6.60.0: a settled item as the questions route lists it (`settled`): codes and times only,
868
+ * never the questions, the card, or what was chosen.
869
+ */
870
+ export interface SettledQuestionView {
871
+ id: string
872
+ sessionId: string
873
+ provider: 'claude'
874
+ kind: BrokerItemKind
875
+ tool: string
876
+ createdAt: string
877
+ deadlineAt: string
878
+ settledAt: string
879
+ resolution: BrokerResolution
880
+ /** Always false: a settled item takes no answer. */
881
+ answerable: false
882
+ awayHold: boolean
883
+ /**
884
+ * The broker gave the hook `{}`, so the Mac's own dialog showed, and the session has not
885
+ * moved past the request since: its tool has not run nor been denied, its turn has not
886
+ * ended. A client can say the Mac is waiting on an answer.
887
+ */
888
+ waitingAtMac: boolean
889
+ }
890
+
891
+ /** 6.60.0: the resolutions that write `{}` to a listening hook, so the Mac's dialog shows. */
892
+ const MAC_DIALOG_RESOLUTIONS: ReadonlySet<BrokerResolution> = new Set(['handed_to_desk', 'expired', 'no_client', 'drained'])
893
+
628
894
  /**
629
895
  * `/api/health`'s `permissionBroker`. Counts, the mode and two timestamps: never an id,
630
896
  * a question, a command, or how many are held right now (that is on the authenticated
@@ -648,6 +914,10 @@ export interface PermissionBrokerHealth {
648
914
  invalidAnswer: number
649
915
  /** A desk read that failed while something was held. */
650
916
  deskUnreadable: number
917
+ /** 6.60.0: of `parked`, those held by the away hold. */
918
+ awayHeld: number
919
+ /** 6.60.0: of `parked`, those parked while no client that can answer was polling. */
920
+ parkedWithoutClient: number
651
921
  /** Requests answered `{}` without being parked, keyed by the camelCase of the reason. */
652
922
  fastPath: Record<string, number>
653
923
  }
@@ -682,6 +952,26 @@ export const CLIENT_ANSWER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
682
952
  /** Events that end the turn a request was part of: every held request of the session is moot. */
683
953
  const TURN_BOUNDARIES: ReadonlySet<string> = new Set(['Stop', 'StopFailure', 'SessionEnd', 'UserPromptSubmit'])
684
954
 
955
+ /**
956
+ * The session moved past a request without the broker: its turn ended (or a new session
957
+ * started over it), or its own tool ran or was denied, which means the Mac's dialog was
958
+ * answered. For a pending item that is a retraction; for one handed to the Mac (6.60.0) it
959
+ * means the Mac is no longer waiting on it. A question's tool is AskUserQuestion; an
960
+ * approval matches by fingerprint, or by tool name for a denial.
961
+ */
962
+ function sessionMovedPast(item: Pick<BrokerItem, 'kind' | 'fingerprint' | 'toolName'>, env: HookEnvelope, toolName: string, fingerprint: string, strict = false): boolean {
963
+ if (TURN_BOUNDARIES.has(env.event) || (env.event === 'SessionStart' && env.payload.source !== 'compact')) return true
964
+ if (env.event === 'PostToolUse' || env.event === 'PostToolUseFailure' || env.event === 'PermissionDenied') {
965
+ // `strict` (QA round 1, N3), for "the Mac is no longer waiting": an approval only by its
966
+ // own fingerprint, never by a denial of the same tool name, which may be another dialog
967
+ // of the same session still on screen.
968
+ if (item.kind === 'question') return toolName === ASK_USER_QUESTION_TOOL
969
+ if (strict) return fingerprint === item.fingerprint
970
+ return fingerprint === item.fingerprint || (env.event === 'PermissionDenied' && toolName === item.toolName)
971
+ }
972
+ return false
973
+ }
974
+
685
975
  export class PermissionBroker {
686
976
  private readonly deps: PermissionBrokerDeps
687
977
  private readonly items = new Map<string, BrokerItem>()
@@ -689,13 +979,26 @@ export class PermissionBroker {
689
979
  private pollInFlight = false
690
980
  /** Failed desk reads in a row while something is held. Two hand everything back. */
691
981
  private deskUnreadableStreak = 0
692
- private readonly counters = { parked: 0, answered: 0, handedToDesk: 0, handedBack: 0, expired: 0, hookGone: 0, retracted: 0, drained: 0, noClient: 0, invalidAnswer: 0, deskUnreadable: 0 }
982
+ private readonly counters = { parked: 0, answered: 0, handedToDesk: 0, handedBack: 0, expired: 0, hookGone: 0, retracted: 0, drained: 0, noClient: 0, invalidAnswer: 0, deskUnreadable: 0, awayHeld: 0, parkedWithoutClient: 0 }
693
983
  private readonly fastPaths: Record<string, number> = {}
694
984
  private lastFastPath: BrokerFastPath | null = null
695
985
  private lastFastPathAt: number | null = null
696
986
 
987
+ /**
988
+ * 6.60.0 (QA round 1, Ghost Hunter W1): the server cannot know about a waiting-at-the-Mac
989
+ * item settled at or before this time: it started then, or it later dropped one that was
990
+ * still waiting. Never moves back.
991
+ */
992
+ private waitingForgottenBefore: number
993
+
697
994
  constructor(deps: PermissionBrokerDeps) {
698
995
  this.deps = deps
996
+ this.waitingForgottenBefore = deps.now()
997
+ }
998
+
999
+ /** 6.60.0: `waitingAtMacForgottenBefore` on the questions route, in ms. */
1000
+ waitingAtMacForgottenBeforeMs(): number {
1001
+ return this.waitingForgottenBefore
699
1002
  }
700
1003
 
701
1004
  private log(line: string): void {
@@ -703,11 +1006,17 @@ export class PermissionBroker {
703
1006
  }
704
1007
 
705
1008
  /** A request answered `{}` without being parked, counted by why. */
706
- noteFastPath(reason: BrokerFastPath): { ok: false; reason: BrokerFastPath } {
1009
+ noteFastPath(reason: BrokerFastPath, context: { notAway?: NotAwayReason; presence?: { ageMs: number | null; windowMs: number }; clientLive?: boolean } = {}): { ok: false; reason: BrokerFastPath } {
707
1010
  const key = camelKey(reason)
708
1011
  this.fastPaths[key] = (this.fastPaths[key] ?? 0) + 1
709
1012
  this.lastFastPath = reason
710
1013
  this.lastFastPathAt = this.deps.now()
1014
+ // 6.60.0 (QA round 1, Completionist N9, Ghost Hunter W5): one line per request answered
1015
+ // `{}`, with why, how old the wearer's presence is and the window. Numbers only.
1016
+ let presence = context.presence
1017
+ if (!presence) { try { presence = this.presence(this.deps.now()) } catch { presence = undefined } }
1018
+ const age = presence?.ageMs === null || presence?.ageMs === undefined ? 'null' : String(Math.round(presence.ageMs))
1019
+ this.log(`fast path ${reason}${context.notAway ? ` not_away=${context.notAway}` : ''} presenceAgeMs=${age} windowMs=${presence?.windowMs ?? 'null'}${context.clientLive === undefined ? '' : ` clientLive=${context.clientLive}`}`)
711
1020
  return { ok: false, reason }
712
1021
  }
713
1022
 
@@ -717,12 +1026,89 @@ export class PermissionBroker {
717
1026
  * forever.
718
1027
  */
719
1028
  private clientLive(now: number): boolean {
720
- const polledAt = this.deps.lastQuestionsPollAt()
721
- if (polledAt === null || !Number.isFinite(polledAt)) return false
1029
+ // 6.60.0: the 6.59.0 liveness is a poll WITHOUT `hold=1` (every older client).
1030
+ const legacy = this.deps.lastLegacyPollAt
1031
+ return this.polledWithin(legacy ? legacy() : this.deps.lastQuestionsPollAt(), now)
1032
+ }
1033
+
1034
+ /** A poll stamp within the live window, and not more than the skew ahead of the clock. */
1035
+ private polledWithin(polledAt: number | null | undefined, now: number): boolean {
1036
+ if (polledAt === null || polledAt === undefined || !Number.isFinite(polledAt)) return false
722
1037
  if (polledAt - now > POLL_FUTURE_SKEW_MS) return false
723
1038
  return now - polledAt <= CLIENT_LIVE_WINDOW_MS
724
1039
  }
725
1040
 
1041
+ /** 6.60.0: the away hold in ms; 0 when off, absent, or unreadable. */
1042
+ private awayHoldMs(): number {
1043
+ let ms = 0
1044
+ try { ms = Number(this.deps.awayHoldMs?.() ?? 0) } catch { ms = 0 }
1045
+ return Number.isFinite(ms) && ms > 0 ? ms : 0
1046
+ }
1047
+
1048
+ /** 6.60.0: the recent-presence window in ms, never below the 30 s live window. */
1049
+ private recentWindowMs(): number {
1050
+ let ms = 0
1051
+ try { ms = Number(this.deps.awayRecentClientMs?.() ?? 0) } catch { ms = 0 }
1052
+ return Math.max(CLIENT_LIVE_WINDOW_MS, Number.isFinite(ms) && ms > 0 ? ms : 0)
1053
+ }
1054
+
1055
+ /**
1056
+ * 6.60.0: the newest qualifying wearer presence and what it allows, read now:
1057
+ * - `recent`: within the window, so the away hold may apply;
1058
+ * - `holdLive`: a `hold=1` client polled within the live window AND its presence is
1059
+ * recent, so it counts as a live client the way an older client's poll does. A
1060
+ * connected lens nobody wears never counts (QA round 1, Skeptic W1).
1061
+ */
1062
+ private presence(now: number): { ageMs: number | null; recent: boolean; holdLive: boolean; windowMs: number } {
1063
+ let ageMs: number | null = null
1064
+ try { ageMs = this.deps.presenceAgeMs?.() ?? null } catch { ageMs = null }
1065
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0) ageMs = null
1066
+ const windowMs = this.recentWindowMs()
1067
+ const recent = ageMs !== null && ageMs <= windowMs
1068
+ let holdPollAt: number | null = null
1069
+ try { holdPollAt = this.deps.lastHoldPollAt?.() ?? null } catch { holdPollAt = null }
1070
+ return { ageMs, recent, holdLive: recent && this.polledWithin(holdPollAt, now), windowMs }
1071
+ }
1072
+
1073
+ /** 6.60.0: the installed hook timeout, or null when absent or unreadable. */
1074
+ private installedHookTimeoutS(): number | null {
1075
+ let value: number | null = null
1076
+ try { value = this.deps.installedHookTimeoutS?.() ?? null } catch { value = null }
1077
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
1078
+ }
1079
+
1080
+ /**
1081
+ * 6.60.0: how long a request arriving now, from a hook that stamped `hookWaitS`, may be held:
1082
+ * the away hold when it applies (never shorter than the base timeout), else the base
1083
+ * timeout, and never past `holdCeilingS`. `ceilingAllowsAway` is the hook waiting longer
1084
+ * than the 6.59.0 broker could hold.
1085
+ */
1086
+ private holdMs(hookWaitS: number | null, away: boolean): { holdMs: number; awayHoldMs: number; installedHookTimeoutS: number | null; ceilingS: number; ceilingAllowsAway: boolean } {
1087
+ const awayHoldMs = away ? this.awayHoldMs() : 0
1088
+ const installedHookTimeoutS = this.installedHookTimeoutS()
1089
+ const ceilingS = holdCeilingS(installedHookTimeoutS, hookWaitS)
1090
+ const baseMs = this.deps.timeoutMs()
1091
+ const wanted = awayHoldMs > 0 ? Math.max(baseMs, awayHoldMs) : baseMs
1092
+ return { holdMs: Math.min(wanted, ceilingS * 1000), awayHoldMs, installedHookTimeoutS, ceilingS, ceilingAllowsAway: ceilingS > AWAY_MIN_CEILING_S }
1093
+ }
1094
+
1095
+ /** 6.60.0: the recent-presence window, for the questions route and `/api/models`. */
1096
+ awayRecentClientWindowMs(): number {
1097
+ return this.recentWindowMs()
1098
+ }
1099
+
1100
+ /**
1101
+ * 6.60.0: the longest a question that starts now while Miles is away is held, for a hook
1102
+ * as the settings file has it, when a `hold=1` client reported wearer presence within the
1103
+ * window (`awayHoldMs` on the questions route and `/api/models`). 0 when the away hold is
1104
+ * off, or when the installed hook waits no longer than the 6.59.0 broker could hold.
1105
+ */
1106
+ awayHoldNowMs(): number {
1107
+ if (this.awayHoldMs() === 0) return 0
1108
+ const hold = this.holdMs(this.installedHookTimeoutS(), true)
1109
+ return hold.ceilingAllowsAway ? hold.holdMs : 0
1110
+ }
1111
+
726
1112
  /** The desk-idle threshold, never below DESK_IDLE_MIN_S. */
727
1113
  private deskIdleThreshold(): number {
728
1114
  const configured = Number(this.deps.deskIdleSeconds())
@@ -756,17 +1142,33 @@ export class PermissionBroker {
756
1142
  if (UNBROKERED_TOOLS.has(facts.toolName)) return this.noteFastPath('unsupported_tool')
757
1143
  }
758
1144
  const now = this.deps.now()
759
- if (!this.clientLive(now)) return this.noteFastPath('no_client')
1145
+ const presence = this.presence(now)
1146
+ const clientLive = this.clientLive(now) || presence.holdLive
1147
+ // 6.60.0: the away hold applies only when it is on, the installed hook waits longer than
1148
+ // the 6.59.0 broker could hold, and a `hold=1` client reported wearer presence within the
1149
+ // window (Miles, 19:54; QA round 1).
1150
+ const switchOn = this.awayHoldMs() > 0
1151
+ const ceilingAllowsAway = this.holdMs(facts.hookWaitS, false).ceilingAllowsAway
1152
+ const away = switchOn && ceilingAllowsAway && presence.recent
1153
+ const notAwayReason: NotAwayReason | null = away ? null
1154
+ : !switchOn ? 'hold_off'
1155
+ : !ceilingAllowsAway ? 'ceiling_at_base'
1156
+ : presence.ageMs === null ? 'no_hold_client'
1157
+ : 'stale_presence'
1158
+ const hold = this.holdMs(facts.hookWaitS, away)
1159
+ // With it, a request is held whether or not a client is polling right now, so a lens or
1160
+ // phone that comes back can still answer it. Without it, no client is `{}`, as in 6.59.0.
1161
+ if (!clientLive && !away) return this.noteFastPath('no_client', { notAway: notAwayReason!, presence, clientLive })
760
1162
  // The deadline counts from when the HOOK started, when its stamp is sane: a request the
761
1163
  // server read late must still be answered before the hook's curl gives up.
762
1164
  const requestedAt = facts.hookStartedAtMs !== null && facts.hookStartedAtMs <= now ? facts.hookStartedAtMs : now
763
- const deadlineAt = requestedAt + this.deps.timeoutMs()
1165
+ const deadlineAt = requestedAt + hold.holdMs
764
1166
  if (deadlineAt <= now) return this.noteFastPath('stale_hook')
765
1167
  if (this.pendingCount() >= MAX_PENDING) return this.noteFastPath('capacity')
766
1168
  let idle: number | null = null
767
1169
  try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null }
768
1170
  if (idle === null) return this.noteFastPath('desk_unknown')
769
- if (idle < this.deskIdleThreshold()) return this.noteFastPath('desk_active')
1171
+ if (idle < this.deskIdleThreshold()) return this.noteFastPath('desk_active', { notAway: 'desk_active', presence, clientLive })
770
1172
  const mode = this.deps.mode()
771
1173
  if (mode === 'off') return this.noteFastPath('broker_off')
772
1174
  if (!this.deps.admissionsOpen()) return this.noteFastPath('draining')
@@ -785,7 +1187,20 @@ export class PermissionBroker {
785
1187
  approval = approvalCard(facts.toolName, facts.toolInput)
786
1188
  }
787
1189
  const fingerprint = toolFingerprint(facts.toolName, facts.toolInput)
788
- return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt } }
1190
+ const awayHold = away
1191
+ const holdFacts: HoldFacts = {
1192
+ installedHookTimeoutS: hold.installedHookTimeoutS,
1193
+ hookWaitS: facts.hookWaitS,
1194
+ awayHoldS: Math.round(hold.awayHoldMs / 1000),
1195
+ ceilingS: hold.ceilingS,
1196
+ effectiveDeadlineS: Math.round(hold.holdMs / 1000),
1197
+ deskIdleMs: idle * 1000,
1198
+ clientLive,
1199
+ presenceAgeMs: presence.ageMs === null ? null : Math.round(presence.ageMs),
1200
+ recentWindowS: Math.round(presence.windowMs / 1000),
1201
+ notAwayReason,
1202
+ }
1203
+ return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, hold: holdFacts } }
789
1204
  }
790
1205
 
791
1206
  /** Hold the hook's response until exactly one resolution. Returns the item id. */
@@ -813,14 +1228,20 @@ export class PermissionBroker {
813
1228
  handedBackBy: null,
814
1229
  channel,
815
1230
  timer: null,
1231
+ awayHold: request.awayHold === true,
1232
+ waitingAtMac: false,
816
1233
  }
817
1234
  this.items.set(id, item)
818
1235
  this.counters.parked++
1236
+ if (item.awayHold) this.counters.awayHeld++
1237
+ if (request.hold && !request.hold.clientLive) this.counters.parkedWithoutClient++
819
1238
  item.timer = setTimeout(() => { this.settle(item, 'expired') }, Math.max(0, request.deadlineAt - now))
820
1239
  item.timer.unref?.()
821
1240
  try { this.deps.signals?.attach(item.sessionId, id, item.fingerprint) } catch { /* rows are advisory */ }
822
1241
  this.ensurePolling()
823
- this.log(`parked ${id} kind=${item.kind} session=${item.sessionId.slice(0, 8)} deadline_s=${Math.round((request.deadlineAt - now) / 1000)} pending=${this.pendingCount()}`)
1242
+ // 6.60.0: the numbers the deadline came from, once per request (never text).
1243
+ const hold = request.hold ? ` hold=${JSON.stringify(request.hold)}` : ''
1244
+ 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}`)
824
1245
  return id
825
1246
  }
826
1247
 
@@ -838,7 +1259,10 @@ export class PermissionBroker {
838
1259
  if (item.timer) clearTimeout(item.timer)
839
1260
  item.timer = null
840
1261
  if (resolution !== 'hook_gone') {
841
- try { item.channel?.reply({}) } catch { /* the hook is gone anyway */ }
1262
+ let written = false
1263
+ try { written = item.channel?.reply({}) === true } catch { /* the hook is gone anyway */ }
1264
+ // 6.60.0: `{}` reached the hook, so the Mac's own dialog is up for it now.
1265
+ item.waitingAtMac = written && MAC_DIALOG_RESOLUTIONS.has(resolution)
842
1266
  }
843
1267
  item.channel = null
844
1268
  // A settled item is kept for its codes, not its content.
@@ -870,11 +1294,70 @@ export class PermissionBroker {
870
1294
  deadlineAt: new Date(item.deadlineAt).toISOString(),
871
1295
  ...(item.questions ? { questions: item.questions } : {}),
872
1296
  ...(item.approval ? { approval: item.approval } : {}),
1297
+ answerable: true,
1298
+ awayHold: item.awayHold,
1299
+ waitingAtMac: false,
873
1300
  })
874
1301
  }
875
1302
  return views.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
876
1303
  }
877
1304
 
1305
+ /**
1306
+ * 6.60.0: the settled items still kept, newest settled first, at most SETTLED_LIST_MAX:
1307
+ * codes and times only. `waitingAtMac` says the Mac's own dialog is still waiting on one.
1308
+ */
1309
+ settledList(): SettledQuestionView[] {
1310
+ this.prune()
1311
+ const settled: BrokerItem[] = []
1312
+ for (const item of this.items.values()) if (item.state !== 'pending' && item.settledAt !== null) settled.push(item)
1313
+ settled.sort((a, b) => (b.settledAt ?? 0) - (a.settledAt ?? 0))
1314
+ // Every item the Mac still waits on is listed whatever the cap, so a missing one is never
1315
+ // read as "moved past" (Ghost Hunter W1); the newest others fill up to SETTLED_LIST_MAX.
1316
+ const waiting = settled.filter(item => item.waitingAtMac)
1317
+ const others = settled.filter(item => !item.waitingAtMac).slice(0, Math.max(0, SETTLED_LIST_MAX - waiting.length))
1318
+ const listed = [...waiting, ...others].sort((a, b) => (b.settledAt ?? 0) - (a.settledAt ?? 0))
1319
+ return listed.map(item => ({
1320
+ id: item.id,
1321
+ sessionId: item.sessionId,
1322
+ provider: 'claude' as const,
1323
+ kind: item.kind,
1324
+ tool: item.toolName,
1325
+ createdAt: new Date(item.createdAt).toISOString(),
1326
+ deadlineAt: new Date(item.deadlineAt).toISOString(),
1327
+ settledAt: new Date(item.settledAt!).toISOString(),
1328
+ resolution: item.state as BrokerResolution,
1329
+ answerable: false as const,
1330
+ awayHold: item.awayHold,
1331
+ waitingAtMac: item.waitingAtMac,
1332
+ }))
1333
+ }
1334
+
1335
+ /**
1336
+ * 6.60.0 (QA round 1, N2): the session's row now reads ended (its process is gone, or it
1337
+ * ended), so no Mac dialog of it can still be waiting. Cleared at once, not after the hour.
1338
+ * Takes a full session id or the registry's eight-character form. Returns how many cleared.
1339
+ */
1340
+ sessionRowEnded(sessionId: string): number {
1341
+ const wanted = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
1342
+ if (wanted.length < 8) return 0
1343
+ let cleared = 0
1344
+ for (const item of this.items.values()) {
1345
+ if (!item.waitingAtMac) continue
1346
+ if (item.sessionId !== wanted && !(wanted.length < 36 && item.sessionId.startsWith(wanted))) continue
1347
+ item.waitingAtMac = false
1348
+ cleared++
1349
+ }
1350
+ if (cleared > 0) this.log(`session row ended: ${cleared} no longer waiting at the Mac session=${wanted.slice(0, 8)}`)
1351
+ return cleared
1352
+ }
1353
+
1354
+ /** 6.60.0: how many settled items are still waiting at the Mac. */
1355
+ waitingAtMacCount(): number {
1356
+ let n = 0
1357
+ for (const item of this.items.values()) if (item.state !== 'pending' && item.waitingAtMac) n++
1358
+ return n
1359
+ }
1360
+
878
1361
  /**
879
1362
  * `POST /api/session-questions/:id/answer`. Synchronous from the state check to the
880
1363
  * reply, so two racing answers cannot both win: the first one settles the item and the
@@ -1040,17 +1523,13 @@ export class PermissionBroker {
1040
1523
  } catch { /* rows are advisory */ }
1041
1524
  continue
1042
1525
  }
1043
- if (item.state !== 'pending' || env.ts < item.requestedAt) continue
1044
- if (TURN_BOUNDARIES.has(env.event) || (env.event === 'SessionStart' && p.source !== 'compact')) {
1045
- this.settle(item, 'retracted')
1526
+ // 6.60.0: one handed to the Mac's dialog stops waiting there when the session moves past it.
1527
+ if (item.waitingAtMac && env.ts >= item.requestedAt && sessionMovedPast(item, env, toolName, fingerprint, true)) {
1528
+ item.waitingAtMac = false
1046
1529
  continue
1047
1530
  }
1048
- if (env.event === 'PostToolUse' || env.event === 'PostToolUseFailure' || env.event === 'PermissionDenied') {
1049
- const same = item.kind === 'question'
1050
- ? toolName === ASK_USER_QUESTION_TOOL
1051
- : fingerprint === item.fingerprint || (env.event === 'PermissionDenied' && toolName === item.toolName)
1052
- if (same) this.settle(item, 'retracted')
1053
- }
1531
+ if (item.state !== 'pending' || env.ts < item.requestedAt) continue
1532
+ if (sessionMovedPast(item, env, toolName, fingerprint)) this.settle(item, 'retracted')
1054
1533
  }
1055
1534
  }
1056
1535
 
@@ -1075,7 +1554,16 @@ export class PermissionBroker {
1075
1554
  async tick(): Promise<void> {
1076
1555
  if (this.pendingCount() === 0) { this.stopPolling(); return }
1077
1556
  if (!this.deps.admissionsOpen()) { this.settleAll('drained'); return }
1078
- if (!this.clientLive(this.deps.now())) { this.settleAll('no_client'); return }
1557
+ const now = this.deps.now()
1558
+ const presence = this.presence(now)
1559
+ if (!(this.clientLive(now) || presence.holdLive)) {
1560
+ // 6.60.0: an item held by the away hold outlives the client going quiet, but only while
1561
+ // the wearer's latest presence is still within the window (QA round 1, Skeptic W2: a
1562
+ // single glance must not hold a question 10 more minutes). The desk below still hands
1563
+ // it back.
1564
+ this.settleAll('no_client', item => !item.awayHold || !presence.recent)
1565
+ if (this.pendingCount() === 0) return
1566
+ }
1079
1567
  if (this.pollInFlight) return
1080
1568
  this.pollInFlight = true
1081
1569
  let idle: number | null = null
@@ -1091,25 +1579,49 @@ export class PermissionBroker {
1091
1579
  this.settleAll('handed_to_desk')
1092
1580
  }
1093
1581
 
1094
- private settleAll(resolution: 'handed_to_desk' | 'drained' | 'no_client'): void {
1095
- for (const item of [...this.items.values()]) if (item.state === 'pending') this.settle(item, resolution)
1582
+ private settleAll(resolution: 'handed_to_desk' | 'drained' | 'no_client', only: (item: BrokerItem) => boolean = () => true): void {
1583
+ for (const item of [...this.items.values()]) if (item.state === 'pending' && only(item)) this.settle(item, resolution)
1096
1584
  if (this.pendingCount() === 0) this.stopPolling()
1097
1585
  }
1098
1586
 
1099
- /** Settled items are kept for SETTLED_RETENTION_MS (late answers, replays), at most MAX_SETTLED. */
1587
+ /**
1588
+ * Settled items are kept for SETTLED_RETENTION_MS (late answers, replays), at most
1589
+ * MAX_SETTLED. 6.60.0: one still waiting at the Mac is kept for MAC_WAIT_RETENTION_MS.
1590
+ */
1100
1591
  private prune(): void {
1101
1592
  const now = this.deps.now()
1102
1593
  const settled: BrokerItem[] = []
1103
1594
  for (const item of this.items.values()) {
1104
1595
  if (item.state === 'pending') continue
1105
- if (item.settledAt !== null && now - item.settledAt > SETTLED_RETENTION_MS) this.items.delete(item.id)
1596
+ if (item.settledAt !== null && now - item.settledAt > this.retentionMs(item)) this.forget(item)
1106
1597
  else settled.push(item)
1107
1598
  }
1599
+ // Over the cap, the oldest that no longer wait go first.
1108
1600
  const excess = settled.length - MAX_SETTLED
1109
- for (let i = 0; i < excess; i++) this.items.delete(settled[i]!.id)
1601
+ if (excess > 0) {
1602
+ settled.sort((a, b) => Number(a.waitingAtMac) - Number(b.waitingAtMac) || (a.settledAt ?? 0) - (b.settledAt ?? 0))
1603
+ for (let i = 0; i < excess; i++) this.forget(settled[i]!)
1604
+ }
1110
1605
  if (this.pendingCount() === 0) this.stopPolling()
1111
1606
  }
1112
1607
 
1608
+ /**
1609
+ * How long a settled item is kept: ten minutes; an hour while the Mac waits on it; a day
1610
+ * while its session's row still waits on that very request (Ghost Hunter W1).
1611
+ */
1612
+ private retentionMs(item: BrokerItem): number {
1613
+ if (!item.waitingAtMac) return SETTLED_RETENTION_MS
1614
+ let still = false
1615
+ try { still = this.deps.stillWaitingAtMac?.(item.sessionId, item.fingerprint) === true } catch { still = false }
1616
+ return still ? MAC_WAIT_MAX_RETENTION_MS : MAC_WAIT_RETENTION_MS
1617
+ }
1618
+
1619
+ /** Drop a settled item. One the Mac may still wait on moves the forgotten horizon. */
1620
+ private forget(item: BrokerItem): void {
1621
+ this.items.delete(item.id)
1622
+ if (item.waitingAtMac && item.settledAt !== null && item.settledAt > this.waitingForgottenBefore) this.waitingForgottenBefore = item.settledAt
1623
+ }
1624
+
1113
1625
  health(): PermissionBrokerHealth {
1114
1626
  const mode = this.deps.mode()
1115
1627
  let polledAt: number | null = null
@@ -1184,6 +1696,17 @@ export interface SessionQuestionsCapability {
1184
1696
  protocolVersion: number
1185
1697
  pollIntervalMs: number
1186
1698
  liveWindowMs: number
1699
+ /**
1700
+ * 6.60.0: the longest a question that starts while you are away is held for the glasses,
1701
+ * whether or not a client is polling (`PermissionBroker.awayHoldNowMs`); 0 when the away
1702
+ * hold is off or the broker is.
1703
+ */
1704
+ awayHoldMs: number
1705
+ /**
1706
+ * 6.60.0: the away hold applies only when a client that can answer polled within this many
1707
+ * ms (`COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`); 0 means it must be polling right now.
1708
+ */
1709
+ awayRecentClientMs: number
1187
1710
  }
1188
1711
 
1189
1712
  /**
@@ -1194,11 +1717,17 @@ export interface SessionQuestionsCapability {
1194
1717
  export function sessionQuestionsCapability(): SessionQuestionsCapability {
1195
1718
  let mode: BrokerMode = 'off'
1196
1719
  try { mode = registered ? registered.health().mode : 'off' } catch { mode = 'off' }
1720
+ let awayHoldMs = 0
1721
+ try { awayHoldMs = registered && mode !== 'off' ? registered.awayHoldNowMs() : 0 } catch { awayHoldMs = 0 }
1722
+ let awayRecentClientMs = 0
1723
+ try { awayRecentClientMs = registered && awayHoldMs > 0 ? registered.awayRecentClientWindowMs() : 0 } catch { awayRecentClientMs = 0 }
1197
1724
  return {
1198
1725
  enabled: mode !== 'off',
1199
1726
  mode,
1200
1727
  protocolVersion: SESSION_QUESTIONS_PROTOCOL_VERSION,
1201
1728
  pollIntervalMs: QUESTIONS_POLL_INTERVAL_MS,
1202
1729
  liveWindowMs: CLIENT_LIVE_WINDOW_MS,
1730
+ awayHoldMs,
1731
+ awayRecentClientMs,
1203
1732
  }
1204
1733
  }