@gotcos/glasses-server 7.0.7 → 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 +19 -0
- package/CHANGELOG.md +170 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- 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-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- 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/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -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/today-brief.ts +522 -58
- 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-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-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -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 +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- 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
|
@@ -97,7 +97,21 @@ const filesLine = (n: number): string => n === 1 ? 'The file stays here in the C
|
|
|
97
97
|
const nextAsk = (drop: DropRecord): DropNextAsk =>
|
|
98
98
|
({ kind: 'ask', prompt: drop.text, ...(drop.attachments?.length ? { attachmentIds: drop.attachments.map(a => a.id) } : {}) })
|
|
99
99
|
/** One item that waits on an answer: a live question, or a live item marked needsReply (6.99.10). */
|
|
100
|
-
|
|
100
|
+
/**
|
|
101
|
+
* 7.0.9 (Miles 11/08, "Expire after 7 days"): an item that asks for a reply stops waiting on you this long after it
|
|
102
|
+
* was asked (its own createdAt, never when it was read). It is not answered or closed: it moves to the chat's older
|
|
103
|
+
* questions (GET /api/inbox/archive), where it can still be read and answered, and answering it there clears it.
|
|
104
|
+
* The one rule for every count: `asked`, the chat part of `waiting`, `archived`, and the web's split.
|
|
105
|
+
*/
|
|
106
|
+
export const WAITING_EXPIRY_DAYS = 7
|
|
107
|
+
export const WAITING_EXPIRY_MS = WAITING_EXPIRY_DAYS * 86_400_000
|
|
108
|
+
const asksForReply = (i: FeedItem): boolean => i.state === 'live' && (i.kind === 'question' || i.needsReply === true)
|
|
109
|
+
/** How long ago it was asked, in ms; an unreadable time reads as just asked (it keeps waiting rather than vanish). */
|
|
110
|
+
const askedAgo = (i: FeedItem, nowMs: number): number => { const t = Date.parse(i.createdAt); return Number.isFinite(t) ? nowMs - t : 0 }
|
|
111
|
+
export const isWaiting = (i: FeedItem, nowMs: number = Date.now()): boolean => asksForReply(i) && askedAgo(i, nowMs) < WAITING_EXPIRY_MS
|
|
112
|
+
/** Asks for a reply, but was asked WAITING_EXPIRY_DAYS or more ago: shown under older questions, never counted. */
|
|
113
|
+
export const isArchivedQuestion = (i: FeedItem, nowMs: number = Date.now()): boolean => asksForReply(i) && askedAgo(i, nowMs) >= WAITING_EXPIRY_MS
|
|
114
|
+
export const ARCHIVE_LIST_MAX = 200
|
|
101
115
|
const A = {
|
|
102
116
|
undo: { id: 'undo', label: 'Undo' }, move: { id: 'move', label: 'Move' },
|
|
103
117
|
openCard: { id: 'open_card', label: 'Open card' }, openThread: { id: 'open_thread', label: 'Open thread' },
|
|
@@ -559,14 +573,24 @@ export class DropService {
|
|
|
559
573
|
* waits on an answer from you, and clears only when it is answered, done or dismissed: every live question COS asked
|
|
560
574
|
* (a router ask), every live item marked `needsReply` (a follow-up that needs a decision, a ping that asks), plus
|
|
561
575
|
* `yourMove`, the Work items in Your move, when the caller could count them. Always a whole number, never negative.
|
|
576
|
+
*
|
|
577
|
+
* 7.0.9 (Miles 11/08: the web rail said 99+ and the pinned row "286 waiting" for things he had already seen): two
|
|
578
|
+
* more fields, so a client can say what each number is. `asked` is the chat's own part of `waiting`, the live
|
|
579
|
+
* questions and needsReply items, which truly wait on a reply in this chat. `yourMove` is the Work part (null when
|
|
580
|
+
* it could not be counted). `waiting` keeps its 6.99.10 meaning for COS Control, which reads it by that name.
|
|
562
581
|
*/
|
|
563
|
-
summary(extra: { yourMove?: number | null } = {}): { unread: number; waiting: number; lastLine: string; lastAt: string | null; version: number } {
|
|
582
|
+
summary(extra: { yourMove?: number | null } = {}): { unread: number; waiting: number; asked: number; archived: number; waitingExpiryDays: number; yourMove: number | null; lastLine: string; lastAt: string | null; version: number } {
|
|
564
583
|
const items = this.deps.feed.list()
|
|
584
|
+
const nowMs = this.now().getTime()
|
|
565
585
|
const unread = items.filter(i => i.readAt === null && (i.kind === 'ping' || i.kind === 'followup' || i.kind === 'question')).length
|
|
566
|
-
|
|
567
|
-
|
|
586
|
+
// 7.0.9: a question asked WAITING_EXPIRY_DAYS ago or more is in older questions, out of `asked` and so out of the
|
|
587
|
+
// chat part of `waiting`. Work's part of `waiting` (yourMove) is untouched.
|
|
588
|
+
const asked = items.filter(i => isWaiting(i, nowMs)).length
|
|
589
|
+
const archived = items.filter(i => isArchivedQuestion(i, nowMs)).length
|
|
590
|
+
const counted = typeof extra.yourMove === 'number' && Number.isSafeInteger(extra.yourMove) && extra.yourMove >= 0
|
|
591
|
+
const work = counted && (extra.yourMove as number) > 0 ? extra.yourMove as number : 0
|
|
568
592
|
const last = items[items.length - 1]
|
|
569
|
-
return { unread, waiting: asked + work, lastLine: last ? short(pinLine(last), 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
|
|
593
|
+
return { unread, waiting: asked + work, asked, archived, waitingExpiryDays: WAITING_EXPIRY_DAYS, yourMove: counted ? extra.yourMove as number : null, lastLine: last ? short(pinLine(last), 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
|
|
570
594
|
}
|
|
571
595
|
|
|
572
596
|
/**
|
|
@@ -574,7 +598,7 @@ export class DropService {
|
|
|
574
598
|
* its buttons. Only an item marked `needsReply` can be dismissed; a question is answered by one of its choices
|
|
575
599
|
* (Just a note among them). Idempotent: a second dismiss answers the same.
|
|
576
600
|
*/
|
|
577
|
-
dismiss(itemId: string): { unread: number; waiting: number; version: number } {
|
|
601
|
+
dismiss(itemId: string): { unread: number; waiting: number; asked: number; version: number } {
|
|
578
602
|
const item = this.deps.feed.get(itemId)
|
|
579
603
|
if (!item) throw new DropError('feed_item_not_found', 404)
|
|
580
604
|
if (!item.needsReply) throw new DropError('not_dismissable', 409, 'Only something that waits on you can be dismissed.')
|
|
@@ -583,11 +607,21 @@ export class DropService {
|
|
|
583
607
|
this.deps.feed.update(new Set([item.id]), i => ({ ...i, state: 'done', actions: [], readAt: i.readAt ?? at }))
|
|
584
608
|
}
|
|
585
609
|
const s = this.summary()
|
|
586
|
-
return { unread: s.unread, waiting: s.waiting, version: s.version }
|
|
610
|
+
return { unread: s.unread, waiting: s.waiting, asked: s.asked, version: s.version }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* 7.0.9: the older questions, newest first: what still asks for a reply but was asked WAITING_EXPIRY_DAYS or more
|
|
615
|
+
* ago. Each answers exactly as in the chat (POST /api/inbox/action), and answering it takes it off this list.
|
|
616
|
+
*/
|
|
617
|
+
archive(): { items: Array<FeedItem & { archived: true }>; total: number; waitingExpiryDays: number; version: number } {
|
|
618
|
+
const nowMs = this.now().getTime()
|
|
619
|
+
const all = this.deps.feed.list().filter(i => isArchivedQuestion(i, nowMs)).reverse()
|
|
620
|
+
return { items: all.slice(0, ARCHIVE_LIST_MAX).map(i => ({ ...i, archived: true as const })), total: all.length, waitingExpiryDays: WAITING_EXPIRY_DAYS, version: this.deps.feed.version() }
|
|
587
621
|
}
|
|
588
622
|
|
|
589
|
-
/** The chat, oldest first. `after` is an item id: only items after it. */
|
|
590
|
-
feed(options: { after?: string; limit?: number } = {}): { items: FeedItem
|
|
623
|
+
/** The chat, oldest first. `after` is an item id: only items after it. 7.0.9: an older question carries `archived: true`. */
|
|
624
|
+
feed(options: { after?: string; limit?: number } = {}): { items: Array<FeedItem & { archived?: true }>; version: number; more: boolean } {
|
|
591
625
|
const items = this.deps.feed.list()
|
|
592
626
|
let start = 0
|
|
593
627
|
if (options.after) {
|
|
@@ -599,7 +633,8 @@ export class DropService {
|
|
|
599
633
|
const rest = items.slice(start)
|
|
600
634
|
// Without `after` the newest page; with it, the next page forward.
|
|
601
635
|
const page = options.after ? rest.slice(0, limit) : rest.slice(-limit)
|
|
602
|
-
|
|
636
|
+
const nowMs = this.now().getTime()
|
|
637
|
+
return { items: page.map(i => isArchivedQuestion(i, nowMs) ? { ...i, archived: true as const } : i), version: this.deps.feed.version(), more: options.after ? rest.length > limit : rest.length > limit }
|
|
603
638
|
}
|
|
604
639
|
|
|
605
640
|
/** The drop a chat item belongs to. */
|
|
@@ -609,7 +644,7 @@ export class DropService {
|
|
|
609
644
|
return item.threadId
|
|
610
645
|
}
|
|
611
646
|
|
|
612
|
-
markRead(through: string): { unread: number; waiting: number; version: number } {
|
|
647
|
+
markRead(through: string): { unread: number; waiting: number; asked: number; version: number } {
|
|
613
648
|
const items = this.deps.feed.list()
|
|
614
649
|
const index = items.findIndex(i => i.id === through)
|
|
615
650
|
if (index < 0) throw new DropError('feed_item_not_found', 404)
|
|
@@ -618,7 +653,7 @@ export class DropService {
|
|
|
618
653
|
if (ids.size) this.deps.feed.update(ids, item => ({ ...item, readAt: at }))
|
|
619
654
|
const s = this.summary()
|
|
620
655
|
// Opening the chat clears unread only: what waits on you stays until it is answered, done or dismissed.
|
|
621
|
-
return { unread: s.unread, waiting: s.waiting, version: s.version }
|
|
656
|
+
return { unread: s.unread, waiting: s.waiting, asked: s.asked, version: s.version }
|
|
622
657
|
}
|
|
623
658
|
|
|
624
659
|
/** History (plan section 6), P1: words over drop text and link labels, with kind and status filters, newest first. */
|
package/server/lib/drop-store.ts
CHANGED
|
@@ -387,6 +387,13 @@ export class InboxFeedStore {
|
|
|
387
387
|
private items: FeedItem[] = []
|
|
388
388
|
private quarantined: unknown[] = []
|
|
389
389
|
private versionNo = 0
|
|
390
|
+
/**
|
|
391
|
+
* 7.0.9 (Miles 11/08, "99 plus messages, despite the fact that I've already seen all of those"): the moment this
|
|
392
|
+
* feed started counting unread as net new. Every item still unread when the first build with it opened the file is
|
|
393
|
+
* marked read once, at that open, so the rail never counts what was already on screen. A file this build wrote
|
|
394
|
+
* carries it; a file without it (an older build wrote it last) is migrated on open.
|
|
395
|
+
*/
|
|
396
|
+
private readFloorAt: string
|
|
390
397
|
private readonly path: string
|
|
391
398
|
private readonly lock: ServerInstanceLock
|
|
392
399
|
private closed = false
|
|
@@ -400,6 +407,19 @@ export class InboxFeedStore {
|
|
|
400
407
|
this.versionNo = Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : this.items.length
|
|
401
408
|
} catch (e) { this.close(); throw e }
|
|
402
409
|
if (this.quarantined.length) console.warn(`[inbox] ${this.quarantined.length} unreadable feed item(s) set aside`)
|
|
410
|
+
const floor = opened.journal?.readFloorAt
|
|
411
|
+
this.readFloorAt = this.now().toISOString()
|
|
412
|
+
if (typeof floor === 'string' && Number.isFinite(Date.parse(floor))) this.readFloorAt = floor
|
|
413
|
+
else if (opened.journal) this.migrateReadFloor()
|
|
414
|
+
}
|
|
415
|
+
/** When unread started meaning net new on this feed (see readFloorAt). */
|
|
416
|
+
readFloor(): string { return this.readFloorAt }
|
|
417
|
+
/** Once per file: what was unread before this build opened it had already been shown, so it reads as read now. */
|
|
418
|
+
private migrateReadFloor(): void {
|
|
419
|
+
const at = this.readFloorAt
|
|
420
|
+
const next = this.items.map(i => i.readAt === null ? { ...i, readAt: at } : i)
|
|
421
|
+
// A file that cannot be written now still opens; the next open tries again. Unread never blocks the chat.
|
|
422
|
+
try { this.write(next, 1) } catch (e) { console.warn(`[inbox] read floor not written: ${(e as Error).message}`) }
|
|
403
423
|
}
|
|
404
424
|
/** Oldest first, as a chat reads. */
|
|
405
425
|
list(): FeedItem[] { return structuredClone(this.items) }
|
|
@@ -455,7 +475,7 @@ export class InboxFeedStore {
|
|
|
455
475
|
if (kept.length > DROP_LIMITS.feed) throw new DropStoreError('inbox_store_full', 503)
|
|
456
476
|
const version = this.versionNo + bump
|
|
457
477
|
// Never write a file the reader would refuse: the oldest COS lines (receipts, answers, pings) go first.
|
|
458
|
-
const serialize = () => JSON.stringify({ version: 1, feedVersion: version, items: kept, quarantined: this.quarantined }) + '\n'
|
|
478
|
+
const serialize = () => JSON.stringify({ version: 1, feedVersion: version, readFloorAt: this.readFloorAt, items: kept, quarantined: this.quarantined }) + '\n'
|
|
459
479
|
let body = serialize()
|
|
460
480
|
while (Buffer.byteLength(body) > this.maxBytes) {
|
|
461
481
|
const oldest = kept.findIndex(i => i.kind !== 'drop')
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-loop stall detector (S1, 2026-11-09). Logs only when the server's event loop was blocked.
|
|
3
|
+
*
|
|
4
|
+
* Why: COS Control's dictation asks GET /api/transcribe/capabilities with a 5 s timeout. Six
|
|
5
|
+
* dictations (11/03 13:01 CST, 11/04 15:32, and four between 18:12 and 18:32 on 11/04) failed at
|
|
6
|
+
* 5,052 to 5,072 ms with no answer, logged by Control as "refused:unknown". That route is a
|
|
7
|
+
* constant JSON object, so a 5 s silence means the process did not get to it: a blocked event
|
|
8
|
+
* loop, a process mid-restart, or a Mac under memory pressure. Nothing in the server logs could
|
|
9
|
+
* tell those apart. This line can:
|
|
10
|
+
*
|
|
11
|
+
* [event-loop] stalled 6230 ms (rss 1840 MB, heap 910 MB; 1 stall(s) over 2000 ms since boot)
|
|
12
|
+
*
|
|
13
|
+
* A 500 ms unref'd timer measures its own lateness. A late tick above the threshold is one stall.
|
|
14
|
+
* At most one line per 10 s; stalls in between are counted into the next line.
|
|
15
|
+
*/
|
|
16
|
+
export const STALL_SAMPLE_MS = 500
|
|
17
|
+
export const STALL_THRESHOLD_MS = 2_000
|
|
18
|
+
const LOG_EVERY_MS = 10_000
|
|
19
|
+
|
|
20
|
+
export interface StallMonitor {
|
|
21
|
+
stop(): void
|
|
22
|
+
stats(): { stalls: number; worstMs: number }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function startEventLoopStallMonitor(options: {
|
|
26
|
+
sampleMs?: number
|
|
27
|
+
thresholdMs?: number
|
|
28
|
+
now?: () => number
|
|
29
|
+
log?: (line: string) => void
|
|
30
|
+
memory?: () => { rss: number; heapUsed: number }
|
|
31
|
+
} = {}): StallMonitor {
|
|
32
|
+
const sampleMs = options.sampleMs ?? STALL_SAMPLE_MS
|
|
33
|
+
const thresholdMs = options.thresholdMs ?? STALL_THRESHOLD_MS
|
|
34
|
+
const now = options.now ?? (() => performance.now())
|
|
35
|
+
const log = options.log ?? (line => console.warn(line))
|
|
36
|
+
const memory = options.memory ?? (() => process.memoryUsage())
|
|
37
|
+
let last = now()
|
|
38
|
+
let stalls = 0
|
|
39
|
+
let worstMs = 0
|
|
40
|
+
let lastLogAt = -Infinity
|
|
41
|
+
let unlogged = 0
|
|
42
|
+
const timer = setInterval(() => {
|
|
43
|
+
const at = now()
|
|
44
|
+
const lateMs = at - last - sampleMs
|
|
45
|
+
last = at
|
|
46
|
+
if (lateMs < thresholdMs) return
|
|
47
|
+
stalls++
|
|
48
|
+
worstMs = Math.max(worstMs, lateMs)
|
|
49
|
+
if (at - lastLogAt < LOG_EVERY_MS) { unlogged++; return }
|
|
50
|
+
lastLogAt = at
|
|
51
|
+
const m = memory()
|
|
52
|
+
const extra = unlogged ? `; ${unlogged} more since the last line` : ''
|
|
53
|
+
unlogged = 0
|
|
54
|
+
log(`[event-loop] stalled ${Math.round(lateMs)} ms (rss ${Math.round(m.rss / 1e6)} MB, heap ${Math.round(m.heapUsed / 1e6)} MB; `
|
|
55
|
+
+ `${stalls} stall(s) over ${thresholdMs} ms since boot${extra})`)
|
|
56
|
+
}, sampleMs)
|
|
57
|
+
timer.unref?.()
|
|
58
|
+
return {
|
|
59
|
+
stop: () => clearInterval(timer),
|
|
60
|
+
stats: () => ({ stalls, worstMs: Math.round(worstMs) }),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// X1 (Q1 review O2, 2026-11-09): WHICH engine asked, read from the process that ran the hook, never
|
|
2
|
+
// from the session id alone.
|
|
3
|
+
//
|
|
4
|
+
// The hook script posts its own `$PPID`, which is the Claude Code process that ran it (measured: a
|
|
5
|
+
// CLI 2.1.322 run and the Desktop engine 2.1.317 both, `~/.claude/sessions/<ppid>.json` names the
|
|
6
|
+
// same session). Reading the record for the session id instead lets a second process that shares the
|
|
7
|
+
// id (an older CLI, a `claude -p --resume <id>`) inherit a tab's version (Q1 O2).
|
|
8
|
+
//
|
|
9
|
+
// The entrypoint is not enough either. `claude -p` takes `CLAUDE_CODE_ENTRYPOINT` from whatever
|
|
10
|
+
// started it: a print run launched from a Desktop tab's shell registers as `claude-desktop`, and its
|
|
11
|
+
// `kind` reads `interactive` (measured 22:05). A print run has no dialog, and it does reach the
|
|
12
|
+
// PermissionRequest hook for an approval and waits on it (measured: a 20 s hold blocked it 20 s), so a
|
|
13
|
+
// full hold there would stall a headless job for up to an hour. What does tell them apart is where
|
|
14
|
+
// the process runs (census of 25 live sessions, 22:06):
|
|
15
|
+
// - a Desktop tab's engine is a child of Claude.app's own `Contents/Helpers/disclaimer`;
|
|
16
|
+
// - an interactive CLI has a controlling terminal (`ttys000`) and none of the print or SDK flags;
|
|
17
|
+
// - a print run started from a shell or a tool has neither.
|
|
18
|
+
// Q1 C3 (Codex second opinion), measured 22:42: a terminal alone is not enough. A `claude -p` run under
|
|
19
|
+
// a terminal (typed there, or started by a CLI session's own Bash tool, whose children inherit its
|
|
20
|
+
// terminal) has a tty too, and `-p` in its arguments. So a terminal engine must also carry none of
|
|
21
|
+
// `-p`, `--print`, `--input-format`, `--output-format` (an SDK host passes the last two). Miles's two
|
|
22
|
+
// interactive CLI sessions carried none of them.
|
|
23
|
+
// Anything that cannot be read is `other`, and `other` is never proven.
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process'
|
|
26
|
+
import { readFileSync } from 'node:fs'
|
|
27
|
+
import { homedir } from 'node:os'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
import type { EngineFacts, EngineHost } from './full-hold.js'
|
|
30
|
+
|
|
31
|
+
export function claudeSessionsRegistryDir(): string {
|
|
32
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ? process.env.CLAUDE_CONFIG_DIR : join(homedir(), '.claude')
|
|
33
|
+
return join(configDir, 'sessions')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `~/.claude/sessions/<pid>.json` parsed, only when it names `sessionId` (and, if it says, that pid). Bounded; null on anything else. */
|
|
37
|
+
function readRecord(pid: number, sessionId: string, dir: string): Record<string, unknown> | null {
|
|
38
|
+
if (!Number.isInteger(pid) || pid <= 1) return null
|
|
39
|
+
try {
|
|
40
|
+
const text = readFileSync(join(dir, `${pid}.json`), 'utf-8')
|
|
41
|
+
if (text.length > 256 * 1024) return null
|
|
42
|
+
const raw = JSON.parse(text) as Record<string, unknown>
|
|
43
|
+
if (typeof raw.sessionId !== 'string' || raw.sessionId.toLowerCase() !== sessionId.toLowerCase()) return null
|
|
44
|
+
if (typeof raw.pid === 'number' && raw.pid !== pid) return null
|
|
45
|
+
return raw
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The engine's entrypoint and version from its own record. */
|
|
52
|
+
export function registryRecordForPid(pid: number, sessionId: string, dir = claudeSessionsRegistryDir()): { entrypoint: string | null; version: string | null } | null {
|
|
53
|
+
const raw = readRecord(pid, sessionId, dir)
|
|
54
|
+
if (!raw) return null
|
|
55
|
+
return {
|
|
56
|
+
entrypoint: typeof raw.entrypoint === 'string' && raw.entrypoint ? raw.entrypoint : null,
|
|
57
|
+
version: typeof raw.version === 'string' && raw.version ? raw.version.slice(0, 32) : null,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type PsRun = (args: string[]) => Promise<string | null>
|
|
62
|
+
|
|
63
|
+
/** A flag only a print run or an SDK host passes (Q1 C3). */
|
|
64
|
+
const PRINT_OR_SDK_FLAG = /^(?:-p|--print|--input-format(?:=.*)?|--output-format(?:=.*)?)$/
|
|
65
|
+
|
|
66
|
+
const runPs: PsRun = args => new Promise(resolve => {
|
|
67
|
+
try {
|
|
68
|
+
execFile('/bin/ps', args, { timeout: 1_000, maxBuffer: 64 * 1024 }, (error, stdout) => resolve(error ? null : String(stdout)))
|
|
69
|
+
} catch {
|
|
70
|
+
resolve(null)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** Where a Claude Code process runs: Claude.app's helper, a terminal, or neither. */
|
|
75
|
+
export async function processHost(pid: number, ps: PsRun = runPs): Promise<EngineHost> {
|
|
76
|
+
if (!Number.isInteger(pid) || pid <= 1) return 'other'
|
|
77
|
+
const own = await ps(['-o', 'ppid=,tty=', '-p', String(pid)])
|
|
78
|
+
if (!own) return 'other'
|
|
79
|
+
const [ppidRaw, tty] = own.trim().split(/\s+/)
|
|
80
|
+
const ppid = Number(ppidRaw)
|
|
81
|
+
if (tty && tty !== '??' && tty !== '-' && /^tty/.test(tty)) {
|
|
82
|
+
const args = await ps(['-o', 'args=', '-p', String(pid)])
|
|
83
|
+
if (args === null) return 'other'
|
|
84
|
+
return args.trim().split(/\s+/).some(token => PRINT_OR_SDK_FLAG.test(token)) ? 'other' : 'terminal'
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isInteger(ppid) || ppid <= 1) return 'other'
|
|
87
|
+
const parent = await ps(['-o', 'comm=', '-p', String(ppid)])
|
|
88
|
+
const path = parent ? parent.trim() : ''
|
|
89
|
+
return /\/Claude\.app\/Contents\/Helpers\/disclaimer$/.test(path) ? 'desktop-app' : 'other'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* X1 round 3: the engine's own status now (`waiting` while its dialog is up), from the same record, or
|
|
94
|
+
* null when the record is gone (the process ended) or names another session (the pid was reused).
|
|
95
|
+
* Measured 22:39 on both engines: `waiting` is written 0 to 5 ms before the PermissionRequest hook
|
|
96
|
+
* starts and leaves `waiting` within milliseconds of an answer at the desk (`busy`, or `idle` on Esc);
|
|
97
|
+
* the record is removed when the process exits.
|
|
98
|
+
*/
|
|
99
|
+
export function registryStatusForPid(pid: number, sessionId: string, dir = claudeSessionsRegistryDir()): { status: string | null; statusUpdatedAt: number | null } | null {
|
|
100
|
+
const raw = readRecord(pid, sessionId, dir)
|
|
101
|
+
if (!raw) return null
|
|
102
|
+
return {
|
|
103
|
+
status: typeof raw.status === 'string' ? raw.status : null,
|
|
104
|
+
statusUpdatedAt: typeof raw.statusUpdatedAt === 'number' && Number.isFinite(raw.statusUpdatedAt) ? raw.statusUpdatedAt : null,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The engine facts the full hold reads, for the process that ran the hook. Null when its record does not name the session. */
|
|
109
|
+
export async function engineForHookProcess(sessionId: string, ppid: number | null, deps: { dir?: string; ps?: PsRun } = {}): Promise<EngineFacts | null> {
|
|
110
|
+
if (ppid === null) return null
|
|
111
|
+
const record = registryRecordForPid(ppid, sessionId, deps.dir)
|
|
112
|
+
if (!record) return null
|
|
113
|
+
return { ...record, host: await processHost(ppid, deps.ps) }
|
|
114
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// X1 (2026-11-09): the full hold. A session question (AskUserQuestion) or a tool approval held for
|
|
2
|
+
// the WHOLE wait of the PermissionRequest hook, whether or not a COS device is live when it is asked.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS SAFE AT THE DESK (canaries, operations/personal/wk46_2026/production_hardening/
|
|
5
|
+
// x1_question_fetch/CANARIES.md): on the engines below, Claude Code shows its own question or
|
|
6
|
+
// approval dialog at once WHILE the PermissionRequest hook is still waiting (C1, C5). An answer at
|
|
7
|
+
// the Mac wins and a later hook answer is ignored (C3, C4); a hook answer withdraws the dialog (C2;
|
|
8
|
+
// the desktop engine sends `control_cancel_request` to the app, C6). A hold of 3,703 s worked (C7).
|
|
9
|
+
// So holding the hook never delays the Mac's own prompt (Miles's option B, 11/05) ON THESE ENGINES.
|
|
10
|
+
// An engine that is not listed, or reads older, keeps today's rule: it may not show its dialog
|
|
11
|
+
// until the hook returns, and a long hold there would keep the desk waiting.
|
|
12
|
+
//
|
|
13
|
+
// Both switches default OFF. With both off nothing in this file is read on the permission path.
|
|
14
|
+
//
|
|
15
|
+
// Pure, no imports beyond the one switch reader.
|
|
16
|
+
|
|
17
|
+
import { readSwitch } from './env-switch.js'
|
|
18
|
+
|
|
19
|
+
/** `COS_QUESTION_FULL_HOLD`: hold a question for the whole hook wait. Default OFF. Read per request. */
|
|
20
|
+
export function questionFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
21
|
+
return readSwitch(env.COS_QUESTION_FULL_HOLD, false)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `COS_APPROVAL_FULL_HOLD`: hold a tool approval for the whole hook wait while the desk stays idle. Default OFF. */
|
|
25
|
+
export function approvalFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
26
|
+
return readSwitch(env.COS_APPROVAL_FULL_HOLD, false)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `COS_WAITING_QUESTION_WORDS`: a session waiting on a question carries the first 80 characters of its
|
|
31
|
+
* first question (`waiting_detail`, through `toolTarget`), so Today and the Sessions rows say what it
|
|
32
|
+
* asks. Default ON: Miles, 2026-11-09 22:10 CST, "Yes, show by default", told that the text is written
|
|
33
|
+
* to the on-disk session-hook ledger and sent to any signed-in browser, including for questions
|
|
34
|
+
* answered at the desk. `0` restores 7.0.13's empty detail exactly. Question text only: never an
|
|
35
|
+
* option, a description or an answer. The `waiting` list with full words and options is NOT this
|
|
36
|
+
* switch: it is on only while a full-hold switch is.
|
|
37
|
+
*/
|
|
38
|
+
export function waitingQuestionWordsEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
39
|
+
return readSwitch(env.COS_WAITING_QUESTION_WORDS, true)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Control and format characters (bidi overrides, zero-width) taken out of a question before it becomes row text. */
|
|
43
|
+
export function questionRowText(question: unknown): string {
|
|
44
|
+
if (typeof question !== 'string') return ''
|
|
45
|
+
return question.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FullHoldSwitches { questions: boolean; approvals: boolean }
|
|
49
|
+
|
|
50
|
+
export function fullHoldSwitches(env: NodeJS.ProcessEnv): FullHoldSwitches {
|
|
51
|
+
return { questions: questionFullHoldEnabled(env), approvals: approvalFullHoldEnabled(env) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The oldest engine of each kind proven to show its own dialog while the hook waits, by the
|
|
56
|
+
* registry record's `entrypoint` and `version` (`~/.claude/sessions/<pid>.json`).
|
|
57
|
+
* - `cli` 2.1.321: canaries C1 to C4, C7, C9 (2026-11-09, tmux, real TTY).
|
|
58
|
+
* - `claude-desktop` 2.1.317: the engine Claude Desktop runs, driven over its stream-json
|
|
59
|
+
* protocol, C5, C6, C9 (and the 6.71.7 Desktop D1 canary of 2026-10-11 in the real app).
|
|
60
|
+
* Anything else (`sdk-cli`, an unknown entrypoint, no version) is not proven.
|
|
61
|
+
*/
|
|
62
|
+
export const FULL_HOLD_ENGINE_FLOORS: Readonly<Record<string, readonly number[]>> = Object.freeze({
|
|
63
|
+
cli: Object.freeze([2, 1, 321]),
|
|
64
|
+
'claude-desktop': Object.freeze([2, 1, 317]),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** Where the engine runs (`lib/full-hold-engine.ts` `processHost`): Claude.app's helper, a terminal, or neither. */
|
|
68
|
+
export type EngineHost = 'desktop-app' | 'terminal' | 'other'
|
|
69
|
+
|
|
70
|
+
export interface EngineFacts { entrypoint: string | null; version: string | null; host?: EngineHost }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Q1 O2: the host each entrypoint must run in. A `claude -p` print run takes its entrypoint from
|
|
74
|
+
* whatever launched it, has no dialog, and runs under neither (`lib/full-hold-engine.ts`).
|
|
75
|
+
*/
|
|
76
|
+
export const FULL_HOLD_ENGINE_HOSTS: Readonly<Record<string, EngineHost>> = Object.freeze({
|
|
77
|
+
cli: 'terminal',
|
|
78
|
+
'claude-desktop': 'desktop-app',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** `2.1.321` as numbers; null for anything that is not one to four dot-separated whole numbers. */
|
|
82
|
+
export function parseEngineVersion(version: unknown): number[] | null {
|
|
83
|
+
if (typeof version !== 'string') return null
|
|
84
|
+
const trimmed = version.trim()
|
|
85
|
+
if (!/^\d{1,6}(?:\.\d{1,6}){0,3}$/.test(trimmed)) return null
|
|
86
|
+
return trimmed.split('.').map(Number)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when `version` is at or above `floor`, part by part (a missing part reads 0). */
|
|
90
|
+
export function versionAtLeast(version: readonly number[], floor: readonly number[]): boolean {
|
|
91
|
+
const length = Math.max(version.length, floor.length)
|
|
92
|
+
for (let i = 0; i < length; i++) {
|
|
93
|
+
const v = version[i] ?? 0
|
|
94
|
+
const f = floor[i] ?? 0
|
|
95
|
+
if (v !== f) return v > f
|
|
96
|
+
}
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether this engine is proven to keep its dialog on screen while the hook waits, and runs where that dialog is. */
|
|
101
|
+
export function engineProvenForFullHold(engine: EngineFacts | null | undefined): boolean {
|
|
102
|
+
if (!engine || typeof engine.entrypoint !== 'string') return false
|
|
103
|
+
if (!Object.prototype.hasOwnProperty.call(FULL_HOLD_ENGINE_FLOORS, engine.entrypoint)) return false
|
|
104
|
+
if (engine.host !== FULL_HOLD_ENGINE_HOSTS[engine.entrypoint]) return false
|
|
105
|
+
const floor = FULL_HOLD_ENGINE_FLOORS[engine.entrypoint]!
|
|
106
|
+
const version = parseEngineVersion(engine.version)
|
|
107
|
+
return version !== null && versionAtLeast(version, floor)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The PermissionRequest wait the installer writes for a kind under the full hold. */
|
|
111
|
+
export const FULL_HOLD_HOOK_WAIT_S = 3600
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The matcher of the non-question PermissionRequest block once the hook is split (canary C9:
|
|
115
|
+
* honoured on both engines; a question runs only the `AskUserQuestion` block, everything else
|
|
116
|
+
* only this one).
|
|
117
|
+
*/
|
|
118
|
+
export const NON_QUESTION_MATCHER = '^(?!AskUserQuestion$).*'
|
|
119
|
+
export const QUESTION_MATCHER = 'AskUserQuestion'
|
|
120
|
+
|
|
121
|
+
/** How a web (browser session) answer proves a person just touched the page, while a flag is on. */
|
|
122
|
+
export const WEB_ANSWER_TOUCH_MAX_MS = 60_000
|
|
123
|
+
/** How long a web approval's confirm step stays usable. */
|
|
124
|
+
export const WEB_CONFIRM_TTL_MS = 60_000
|