@gotcos/glasses-server 7.0.31 → 7.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -8
- package/CHANGELOG.md +161 -0
- package/bin/hooks/cos-session-hook +3 -2
- package/docs/pairing-fixture.json +1 -1
- package/package.json +3 -1
- package/server/index.ts +4 -2
- package/server/lib/api-auth.ts +3 -2
- package/server/lib/archive.ts +4 -2
- package/server/lib/claude-hooks-installer.ts +101 -15
- package/server/lib/codex-hooks-installer.ts +4 -1
- package/server/lib/full-hold.ts +61 -5
- package/server/lib/glasses-pairing.ts +2 -1
- package/server/lib/permission-broker.ts +130 -14
- package/server/lib/question-hold-setting.ts +38 -0
- package/server/lib/question-queue.ts +111 -0
- package/server/lib/session-hooks-runtime.ts +28 -3
- package/server/lib/session-title-cache.ts +40 -0
- package/server/lib/turn-end-ask.ts +51 -1
- package/server/lib/web-session-auth.ts +17 -0
- package/server/routes/agent-sessions.ts +3 -0
- package/server/routes/permission-broker.ts +31 -2
- package/server/routes/session-hooks.ts +23 -0
- package/server/routes/sessions.ts +3 -0
- package/server/web-control/.manifest.json +9 -9
- package/server/web-control/app.css +26 -3
- package/server/web-control/app.js +234 -35
- package/server/web-control/control.css +5 -1
- package/server/web-control/index.html +7 -5
- package/server/web-control/panes/memories.js +18 -9
- package/server/web-control/panes/messages.js +15 -3
- package/server/web-control/panes/questions.js +6 -1
- package/server/web-control/panes/settings.js +18 -10
- package/server/web-control/shell.js +8 -13
|
@@ -64,7 +64,8 @@ export const PAIRING_REASON_STATUS: Record<PairingReason, number> = {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export const PAIRING_REASON_MESSAGE: Record<PairingReason, string> = {
|
|
67
|
-
|
|
67
|
+
// 7.0.32 (Miles 11/13 06:34): the glasses pair by the typed code only, so no pairing answer says scan.
|
|
68
|
+
bad_request: 'That pairing request was not understood. Check the code in COS Control and type it again.',
|
|
68
69
|
not_loopback: 'Pairing codes are made only by COS Control on this Mac.',
|
|
69
70
|
not_allowed_network: 'Connect over Tailscale, or turn on "Allow pairing on this Wi-Fi" in COS Control.',
|
|
70
71
|
denied: 'Pairing was denied on the Mac.',
|
|
@@ -140,7 +140,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
140
140
|
import { boundForRedaction } from './activity-preview.js'
|
|
141
141
|
import { inferHookProvider, toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
|
|
142
142
|
import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signal-store.js'
|
|
143
|
-
import { WEB_ANSWER_TOUCH_MAX_MS, WEB_CONFIRM_TTL_MS, engineProvenForFullHold, type EngineFacts, type FullHoldSwitches } from './full-hold.js'
|
|
143
|
+
import { FULL_HOLD_QUESTION_WAIT_S, WEB_ANSWER_TOUCH_MAX_MS, WEB_CONFIRM_TTL_MS, defaultHoldTakesStamp, engineProvenForFullHold, type EngineFacts, type FullHoldSwitches } from './full-hold.js'
|
|
144
144
|
|
|
145
145
|
// ---------------------------------------------------------------------------
|
|
146
146
|
// Configuration
|
|
@@ -228,7 +228,7 @@ export const LEGACY_HOOK_WAIT_S = 130
|
|
|
228
228
|
*/
|
|
229
229
|
export const HOOK_REPLY_MARGIN_S = 10
|
|
230
230
|
/** A `hookWaitS` stamp outside this range is not one the installer writes: no stamp. */
|
|
231
|
-
export const HOOK_WAIT_STAMP_MAX_S =
|
|
231
|
+
export const HOOK_WAIT_STAMP_MAX_S = FULL_HOLD_QUESTION_WAIT_S
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
234
|
* 6.60.0: the longest a request may be held, in seconds, from the hook's own start.
|
|
@@ -748,6 +748,46 @@ export type BrokerItemKind = 'question' | 'approval'
|
|
|
748
748
|
/** 6.99.27: the door an answer came through: the Even app with the API token, or COS Control Web's browser session. */
|
|
749
749
|
export type AnswerSurface = 'app' | 'web'
|
|
750
750
|
|
|
751
|
+
/**
|
|
752
|
+
* X1 (the one queue, 7.0.32 candidate): which surface answered, as the queue says it ("Answered on the glasses").
|
|
753
|
+
* `web` is the browser door. The token door names its client when it says so (`X-Cos-Client` or `?client=`:
|
|
754
|
+
* glasses, phone, mac_app); otherwise `cos` (a COS surface that did not say which). `mac` is the Mac's own dialog.
|
|
755
|
+
*/
|
|
756
|
+
export type QueueSurface = 'mac' | 'web' | 'glasses' | 'phone' | 'mac_app' | 'cos'
|
|
757
|
+
const CLIENT_SURFACES: ReadonlySet<string> = new Set(['glasses', 'phone', 'mac_app'])
|
|
758
|
+
|
|
759
|
+
export function answerSurfaceOf(by: AnswerSurface, client: unknown): QueueSurface {
|
|
760
|
+
if (by === 'web') return 'web'
|
|
761
|
+
const said = typeof client === 'string' ? client.trim().toLowerCase().replace(/-/g, '_') : ''
|
|
762
|
+
return CLIENT_SURFACES.has(said) ? said as QueueSurface : 'cos'
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* X1: an answered entry stays in the queue this long, saying where it was answered. The same window the waiting list
|
|
767
|
+
* already keeps an answered question for (WAITING_ANSWERED_KEEP_MS), so a question's answered entry is dropped by the
|
|
768
|
+
* list's own prune; an approval's by the check in `queueSources`.
|
|
769
|
+
*/
|
|
770
|
+
export const QUEUE_ANSWERED_KEEP_MS = 120_000
|
|
771
|
+
|
|
772
|
+
/** X1: one waiting thing the broker knows of, for the queue (`lib/question-queue.ts` builds the wire entries). */
|
|
773
|
+
export interface BrokerQueueSource {
|
|
774
|
+
key: string
|
|
775
|
+
sessionId: string
|
|
776
|
+
provider: 'claude' | 'codex'
|
|
777
|
+
kind: 'question' | 'approval'
|
|
778
|
+
tool: string
|
|
779
|
+
questions: BrokerQuestion[]
|
|
780
|
+
approval: ApprovalCard | null
|
|
781
|
+
askedAt: number
|
|
782
|
+
/** `here`: a COS surface can answer it now (`heldId`); `mac`: only the Mac's own dialog can. */
|
|
783
|
+
where: 'here' | 'mac'
|
|
784
|
+
heldId: string | null
|
|
785
|
+
deadlineAt: number | null
|
|
786
|
+
state: 'waiting' | 'answered'
|
|
787
|
+
answeredBy: QueueSurface | null
|
|
788
|
+
answeredAt: number | null
|
|
789
|
+
}
|
|
790
|
+
|
|
751
791
|
export type BrokerResolution = 'answered' | 'handed_to_desk' | 'expired' | 'hook_gone' | 'retracted' | 'drained' | 'no_client'
|
|
752
792
|
export type BrokerFastPath =
|
|
753
793
|
| 'broker_off' | 'draining' | 'malformed' | 'cursor' | 'approvals_off' | 'unsupported_tool'
|
|
@@ -942,7 +982,7 @@ interface BrokerItem {
|
|
|
942
982
|
deadlineAt: number
|
|
943
983
|
state: 'pending' | BrokerResolution
|
|
944
984
|
settledAt: number | null
|
|
945
|
-
answer: { clientAnswerId: string; chosen: Chosen } | null
|
|
985
|
+
answer: { clientAnswerId: string; chosen: Chosen; surface?: QueueSurface } | null
|
|
946
986
|
/** The clientAnswerId of the "Leave for the Mac" that settled it, so its retry replays. */
|
|
947
987
|
handedBackBy: string | null
|
|
948
988
|
channel: HookReplyChannel | null
|
|
@@ -1287,20 +1327,31 @@ export class PermissionBroker {
|
|
|
1287
1327
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
|
1288
1328
|
}
|
|
1289
1329
|
|
|
1290
|
-
/** X1: the two switches now; both off when absent or unreadable. */
|
|
1330
|
+
/** X1: the two switches now; both off when absent or unreadable. 7.0.34: plus the question default. */
|
|
1291
1331
|
private fullHoldSwitches(): FullHoldSwitches {
|
|
1292
1332
|
try {
|
|
1293
1333
|
const on = this.deps.fullHold?.()
|
|
1294
|
-
return { questions: on?.questions === true, approvals: on?.approvals === true }
|
|
1334
|
+
return { questions: on?.questions === true, approvals: on?.approvals === true, questionsByDefault: on?.questionsByDefault === true }
|
|
1295
1335
|
} catch {
|
|
1296
|
-
return { questions: false, approvals: false }
|
|
1336
|
+
return { questions: false, approvals: false, questionsByDefault: false }
|
|
1297
1337
|
}
|
|
1298
1338
|
}
|
|
1299
1339
|
|
|
1300
|
-
/**
|
|
1340
|
+
/**
|
|
1341
|
+
* X1: either switch is on (the web door then needs a touch, and an approval a confirm, and the `waiting` view
|
|
1342
|
+
* shows). 7.0.34: under the question DEFAULT this is on only once this Mac's hooks carry the long question block,
|
|
1343
|
+
* the same thing that makes its questions eligible (`defaultHoldTakesStamp` of the installed AskUserQuestion
|
|
1344
|
+
* block). Before that a Mac reads exactly as 7.0.33 with the switch off: no `waiting`, the same web door.
|
|
1345
|
+
*/
|
|
1301
1346
|
private anyFullHoldOn(): boolean {
|
|
1302
1347
|
const on = this.fullHoldSwitches()
|
|
1303
|
-
|
|
1348
|
+
if (on.questions || on.approvals) return true
|
|
1349
|
+
return on.questionsByDefault === true && defaultHoldTakesStamp(this.installedHookTimeoutS('question'))
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** X1, 7.0.34: a question of this kind may be fully held under the switches now: on, or the default with a long stamp. */
|
|
1353
|
+
private questionHoldAllows(on: FullHoldSwitches, hookWaitS: number | null | undefined): boolean {
|
|
1354
|
+
return on.questions || (on.questionsByDefault === true && defaultHoldTakesStamp(hookWaitS))
|
|
1304
1355
|
}
|
|
1305
1356
|
|
|
1306
1357
|
/**
|
|
@@ -1312,7 +1363,7 @@ export class PermissionBroker {
|
|
|
1312
1363
|
*/
|
|
1313
1364
|
private fullHoldEligible(isQuestion: boolean, askHold: boolean, background: boolean, facts: Omit<PermissionRequestFacts, 'fingerprint'>): boolean {
|
|
1314
1365
|
const on = this.fullHoldSwitches()
|
|
1315
|
-
if (isQuestion ? !on.
|
|
1366
|
+
if (isQuestion ? !this.questionHoldAllows(on, facts.hookWaitS) : !on.approvals) return false
|
|
1316
1367
|
if (background || facts.provider === 'codex') return false
|
|
1317
1368
|
if (isQuestion && !askHold) return false
|
|
1318
1369
|
if (facts.hookWaitS === null) return false
|
|
@@ -1485,7 +1536,10 @@ export class PermissionBroker {
|
|
|
1485
1536
|
...(webOnly ? { webOnly: true as const, webPresenceAgeMs: Math.round(presence.webAgeMs ?? 0) } : {}),
|
|
1486
1537
|
...(full ? { fullHold: true as const } : {}),
|
|
1487
1538
|
}
|
|
1488
|
-
|
|
1539
|
+
// X1 (Miles 11/12 17:42, "At once, on all three"): a fully held question is on the web and the glasses the moment
|
|
1540
|
+
// it is asked, beside the Mac's own dialog; the first answer wins. Only a full-hold item (its switch on, engine
|
|
1541
|
+
// proven): with the switches off, a stamped question still waits for the desk to be idle, exactly as 7.0.31.
|
|
1542
|
+
return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || full || (idle !== null && idle >= this.deskIdleThreshold()), ...(webOnly ? { webOnly: true } : {}), ...(full ? { fullHold: true, enginePid } : {}), hold: holdFacts } }
|
|
1489
1543
|
}
|
|
1490
1544
|
|
|
1491
1545
|
/** Hold the hook's response until exactly one resolution. Returns the item id. */
|
|
@@ -1675,7 +1729,7 @@ export class PermissionBroker {
|
|
|
1675
1729
|
* clientAnswerId) replays its 200. `handBack` wins over any answer fields in the same body:
|
|
1676
1730
|
* it is the safe direction, since it only shows the dialog that would have shown anyway.
|
|
1677
1731
|
*/
|
|
1678
|
-
answer(id: string, body: unknown, by: AnswerSurface = 'app'): AnswerResult {
|
|
1732
|
+
answer(id: string, body: unknown, by: AnswerSurface = 'app', client?: unknown): AnswerResult {
|
|
1679
1733
|
const item = BROKER_ID_RE.test(id) ? this.items.get(id) : undefined
|
|
1680
1734
|
if (!item) return { status: 404, body: { error: 'not_found' } }
|
|
1681
1735
|
const input = isRecord(body) ? body : {}
|
|
@@ -1700,7 +1754,9 @@ export class PermissionBroker {
|
|
|
1700
1754
|
if (handBack) return this.handBack(item, clientAnswerId)
|
|
1701
1755
|
// Q1 O1: while a switch is on, an item no device is shown yet (the desk-idle latch has not fired) is
|
|
1702
1756
|
// not answerable by its id, from any door. A hand-back stays allowed: it only shows the Mac's dialog.
|
|
1703
|
-
|
|
1757
|
+
// X1 (at once): a full-hold item is shown the moment it is parked, so only a stamped question the full hold
|
|
1758
|
+
// did not take can be unshown here.
|
|
1759
|
+
if (!item.lensVisible && this.anyFullHoldOn()) {
|
|
1704
1760
|
this.log(`not_shown ${item.id} kind=${item.kind}`)
|
|
1705
1761
|
return { status: 409, body: { error: 'not_shown' } }
|
|
1706
1762
|
}
|
|
@@ -1740,7 +1796,7 @@ export class PermissionBroker {
|
|
|
1740
1796
|
}
|
|
1741
1797
|
item.state = 'answered'
|
|
1742
1798
|
item.settledAt = this.deps.now()
|
|
1743
|
-
item.answer = { clientAnswerId, chosen }
|
|
1799
|
+
item.answer = { clientAnswerId, chosen, surface: answerSurfaceOf(by, client) }
|
|
1744
1800
|
item.channel = null
|
|
1745
1801
|
item.toolInput = {}
|
|
1746
1802
|
item.questions = null
|
|
@@ -1918,6 +1974,11 @@ export class PermissionBroker {
|
|
|
1918
1974
|
|
|
1919
1975
|
waitingList(): WaitingQuestionView[] {
|
|
1920
1976
|
if (!this.extendedQuestionsView()) return []
|
|
1977
|
+
return this.waitingViews()
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/** X1: the waiting views, whatever the switches (the queue needs them; `waitingList` is gated as before). */
|
|
1981
|
+
private waitingViews(): WaitingQuestionView[] {
|
|
1921
1982
|
const now = this.deps.now()
|
|
1922
1983
|
this.pruneWaiting(now)
|
|
1923
1984
|
const answerable = (item: BrokerItem): boolean => item.state === 'pending' && item.lensVisible && now < item.deadlineAt
|
|
@@ -1962,6 +2023,60 @@ export class PermissionBroker {
|
|
|
1962
2023
|
return views.sort((a, b) => a.askedAt.localeCompare(b.askedAt))
|
|
1963
2024
|
}
|
|
1964
2025
|
|
|
2026
|
+
/**
|
|
2027
|
+
* X1 (the one queue): every question and approval a live session waits on that this server knows of, whatever
|
|
2028
|
+
* the switches, plus what was answered in the last QUEUE_ANSWERED_KEEP_MS with where. Questions come from the
|
|
2029
|
+
* waiting entries (held or at the Mac) and from settled questions the Mac still waits on that no entry speaks
|
|
2030
|
+
* for (asked before a restart); approvals from pending items and settled ones the Mac still waits on. Text is
|
|
2031
|
+
* kept only while it waits, as everywhere in this file. Turn-end asks are added by the queue builder.
|
|
2032
|
+
*/
|
|
2033
|
+
queueSources(): BrokerQueueSource[] {
|
|
2034
|
+
const now = this.deps.now()
|
|
2035
|
+
this.prune()
|
|
2036
|
+
const out: BrokerQueueSource[] = []
|
|
2037
|
+
const views = this.waitingViews()
|
|
2038
|
+
const settledSurface = (match: (item: BrokerItem) => boolean): QueueSurface | null => {
|
|
2039
|
+
for (const item of this.items.values()) if (item.state === 'answered' && item.answer && item.answer.clientAnswerId !== 'cos-cancel' && match(item)) return item.answer.surface ?? 'cos'
|
|
2040
|
+
return null
|
|
2041
|
+
}
|
|
2042
|
+
for (const v of views) {
|
|
2043
|
+
if (v.state === 'answered') {
|
|
2044
|
+
// `waitingViews` lists an answered question only within WAITING_ANSWERED_KEEP_MS (= QUEUE_ANSWERED_KEEP_MS).
|
|
2045
|
+
const answeredAt = v.answeredAt ? Date.parse(v.answeredAt) : null
|
|
2046
|
+
if (answeredAt === null) continue
|
|
2047
|
+
const by: QueueSurface = v.answeredBy === 'cos'
|
|
2048
|
+
? settledSurface(item => item.kind === 'question' && item.sessionId === v.sessionId && (!!v.toolUseId && item.toolUseId === v.toolUseId)) ?? 'cos'
|
|
2049
|
+
: 'mac'
|
|
2050
|
+
out.push({ key: v.key, sessionId: v.sessionId, provider: v.provider, kind: 'question', tool: ASK_USER_QUESTION_TOOL, questions: [], approval: null, askedAt: Date.parse(v.askedAt), where: 'mac', heldId: null, deadlineAt: null, state: 'answered', answeredBy: by, answeredAt })
|
|
2051
|
+
continue
|
|
2052
|
+
}
|
|
2053
|
+
out.push({ key: v.key, sessionId: v.sessionId, provider: v.provider, kind: 'question', tool: ASK_USER_QUESTION_TOOL, questions: v.questions, approval: null, askedAt: Date.parse(v.askedAt), where: v.answerable ? 'here' : 'mac', heldId: v.heldId, deadlineAt: v.deadlineAt ? Date.parse(v.deadlineAt) : null, state: 'waiting', answeredBy: null, answeredAt: null })
|
|
2054
|
+
}
|
|
2055
|
+
const listedQuestion = (item: BrokerItem): boolean => views.some(v => v.heldId === item.id || (v.sessionId === item.sessionId && !!v.toolUseId && v.toolUseId === item.toolUseId))
|
|
2056
|
+
for (const item of this.items.values()) {
|
|
2057
|
+
const key = `${item.sessionId}:${item.toolUseId ?? item.id}`
|
|
2058
|
+
const base = { key, sessionId: item.sessionId, provider: item.provider, kind: item.kind, tool: item.toolName, askedAt: item.requestedAt }
|
|
2059
|
+
if (item.kind === 'question') {
|
|
2060
|
+
// A question the Mac still waits on that no waiting entry speaks for (asked before a restart): no words.
|
|
2061
|
+
if (item.state !== 'pending' && item.waitingAtMac && !listedQuestion(item)) {
|
|
2062
|
+
out.push({ ...base, questions: [], approval: null, where: 'mac', heldId: null, deadlineAt: null, state: 'waiting', answeredBy: null, answeredAt: null })
|
|
2063
|
+
}
|
|
2064
|
+
continue
|
|
2065
|
+
}
|
|
2066
|
+
if (item.state === 'pending') {
|
|
2067
|
+
if (!item.lensVisible || now >= item.deadlineAt) continue
|
|
2068
|
+
out.push({ ...base, questions: [], approval: item.approval, where: 'here', heldId: item.id, deadlineAt: item.deadlineAt, state: 'waiting', answeredBy: null, answeredAt: null })
|
|
2069
|
+
} else if (item.waitingAtMac) {
|
|
2070
|
+
out.push({ ...base, questions: [], approval: null, where: 'mac', heldId: null, deadlineAt: null, state: 'waiting', answeredBy: null, answeredAt: null })
|
|
2071
|
+
} else if (item.settledAt !== null && now - item.settledAt <= QUEUE_ANSWERED_KEEP_MS && (item.state === 'answered' || item.state === 'retracted')) {
|
|
2072
|
+
const by: QueueSurface | null = item.state === 'retracted' ? 'mac' : item.answer?.clientAnswerId === 'cos-cancel' ? null : (item.answer?.surface ?? 'cos')
|
|
2073
|
+
if (by === null) continue
|
|
2074
|
+
out.push({ ...base, questions: [], approval: null, where: 'mac', heldId: null, deadlineAt: null, state: 'answered', answeredBy: by, answeredAt: item.settledAt })
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
return out
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1965
2080
|
/** X1: what a web page must send with an answer right now (`webAnswer` on the questions route). */
|
|
1966
2081
|
webAnswerRules(): { touchRequired: boolean; touchMaxMs: number; confirmApprovals: boolean } {
|
|
1967
2082
|
const on = this.anyFullHoldOn()
|
|
@@ -2102,7 +2217,8 @@ export class PermissionBroker {
|
|
|
2102
2217
|
// The switches are read only while one is held, so with both off nothing here runs.
|
|
2103
2218
|
if ([...this.items.values()].some(item => item.state === 'pending' && item.fullHold)) {
|
|
2104
2219
|
const on = this.fullHoldSwitches()
|
|
2105
|
-
|
|
2220
|
+
// 7.0.34: a question held under the default stays held while the default (or on) still stands; off hands it back.
|
|
2221
|
+
this.settleAll('handed_to_desk', item => item.fullHold && (item.kind === 'question' ? !(on.questions || on.questionsByDefault === true) : !on.approvals))
|
|
2106
2222
|
if (this.pendingCount() === 0) return
|
|
2107
2223
|
// X1 round 3: a held item whose settling hook event never arrived (a lost spool event) must not sit
|
|
2108
2224
|
// to its deadline. Its own engine's registry record says whether the session moved on: the record is
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// X1, 7.0.34: the server-side question-hold setting that Control's Settings row writes
|
|
2
|
+
// (`PUT /api/session-hooks/question-hold`). One small file in the COS home: `{"mode":"on"}` or `{"mode":"off"}`.
|
|
3
|
+
// No file means the default (lib/full-hold.ts `questionHoldMode`). `COS_QUESTION_FULL_HOLD` in the environment
|
|
4
|
+
// always wins over this file; the route says so (`source: "env"`) rather than writing something that cannot apply.
|
|
5
|
+
//
|
|
6
|
+
// Read on every call, never cached: the broker reads it per question and the status per refresh, and a stale
|
|
7
|
+
// value after a Settings click would be a hold the person just turned off. The file is a few bytes.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
12
|
+
import { cosGlassesHome } from './claude-hooks-installer.js'
|
|
13
|
+
import type { QuestionHoldSetting } from './full-hold.js'
|
|
14
|
+
|
|
15
|
+
export const QUESTION_HOLD_SETTING_FILE = 'question-hold.json'
|
|
16
|
+
|
|
17
|
+
export function questionHoldSettingPath(home: string = cosGlassesHome()): string {
|
|
18
|
+
return join(home, QUESTION_HOLD_SETTING_FILE)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `on`, `off`, or null for no setting (the default). An unreadable or malformed file is no setting. */
|
|
22
|
+
export function readQuestionHoldSetting(path: string = questionHoldSettingPath()): QuestionHoldSetting | null {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { mode?: unknown }
|
|
25
|
+
return parsed && (parsed.mode === 'on' || parsed.mode === 'off') ? parsed.mode : null
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `on` or `off` writes the file; `default` removes it. */
|
|
32
|
+
export function writeQuestionHoldSetting(mode: QuestionHoldSetting | 'default', path: string = questionHoldSettingPath()): void {
|
|
33
|
+
if (mode === 'default') {
|
|
34
|
+
if (existsSync(path)) rmSync(path, { force: true })
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
atomicWriteFileSync(path, JSON.stringify({ mode }) + '\n', { mode: 0o600 })
|
|
38
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// X1 (Miles 2026-11-12 17:31, "a consistent way ... to access active questions that are in waiting and in queue"):
|
|
2
|
+
// ONE list of everything a session waits on him for, oldest first, built here once and served additively on
|
|
3
|
+
// GET /api/session-questions as `queue` and `queueCount`, so Control Web, the glasses and the Mac app list and
|
|
4
|
+
// count the same things. Present whatever the full-hold switches say: it is also how he finds a question that
|
|
5
|
+
// only the Mac's own dialog can answer.
|
|
6
|
+
//
|
|
7
|
+
// WHAT IS IN IT: questions COS holds (answerable here) or that wait at the Mac's dialog; tool approvals held or at
|
|
8
|
+
// the Mac (listed so they are found; answering them keeps every existing rule, `where` says which door); turn-end
|
|
9
|
+
// asks a row shows; and, for QUEUE_ANSWERED_KEEP_MS, what was just answered and on which surface, so a card can say
|
|
10
|
+
// "Answered on the glasses" instead of vanishing.
|
|
11
|
+
//
|
|
12
|
+
// PRIVACY: the only words are `line` (a question's first 80 characters, cleaned; an approval's existing summary;
|
|
13
|
+
// a turn-end ask's 80 characters) and a turn-end ask's reply labels, the same text the questions route, the
|
|
14
|
+
// session rows and the cards already carry to the same authenticated clients (the API token or the paired web
|
|
15
|
+
// session). With COS_WAITING_QUESTION_WORDS=0 a question's line is ''. Nothing here is logged.
|
|
16
|
+
|
|
17
|
+
import { questionRowText } from './full-hold.js'
|
|
18
|
+
import type { BrokerQueueSource, QueueSurface } from './permission-broker.js'
|
|
19
|
+
import type { AnsweredTurnAsk, QueueTurnAsk } from './turn-end-ask.js'
|
|
20
|
+
|
|
21
|
+
export const QUEUE_LINE_MAX = 80
|
|
22
|
+
/** Entries past this are dropped, oldest answered first, then oldest waiting. */
|
|
23
|
+
export const QUEUE_MAX = 64
|
|
24
|
+
|
|
25
|
+
export type QueueKind = 'question' | 'approval' | 'turn_end'
|
|
26
|
+
|
|
27
|
+
export interface QueueEntry {
|
|
28
|
+
key: string
|
|
29
|
+
sessionId: string
|
|
30
|
+
/** The session's title as the session list last showed it, or null (the client uses its own). */
|
|
31
|
+
title: string | null
|
|
32
|
+
provider: 'claude' | 'codex'
|
|
33
|
+
kind: QueueKind
|
|
34
|
+
/** One line: the question, the approval's summary, or the turn-end ask. '' when words are off or unknown. */
|
|
35
|
+
line: string
|
|
36
|
+
askedAt: string
|
|
37
|
+
/** `here`: answer it from COS now (`heldId` for questions and approvals; a turn-end ask by a reply). `mac`: only the Mac's dialog. */
|
|
38
|
+
where: 'here' | 'mac'
|
|
39
|
+
heldId: string | null
|
|
40
|
+
deadlineAt: string | null
|
|
41
|
+
/** A turn-end ask's reply labels (0 to 2); [] otherwise. */
|
|
42
|
+
choices: string[]
|
|
43
|
+
state: 'waiting' | 'answered'
|
|
44
|
+
answeredBy: QueueSurface | null
|
|
45
|
+
answeredAt: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface QuestionQueue { queue: QueueEntry[]; queueCount: number }
|
|
49
|
+
|
|
50
|
+
export interface QueueInput {
|
|
51
|
+
broker: readonly BrokerQueueSource[]
|
|
52
|
+
turnEnd: readonly QueueTurnAsk[]
|
|
53
|
+
turnEndAnswered: readonly AnsweredTurnAsk[]
|
|
54
|
+
titleFor(sessionId: string): string | null
|
|
55
|
+
/** COS_WAITING_QUESTION_WORDS. */
|
|
56
|
+
words: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clip(text: string, max = QUEUE_LINE_MAX): string {
|
|
60
|
+
const t = questionRowText(text).replace(/\s+/g, ' ').trim()
|
|
61
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const iso = (ms: number | null): string | null => ms === null || !Number.isFinite(ms) ? null : new Date(ms).toISOString()
|
|
65
|
+
|
|
66
|
+
function safeTitle(titleFor: QueueInput['titleFor'], sessionId: string): string | null {
|
|
67
|
+
try { return titleFor(sessionId) } catch { return null }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildQuestionQueue(input: QueueInput): QuestionQueue {
|
|
71
|
+
const entries: QueueEntry[] = []
|
|
72
|
+
for (const s of input.broker) {
|
|
73
|
+
const line = s.kind === 'question'
|
|
74
|
+
? (input.words && s.questions.length > 0 ? clip(s.questions[0]!.question) : '')
|
|
75
|
+
: (s.approval ? clip(s.approval.summary) : '')
|
|
76
|
+
entries.push({
|
|
77
|
+
key: s.key, sessionId: s.sessionId, title: safeTitle(input.titleFor, s.sessionId), provider: s.provider, kind: s.kind,
|
|
78
|
+
line, askedAt: iso(s.askedAt)!, where: s.where, heldId: s.heldId, deadlineAt: iso(s.deadlineAt), choices: [],
|
|
79
|
+
state: s.state, answeredBy: s.answeredBy, answeredAt: iso(s.answeredAt),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
for (const a of input.turnEnd) {
|
|
83
|
+
entries.push({
|
|
84
|
+
key: `${a.sessionId}:turn_end:${a.stoppedAt}`, sessionId: a.sessionId, title: safeTitle(input.titleFor, a.sessionId), provider: 'claude',
|
|
85
|
+
kind: 'turn_end', line: clip(a.ask), askedAt: iso(a.stoppedAt)!, where: 'here', heldId: null, deadlineAt: null,
|
|
86
|
+
choices: [...a.choices], state: 'waiting', answeredBy: null, answeredAt: null,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
for (const a of input.turnEndAnswered) {
|
|
90
|
+
entries.push({
|
|
91
|
+
key: `${a.sessionId}:turn_end:${a.stoppedAt}`, sessionId: a.sessionId, title: safeTitle(input.titleFor, a.sessionId), provider: 'claude',
|
|
92
|
+
kind: 'turn_end', line: clip(a.ask), askedAt: iso(a.stoppedAt)!, where: 'here', heldId: null, deadlineAt: null,
|
|
93
|
+
choices: [], state: 'answered', answeredBy: a.by, answeredAt: iso(a.answeredAt),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
// One entry per key (a waiting and an answered copy of the same ask: the answered one wins).
|
|
97
|
+
const byKey = new Map<string, QueueEntry>()
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
const had = byKey.get(e.key)
|
|
100
|
+
if (!had || (had.state === 'waiting' && e.state === 'answered')) byKey.set(e.key, e)
|
|
101
|
+
}
|
|
102
|
+
let queue = [...byKey.values()].sort((a, b) => a.askedAt.localeCompare(b.askedAt) || a.key.localeCompare(b.key))
|
|
103
|
+
if (queue.length > QUEUE_MAX) {
|
|
104
|
+
const waiting = queue.filter(e => e.state === 'waiting')
|
|
105
|
+
const answered = queue.filter(e => e.state === 'answered')
|
|
106
|
+
const keepAnswered = Math.max(0, QUEUE_MAX - waiting.length)
|
|
107
|
+
const keep = new Set([...waiting.slice(-QUEUE_MAX), ...answered.slice(answered.length - keepAnswered)])
|
|
108
|
+
queue = queue.filter(e => keep.has(e))
|
|
109
|
+
}
|
|
110
|
+
return { queue, queueCount: queue.filter(e => e.state === 'waiting').length }
|
|
111
|
+
}
|
|
@@ -23,7 +23,7 @@ import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatu
|
|
|
23
23
|
import { codexHooksHealthField, cursorObserverHealthField, type CodexHooksHealth, type CursorObserverHealth } from './provider-observe.js'
|
|
24
24
|
import { readHidIdleSeconds } from './permission-broker.js'
|
|
25
25
|
import { waitingQuestionWordsEnabled } from './full-hold.js'
|
|
26
|
-
import { TurnEndAskTracker, turnEndAsksEnabled, type TurnEndAskCounters } from './turn-end-ask.js'
|
|
26
|
+
import { TURN_ASK_MAX_AGE_MS, TurnEndAskTracker, turnEndAsksEnabled, type AnsweredTurnAsk, type QueueTurnAsk, type TurnEndAskCounters } from './turn-end-ask.js'
|
|
27
27
|
|
|
28
28
|
export function sessionHooksEnabled(): boolean {
|
|
29
29
|
const raw = process.env.COS_SESSION_HOOKS
|
|
@@ -44,6 +44,11 @@ export function deskIdleSeconds(): number {
|
|
|
44
44
|
/** Replay this much ledger history into the store at boot. */
|
|
45
45
|
export const LEDGER_REPLAY_WINDOW_MS = 6 * 60 * 60_000
|
|
46
46
|
|
|
47
|
+
/** X1: how much further back than the store the turn-end ask tracker replays: its age-out minus the store's window (2 h). */
|
|
48
|
+
export function turnAskReplayExtraMs(): number {
|
|
49
|
+
return Math.max(0, TURN_ASK_MAX_AGE_MS - LEDGER_REPLAY_WINDOW_MS)
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
// A hook's ppid is the claude process itself: macOS `sh -c '<script> <Event>'` execs the
|
|
48
53
|
// single command, and every recorded session (fixtures, 2026-09-15) shows one ppid across all
|
|
49
54
|
// of its events. The `ps` parent lookup below is the fallback for a shell that does not exec;
|
|
@@ -288,12 +293,22 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
288
293
|
}) : null
|
|
289
294
|
const tracker = turnEndAskTracker
|
|
290
295
|
const unsubscribeTurnAsk = tracker ? sessionSignalStore.subscribe((signal, env, child) => tracker.observe(env, child, signal)) : () => {}
|
|
291
|
-
|
|
296
|
+
// X1: a turn-end ask stays answerable TURN_ASK_MAX_AGE_MS (8 h) but the store replays LEDGER_REPLAY_WINDOW_MS (6 h),
|
|
297
|
+
// so an ask asked 6 to 8 h before a restart was lost. The tracker alone reads back to its own age-out: rows older
|
|
298
|
+
// than the store's window go to the tracker only, never into the store, so every other signal replays as before.
|
|
299
|
+
const trackerSince = tracker ? since - turnAskReplayExtraMs() : since
|
|
300
|
+
let trackerOnly = 0
|
|
301
|
+
const replay = ledger.replay(trackerSince, (env, _key, child, entrypoint) => {
|
|
292
302
|
if (!enabled) return
|
|
303
|
+
if (env.ts < since) {
|
|
304
|
+
trackerOnly++
|
|
305
|
+
tracker?.observe(env, child, { entrypoint })
|
|
306
|
+
return
|
|
307
|
+
}
|
|
293
308
|
sessionSignalStore.apply(env, child)
|
|
294
309
|
if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
|
|
295
310
|
})
|
|
296
|
-
replayed = { rows: replay.rows, applied: replay.applied }
|
|
311
|
+
replayed = { rows: replay.rows, applied: replay.applied - trackerOnly }
|
|
297
312
|
ingester = startSpoolIngester({
|
|
298
313
|
dir,
|
|
299
314
|
ledger,
|
|
@@ -359,6 +374,8 @@ export interface SessionHooksHealth {
|
|
|
359
374
|
/** The install state word; `installed` below is its boolean. */
|
|
360
375
|
state: HookStatus['state']
|
|
361
376
|
installed: boolean
|
|
377
|
+
/** X1, 7.0.34: the question hold for this Mac (additive). */
|
|
378
|
+
questionWait?: HookStatus['questionWait']
|
|
362
379
|
scriptSha: string | null
|
|
363
380
|
/**
|
|
364
381
|
* 6.60.0: our PermissionRequest hook's `timeout` in the Claude settings file (630 after
|
|
@@ -418,6 +435,8 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
|
|
|
418
435
|
installed: status.installed,
|
|
419
436
|
scriptSha: status.scriptSha,
|
|
420
437
|
permissionHookTimeoutS: status.permissionHookTimeoutS,
|
|
438
|
+
// X1, 7.0.34: additive. `state` is untouched; Control 0.5.394 and Control Web read this to offer the long wait.
|
|
439
|
+
...(status.questionWait ? { questionWait: status.questionWait } : {}),
|
|
421
440
|
priorWaitOnly: status.priorWaitOnly,
|
|
422
441
|
tokenPresent: status.tokenPresent,
|
|
423
442
|
lastEventAt: newest ? new Date(newest).toISOString() : null,
|
|
@@ -515,6 +534,12 @@ export function turnEndAskStats(): (TurnEndAskCounters & { tracked: number }) |
|
|
|
515
534
|
return turnEndAskTracker ? turnEndAskTracker.stats() : null
|
|
516
535
|
}
|
|
517
536
|
|
|
537
|
+
/** X1 (the one queue): the turn-end asks a row shows now, and those answered in the last two minutes. Empty when off. */
|
|
538
|
+
export function turnEndAsksForQueue(): { asks: QueueTurnAsk[]; answered: AnsweredTurnAsk[] } {
|
|
539
|
+
if (!turnEndAskTracker) return { asks: [], answered: [] }
|
|
540
|
+
return { asks: turnEndAskTracker.visibleAsks(), answered: turnEndAskTracker.recentlyAnswered() }
|
|
541
|
+
}
|
|
542
|
+
|
|
518
543
|
/** Tests only: the tracker the runtime would build, installed directly. */
|
|
519
544
|
export function __setTurnEndAskTrackerForTests(tracker: TurnEndAskTracker | null): void {
|
|
520
545
|
turnEndAskTracker?.stop()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// X1 (the one queue, 7.0.32 candidate): the last title COS showed for each session, so the questions route can name
|
|
2
|
+
// the asking session without reading a transcript on a 3 s poll. Filled by the session list route from the rows it
|
|
3
|
+
// already built; read by `lib/question-queue.ts`. Memory only, bounded; a session not listed yet reads null and a
|
|
4
|
+
// client falls back to its own list. The titles are the list's own (the same authenticated clients already get them).
|
|
5
|
+
|
|
6
|
+
/** At most this many sessions are remembered; past it the oldest noted go first. */
|
|
7
|
+
export const SESSION_TITLE_CACHE_MAX = 400
|
|
8
|
+
const TITLE_MAX = 120
|
|
9
|
+
|
|
10
|
+
const titles = new Map<string, string>()
|
|
11
|
+
|
|
12
|
+
/** Remember a session's title (lower-cased id). Blank titles are ignored. */
|
|
13
|
+
export function noteSessionTitle(sessionId: unknown, title: unknown): void {
|
|
14
|
+
if (typeof sessionId !== 'string' || !sessionId || typeof title !== 'string') return
|
|
15
|
+
const text = title.replace(/\s+/g, ' ').trim()
|
|
16
|
+
if (!text) return
|
|
17
|
+
const key = sessionId.toLowerCase()
|
|
18
|
+
titles.delete(key)
|
|
19
|
+
titles.set(key, text.length > TITLE_MAX ? text.slice(0, TITLE_MAX - 1) + '…' : text)
|
|
20
|
+
while (titles.size > SESSION_TITLE_CACHE_MAX) {
|
|
21
|
+
const oldest = titles.keys().next().value
|
|
22
|
+
if (oldest === undefined) break
|
|
23
|
+
titles.delete(oldest)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The last title COS listed for this session, or null. Takes a full id or the registry's eight-character form. */
|
|
28
|
+
export function sessionTitleFor(sessionId: string): string | null {
|
|
29
|
+
const key = sessionId.toLowerCase()
|
|
30
|
+
const exact = titles.get(key)
|
|
31
|
+
if (exact !== undefined) return exact
|
|
32
|
+
if (key.length >= 8 && key.length < 36) {
|
|
33
|
+
for (const [id, title] of titles) if (id.startsWith(key)) return title
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function __resetSessionTitlesForTests(): void {
|
|
39
|
+
titles.clear()
|
|
40
|
+
}
|
|
@@ -107,6 +107,28 @@ export function projectedTurnAsk(value: unknown): TurnAskCapture | null {
|
|
|
107
107
|
/** What a row shows (`deriveSessionState` input). */
|
|
108
108
|
export interface VisibleTurnAsk { ask: string; choices: string[]; stoppedAt: number }
|
|
109
109
|
|
|
110
|
+
/** X1 (the one queue): a turn-end ask the queue lists, with its session. */
|
|
111
|
+
export interface QueueTurnAsk extends VisibleTurnAsk { sessionId: string }
|
|
112
|
+
|
|
113
|
+
/** X1: a turn-end ask answered in the last TURN_ASK_ANSWERED_KEEP_MS, and from where (never the reply's words). */
|
|
114
|
+
export interface AnsweredTurnAsk { sessionId: string; ask: string; stoppedAt: number; answeredAt: number; by: TurnAskAnswerSurface }
|
|
115
|
+
export type TurnAskAnswerSurface = 'mac' | 'web' | 'glasses' | 'mac_app' | 'cos'
|
|
116
|
+
export const TURN_ASK_ANSWERED_KEEP_MS = 120_000
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* X1: where a reply that cleared a turn-end ask came from, read from the words COS itself puts on it (contract
|
|
120
|
+
* v1.2: "Answer sent from <SURFACE> through COS, not typed in this session"; a dictated reply carries the
|
|
121
|
+
* `[COS-G2 turn` footer). A COS-spawned Continue child is COS. Anything else was typed at the Mac. Only the
|
|
122
|
+
* surface is kept.
|
|
123
|
+
*/
|
|
124
|
+
export function turnAskAnswerSurface(prompt: unknown, child: boolean): TurnAskAnswerSurface {
|
|
125
|
+
const text = typeof prompt === 'string' ? prompt.slice(0, 2_000) : ''
|
|
126
|
+
if (text.includes('Answer sent from COS Control Web through COS')) return 'web'
|
|
127
|
+
if (text.includes('Answer sent from COS Control on the Mac through COS')) return 'mac_app'
|
|
128
|
+
if (text.includes('[COS-G2 turn')) return 'glasses'
|
|
129
|
+
return child ? 'cos' : 'mac'
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
interface AskState {
|
|
111
133
|
sessionId: string
|
|
112
134
|
promptId: string | null
|
|
@@ -140,6 +162,8 @@ export class TurnEndAskTracker {
|
|
|
140
162
|
private readonly asks = new Map<string, AskState>()
|
|
141
163
|
/** The newest Stop per session, for a wrap_up whose (async) PostToolUse lands after it. */
|
|
142
164
|
private readonly lastStop = new Map<string, { at: number; promptId: string | null }>()
|
|
165
|
+
/** X1: asks answered in the last TURN_ASK_ANSWERED_KEEP_MS that a surface had shown, newest last. */
|
|
166
|
+
private readonly answered: AnsweredTurnAsk[] = []
|
|
143
167
|
private poll: ReturnType<typeof setTimeout> | null = null
|
|
144
168
|
private pollInFlight = false
|
|
145
169
|
private stopped = false
|
|
@@ -162,7 +186,14 @@ export class TurnEndAskTracker {
|
|
|
162
186
|
const current = this.asks.get(sessionId)
|
|
163
187
|
// Any prompt, from the desk or a COS-spawned Continue child, is a reply: the question is answered.
|
|
164
188
|
if (env.event === 'UserPromptSubmit') {
|
|
165
|
-
if (current) {
|
|
189
|
+
if (current) {
|
|
190
|
+
this.asks.delete(sessionId)
|
|
191
|
+
this.counters.clearedByReply++
|
|
192
|
+
if (current.stoppedAt !== null && current.visibleAt !== null) {
|
|
193
|
+
this.answered.push({ sessionId, ask: current.capture.ask, stoppedAt: current.stoppedAt, answeredAt: env.ts, by: turnAskAnswerSurface(p.prompt, child) })
|
|
194
|
+
if (this.answered.length > MAX_TURN_ASKS) this.answered.splice(0, this.answered.length - MAX_TURN_ASKS)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
166
197
|
return
|
|
167
198
|
}
|
|
168
199
|
// A COS-spawned child's other events are not the session's own turn.
|
|
@@ -234,6 +265,25 @@ export class TurnEndAskTracker {
|
|
|
234
265
|
return { ask: state.capture.ask, choices: [...state.capture.choices], stoppedAt: state.stoppedAt }
|
|
235
266
|
}
|
|
236
267
|
|
|
268
|
+
/** X1 (the one queue): every ask a row would show now, oldest stop first. */
|
|
269
|
+
visibleAsks(now = this.deps.now()): QueueTurnAsk[] {
|
|
270
|
+
if (!this.deps.enabled()) return []
|
|
271
|
+
const out: QueueTurnAsk[] = []
|
|
272
|
+
for (const state of [...this.asks.values()]) {
|
|
273
|
+
const shown = this.visibleAsk(state.sessionId, now)
|
|
274
|
+
if (shown) out.push({ sessionId: state.sessionId, ...shown })
|
|
275
|
+
}
|
|
276
|
+
return out.sort((a, b) => a.stoppedAt - b.stoppedAt)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** X1: asks answered within TURN_ASK_ANSWERED_KEEP_MS (by when the reply arrived), oldest first. */
|
|
280
|
+
recentlyAnswered(now = this.deps.now()): AnsweredTurnAsk[] {
|
|
281
|
+
if (!this.deps.enabled()) return []
|
|
282
|
+
// Kept in the order replies arrived, so dropping from the front drops exactly the ones past the window.
|
|
283
|
+
while (this.answered.length > 0 && now - this.answered[0]!.answeredAt > TURN_ASK_ANSWERED_KEEP_MS) this.answered.shift()
|
|
284
|
+
return this.answered.map(a => ({ ...a }))
|
|
285
|
+
}
|
|
286
|
+
|
|
237
287
|
/** The session's row ended: nothing it asked can still be answered. */
|
|
238
288
|
sessionRowEnded(sessionId: string): void {
|
|
239
289
|
const key = sessionId.toLowerCase()
|
|
@@ -444,6 +444,8 @@ export function classifyWebLogin(
|
|
|
444
444
|
}
|
|
445
445
|
if (codeStore && typeof input.code === 'string' && input.code.trim().length > 0) {
|
|
446
446
|
if (codeStore.consume(input.code)) return { ok: true, kind: 'code' }
|
|
447
|
+
// The glasses' 14-character code: by shape only, never looked up (see looksLikeGlassesPairingCode).
|
|
448
|
+
if (looksLikeGlassesPairingCode(input.code)) return { ok: false, reason: 'glasses_code', length: GLASSES_PAIRING_CODE_TYPED_LENGTH }
|
|
447
449
|
const code = normalizeDeviceCode(input.code)
|
|
448
450
|
let glasses = false
|
|
449
451
|
if (code && isGlassesCode) {
|
|
@@ -506,6 +508,21 @@ export function normalizeDeviceCode(input: unknown): string | null {
|
|
|
506
508
|
return clean
|
|
507
509
|
}
|
|
508
510
|
|
|
511
|
+
/**
|
|
512
|
+
* 7.0.32 (O1 00:13, fix 4 of round 2): the code the glasses pair with is 14 characters of the same alphabet. Typed into
|
|
513
|
+
* the browser's sign-in box, it is recognised by its SHAPE only (the same cleaning as normalizeDeviceCode, then exactly
|
|
514
|
+
* 14 characters of the alphabet), never looked up, so a 14-character guess learns nothing about the live glasses code.
|
|
515
|
+
* No browser sign-in code has 14 characters, so nothing valid changes meaning. The 8-character lookup stays as it is.
|
|
516
|
+
*/
|
|
517
|
+
export const GLASSES_PAIRING_CODE_TYPED_LENGTH = 14
|
|
518
|
+
export function looksLikeGlassesPairingCode(input: unknown): boolean {
|
|
519
|
+
if (typeof input !== 'string') return false
|
|
520
|
+
const clean = input.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
|
|
521
|
+
if (clean.length !== GLASSES_PAIRING_CODE_TYPED_LENGTH) return false
|
|
522
|
+
for (const ch of clean) if (!DEVICE_CODE_ALPHABET.includes(ch)) return false
|
|
523
|
+
return true
|
|
524
|
+
}
|
|
525
|
+
|
|
509
526
|
/**
|
|
510
527
|
* Short sign-in codes for another device (the phone), minted by a browser or app that is
|
|
511
528
|
* already signed in. Same shape as glasses pairing: 8 Crockford characters (40 bits), five
|
|
@@ -94,6 +94,7 @@ import { createSessionPreviewRouter } from './session-preview.js'
|
|
|
94
94
|
import { organizerKey, sessionOrganizer, type OrganizerRowFields } from '../lib/session-organizer.js'
|
|
95
95
|
import { listOlderSessions, shortRowFor, OLDER_DEFAULT_DAYS, OLDER_DEFAULT_LIMIT, OLDER_MAX_DAYS, OLDER_MAX_LIMIT, OLDER_MIN_DAYS } from '../lib/agent-session-older.js'
|
|
96
96
|
import { cachedListModel, warmListModels } from '../lib/list-model-memo.js'
|
|
97
|
+
import { noteSessionTitle } from '../lib/session-title-cache.js'
|
|
97
98
|
import { cosStartedSessionIds } from '../lib/cos-started-sessions.js'
|
|
98
99
|
|
|
99
100
|
/** Step 2a: the organizer, or null when it is unavailable or empty (the list is then exactly today's). */
|
|
@@ -649,6 +650,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
649
650
|
}))
|
|
650
651
|
// Step 2a: read the models this answer lacked, in the background, for a later poll. Never awaited.
|
|
651
652
|
warmListModels(sessions)
|
|
653
|
+
// X1 (the one queue): the titles this list showed, so the questions route can name a waiting session.
|
|
654
|
+
for (const entry of sessions) noteSessionTitle(entry.session_id, entry.display_label)
|
|
652
655
|
} catch (error) {
|
|
653
656
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|
|
654
657
|
res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
|