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