@gotcos/glasses-server 6.58.2 → 6.60.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.
- package/CHANGELOG.md +57 -0
- package/README.md +79 -7
- package/bin/hooks/cos-session-hook +24 -4
- package/docs/work-handoff-requests.md +181 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +16 -6
- package/managed-runtime-contract.json +2 -0
- package/package.json +5 -2
- package/server/index.ts +35 -3
- package/server/lib/claude-hooks-installer.ts +120 -17
- package/server/lib/client-liveness.ts +92 -4
- package/server/lib/permission-broker.ts +560 -31
- package/server/lib/query-job-types.ts +11 -2
- package/server/lib/session-hooks-runtime.ts +23 -0
- package/server/lib/work-activity.ts +377 -39
- package/server/lib/work-board-reader.ts +63 -0
- package/server/lib/work-handoff-requests.ts +426 -0
- package/server/routes/permission-broker.ts +37 -3
- package/server/routes/work-board.ts +55 -10
- package/server/routes/work-handoff-requests.ts +159 -0
|
@@ -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: `{}
|
|
44
|
-
// (`COS_PERMISSION_BROKER_TIMEOUT_S`, default 110, clamped
|
|
45
|
-
// hook's 125 s
|
|
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
|
-
/**
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1044
|
-
if (
|
|
1045
|
-
|
|
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 (
|
|
1049
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 >
|
|
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
|
-
|
|
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
|
}
|