@gotcos/glasses-server 6.98.1 → 6.99.12

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-settings.ts +2 -1
  22. package/server/lib/live-cues-v2.ts +6 -4
  23. package/server/lib/meeting-consolidation-run.ts +169 -0
  24. package/server/lib/meeting-store.ts +61 -0
  25. package/server/lib/meeting-title-steer.ts +200 -0
  26. package/server/lib/network-policy.ts +84 -6
  27. package/server/lib/permission-broker.ts +11 -0
  28. package/server/lib/ping-budget.ts +149 -0
  29. package/server/lib/ping-engine.ts +245 -0
  30. package/server/lib/ping-feed-sink.ts +33 -0
  31. package/server/lib/ping-sources.ts +192 -0
  32. package/server/lib/query-job-coordinator.ts +5 -2
  33. package/server/lib/query-job-runtime.ts +45 -4
  34. package/server/lib/query-job-types.ts +10 -1
  35. package/server/lib/reminder-scheduler.ts +153 -0
  36. package/server/lib/tailnet-self-name.ts +103 -0
  37. package/server/lib/tailscale-nudge.ts +40 -2
  38. package/server/lib/task-store.ts +32 -0
  39. package/server/lib/web-ask-admission.ts +56 -0
  40. package/server/lib/web-live-chunks.ts +273 -0
  41. package/server/lib/web-memories-bridge.ts +323 -0
  42. package/server/lib/web-session-auth.ts +492 -0
  43. package/server/lib/web-usage-bridge.ts +154 -0
  44. package/server/lib/work-search.ts +14 -1
  45. package/server/lib/your-move-count.ts +58 -0
  46. package/server/routes/display.ts +11 -1
  47. package/server/routes/drops.ts +260 -0
  48. package/server/routes/health.ts +16 -0
  49. package/server/routes/live-cues.ts +5 -4
  50. package/server/routes/meeting.ts +61 -5
  51. package/server/routes/permission-broker.ts +23 -3
  52. package/server/routes/transcribe-stream.ts +34 -3
  53. package/server/routes/web-control.ts +569 -0
  54. package/server/routes/welcome-context.ts +2 -1
  55. package/server/web-control/app.css +519 -0
  56. package/server/web-control/app.js +3643 -0
  57. package/server/web-control/control.css +312 -0
  58. package/server/web-control/fonts/DMSans.ttf +0 -0
  59. package/server/web-control/fonts/Fraunces.ttf +0 -0
  60. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  61. package/server/web-control/index.html +275 -0
  62. package/server/web-control/mark.svg +1 -0
  63. package/server/web-control/memories/d3.min.js +2 -0
  64. package/server/web-control/memories/graph-explorer.css +200 -0
  65. package/server/web-control/memories/graph-explorer.js +748 -0
  66. package/server/web-control/memories/memories-app.js +1561 -0
  67. package/server/web-control/memories/memories-theme.css +73 -0
  68. package/server/web-control/memories/memories.html +72 -0
  69. package/server/web-control/memories/memory-stewardship.js +63 -0
  70. package/server/web-control/memories/memory-workspace.js +216 -0
  71. package/server/web-control/panes/cos-chat.css +157 -0
  72. package/server/web-control/panes/cos-chat.js +973 -0
  73. package/server/web-control/panes/dictation.js +173 -0
  74. package/server/web-control/panes/meetings.css +257 -0
  75. package/server/web-control/panes/meetings.js +2264 -0
  76. package/server/web-control/panes/messages.js +711 -0
  77. package/server/web-control/panes/questions.css +32 -0
  78. package/server/web-control/panes/questions.js +275 -0
  79. package/server/web-control/panes/sessions.css +452 -0
  80. package/server/web-control/panes/settings.js +292 -0
  81. package/server/web-control/panes/speakers.css +269 -0
  82. package/server/web-control/panes/speakers.js +2755 -0
  83. package/server/web-control/panes/threads.js +200 -0
  84. package/server/web-control/panes/work.css +261 -0
  85. package/server/web-control/panes/work.js +1667 -0
  86. package/server/web-control/shell.js +294 -0
  87. package/server/web-control/theme.js +25 -0
  88. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Drop it P2 settings (Miles, 2026-11-04 23:39): how COS reaches out, so it can be tuned and steered.
3
+ *
4
+ * Every value here is read by the code that does the thing (ping-budget, ping-engine, ping-sources, drop-when,
5
+ * reminder-scheduler, drop-p2-runtime), and each has a test proving it changes behaviour. Defaults are the decided
6
+ * values (plan section 10), so a Mac with no settings file behaves exactly as 6.99.11 was built.
7
+ *
8
+ * Stored beside Drop it's switch, in its own file (`data/drop-p2-settings.json`, 0600, atomic), so the P1 settings
9
+ * file and its `{ enabled }` route stay as COS Control 0.5.371 reads them. Validation clamps a number into range and
10
+ * replaces a value it cannot read with the default; it never refuses a whole save for one bad field, but the PUT route
11
+ * refuses a body that is not an object or names an unknown section.
12
+ *
13
+ * Steer is words, not a model: "Ping me about" and "Leave alone" are each up to 300 characters, matched as whole
14
+ * words of 3 or more letters (canary 9: the ping path calls no model).
15
+ */
16
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
17
+ import { dirname } from 'node:path'
18
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
19
+ import { dataPath } from './data-dir.js'
20
+ import type { BudgetOptions, PingSource } from './ping-budget.js'
21
+
22
+ export const P2_SETTINGS_PROTOCOL = 1
23
+ export const P2_SETTINGS_FILE = () => dataPath('drop-p2-settings.json')
24
+
25
+ /** The polled ping sources he can switch off one by one. Reminders and follow-ups have their own sections. */
26
+ export const P2_PING_SOURCES = ['prep', 'agent', 'followups', 'stale', 'signals'] as const
27
+ export type P2PingSource = typeof P2_PING_SOURCES[number]
28
+ export const P2_FOLLOWUP_KINDS = ['card', 'handoff', 'session', 'thread'] as const
29
+ export type P2FollowupKind = typeof P2_FOLLOWUP_KINDS[number]
30
+ /** What may banner. `reminder` and `resolution` (follow-ups to his own drops) are on by default; the rest land silently. */
31
+ export const P2_BANNER_KINDS = ['reminder', 'resolution', 'prep', 'agent', 'followups', 'stale', 'checkin'] as const
32
+
33
+ export interface P2Settings {
34
+ pings: {
35
+ on: boolean
36
+ dailyCap: number
37
+ perSource: number
38
+ gapMinutes: number
39
+ quietStart: string
40
+ quietEnd: string
41
+ weekendsQuiet: boolean
42
+ firstBanner: string
43
+ sources: Record<P2PingSource, boolean>
44
+ }
45
+ followUps: { on: boolean; kinds: Record<P2FollowupKind, boolean> }
46
+ checkin: { on: boolean; weekday: number; time: string }
47
+ reminders: { defaultTime: string; snoozeMinutes: number; banners: boolean; lensPush: boolean }
48
+ banners: Record<(typeof P2_BANNER_KINDS)[number], boolean>
49
+ steer: { about: string; leaveAlone: string }
50
+ }
51
+
52
+ export const P2_LIMITS = {
53
+ dailyCap: [0, 20], perSource: [1, 10], gapMinutes: [0, 240], snoozeMinutes: [5, 24 * 60], steerChars: 300,
54
+ } as const
55
+
56
+ export const P2_DEFAULTS: P2Settings = {
57
+ pings: { on: true, dailyCap: 6, perSource: 2, gapMinutes: 45, quietStart: '18:00', quietEnd: '06:00', weekendsQuiet: true, firstBanner: '08:00',
58
+ sources: { prep: true, agent: true, followups: true, stale: true, signals: true } },
59
+ followUps: { on: true, kinds: { card: true, handoff: true, session: true, thread: true } },
60
+ checkin: { on: true, weekday: 1, time: '09:00' },
61
+ reminders: { defaultTime: '09:00', snoozeMinutes: 60, banners: true, lensPush: true },
62
+ banners: { reminder: true, resolution: true, prep: false, agent: false, followups: false, stale: false, checkin: false },
63
+ steer: { about: '', leaveAlone: '' },
64
+ }
65
+
66
+ const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/
67
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
68
+ const bool = (v: unknown, d: boolean) => typeof v === 'boolean' ? v : d
69
+ const int = (v: unknown, [lo, hi]: readonly [number, number], d: number) => typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : d
70
+ const time = (v: unknown, d: string) => typeof v === 'string' && TIME.test(v) ? v : d
71
+ const text = (v: unknown, d: string) => typeof v === 'string' ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, P2_LIMITS.steerChars) : d
72
+ function flags<K extends string>(v: unknown, keys: readonly K[], d: Record<K, boolean>): Record<K, boolean> {
73
+ const out = { ...d }
74
+ if (isRecord(v)) for (const k of keys) out[k] = bool(v[k], d[k])
75
+ return out
76
+ }
77
+
78
+ /** Read any object as settings: unknown fields are dropped, numbers clamped, unreadable values left at `base`. */
79
+ export function normalizeP2Settings(raw: unknown, base: P2Settings = P2_DEFAULTS): P2Settings {
80
+ const r = isRecord(raw) ? raw : {}
81
+ const p = isRecord(r.pings) ? r.pings : {}, f = isRecord(r.followUps) ? r.followUps : {}, c = isRecord(r.checkin) ? r.checkin : {}
82
+ const m = isRecord(r.reminders) ? r.reminders : {}, s = isRecord(r.steer) ? r.steer : {}
83
+ return {
84
+ pings: {
85
+ on: bool(p.on, base.pings.on),
86
+ dailyCap: int(p.dailyCap, P2_LIMITS.dailyCap, base.pings.dailyCap),
87
+ perSource: int(p.perSource, P2_LIMITS.perSource, base.pings.perSource),
88
+ gapMinutes: int(p.gapMinutes, P2_LIMITS.gapMinutes, base.pings.gapMinutes),
89
+ quietStart: time(p.quietStart, base.pings.quietStart),
90
+ quietEnd: time(p.quietEnd, base.pings.quietEnd),
91
+ weekendsQuiet: bool(p.weekendsQuiet, base.pings.weekendsQuiet),
92
+ firstBanner: time(p.firstBanner, base.pings.firstBanner),
93
+ sources: flags(p.sources, P2_PING_SOURCES, base.pings.sources),
94
+ },
95
+ followUps: { on: bool(f.on, base.followUps.on), kinds: flags(f.kinds, P2_FOLLOWUP_KINDS, base.followUps.kinds) },
96
+ checkin: { on: bool(c.on, base.checkin.on), weekday: int(c.weekday, [0, 6], base.checkin.weekday), time: time(c.time, base.checkin.time) },
97
+ reminders: {
98
+ defaultTime: time(m.defaultTime, base.reminders.defaultTime),
99
+ snoozeMinutes: int(m.snoozeMinutes, P2_LIMITS.snoozeMinutes, base.reminders.snoozeMinutes),
100
+ banners: bool(m.banners, base.reminders.banners),
101
+ lensPush: bool(m.lensPush, base.reminders.lensPush),
102
+ },
103
+ banners: flags(r.banners, P2_BANNER_KINDS, base.banners),
104
+ steer: { about: text(s.about, base.steer.about), leaveAlone: text(s.leaveAlone, base.steer.leaveAlone) },
105
+ }
106
+ }
107
+
108
+ /** Apply a partial PUT onto the current settings: only the fields it names change. */
109
+ export function mergeP2Settings(current: P2Settings, patch: unknown): P2Settings {
110
+ return normalizeP2Settings(patch, current)
111
+ }
112
+
113
+ const minutes = (hhmm: string) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3))
114
+ /** The numbers the budget and quiet hours read, in the units they use. */
115
+ export function budgetFrom(s: P2Settings): BudgetOptions {
116
+ return {
117
+ perWeekday: s.pings.dailyCap, perSource: s.pings.perSource, gapMs: s.pings.gapMinutes * 60_000,
118
+ quietStartMinutes: minutes(s.pings.quietStart), quietEndMinutes: minutes(s.pings.quietEnd),
119
+ weekendsQuiet: s.pings.weekendsQuiet, firstBannerMinutes: minutes(s.pings.firstBanner),
120
+ }
121
+ }
122
+ export const timeMinutes = minutes
123
+
124
+ /** The kinds that may banner, as the budget reads them. Reminder banners also need the reminders switch. */
125
+ export function bannerSourcesFrom(s: P2Settings): PingSource[] {
126
+ return P2_BANNER_KINDS.filter(k => s.banners[k] && (k !== 'reminder' || s.reminders.banners)) as PingSource[]
127
+ }
128
+
129
+ /** Whole words of 3 or more letters, lowercased, at most 40: what the steer boxes match. */
130
+ export function steerWords(textValue: string): string[] {
131
+ const out = new Set<string>()
132
+ for (const w of textValue.normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
133
+ if (w.length >= 3 && !STEER_STOP.has(w)) out.add(w)
134
+ if (out.size >= 40) break
135
+ }
136
+ return [...out]
137
+ }
138
+ const STEER_STOP = new Set('the and for with from about that this those these any all ping pings me my you your not dont leave alone please stuff things don won can'.split(' '))
139
+ /** True when `line` contains any of `words` as a whole word. */
140
+ export function matchesSteer(line: string, words: readonly string[]): boolean {
141
+ if (!words.length) return false
142
+ const have = new Set(line.normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u))
143
+ return words.some(w => have.has(w))
144
+ }
145
+
146
+ /** Read the settings file, or the defaults when it is missing, unsafe or unreadable. Never throws. */
147
+ export function readP2Settings(file = P2_SETTINGS_FILE()): P2Settings {
148
+ try {
149
+ if (!existsSync(file)) return P2_DEFAULTS
150
+ const st = lstatSync(file)
151
+ if (!st.isFile() || st.isSymbolicLink() || st.size > 64 * 1024) return P2_DEFAULTS
152
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
153
+ return normalizeP2Settings(isRecord(raw) && raw.v === P2_SETTINGS_PROTOCOL ? raw.settings : null)
154
+ } catch { return P2_DEFAULTS }
155
+ }
156
+
157
+ export function writeP2Settings(settings: P2Settings, file = P2_SETTINGS_FILE()): void {
158
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
159
+ durableAtomicWriteFileSync(file, JSON.stringify({ v: P2_SETTINGS_PROTOCOL, settings: normalizeP2Settings(settings) }, null, 2) + '\n')
160
+ try { chmodSync(file, 0o600) } catch { /* best effort: the folder is 0700 */ }
161
+ }
162
+
163
+ /** A cache so the engine's minute tick does not re-read the file each time; a write through this module clears it. */
164
+ export function createP2SettingsReader(file = P2_SETTINGS_FILE(), ttlMs = 15_000, now: () => number = Date.now) {
165
+ let cached: { at: number; settings: P2Settings } | null = null
166
+ return {
167
+ read(): P2Settings {
168
+ if (cached && now() - cached.at < ttlMs) return cached.settings
169
+ cached = { at: now(), settings: readP2Settings(file) }
170
+ return cached.settings
171
+ },
172
+ write(next: P2Settings): P2Settings { writeP2Settings(next, file); cached = { at: now(), settings: normalizeP2Settings(next) }; return cached.settings },
173
+ }
174
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Drop it P2 (plan PLAN_drop_it_2026-11-04 section 4): the resolution loop. "Your Work item finished", once.
3
+ *
4
+ * Links are stable ids, never titles: a card is its domain plus workIdentity, a handoff is its journal receipt id,
5
+ * a session is provider:native, a thread its id. Detection runs two ways that must agree:
6
+ * - events from the server's own writes (a card moved through /api/work-board or /api/tasks), and
7
+ * - a 2-minute poll of the linked open cards and their handoff receipts, because the helper and other sessions
8
+ * write tasks.md directly.
9
+ *
10
+ * Exactly once (canary 7): the key is `dropId:linkKind:linkId:state`, and the feed store's insert is idempotent on
11
+ * it under the store lock, written before any banner. A state seen by both the event and the poll, or again after a
12
+ * restart, finds the key and stops. A QA, Draft, QA bounce posts once.
13
+ *
14
+ * What posts: a card reaching QA or Complete, or archived; a handoff completed (with its report, 240 characters),
15
+ * failed or refused; a session follow-up that was replied to or failed; a thread resolved or gone stale. Other moves
16
+ * change only the drop's status in History. No model, no script; the words are templates over the link's label.
17
+ */
18
+ import type { DropRecord, DropLink } from './drop-store.js'
19
+ import { clip, PING_TEXT, type PingCandidate } from './ping-sources.js'
20
+
21
+ /** Control's report clip (C WorkProgressTracker.swift:588). */
22
+ export const RESOLUTION_REPORT_CHARS = 240
23
+ export const RESOLUTION_POLL_MS = 2 * 60_000
24
+ /** Drops older than this are not polled: a card that resolves after a quarter is not news about the drop. */
25
+ export const RESOLUTION_WINDOW_DAYS = 90
26
+
27
+ export type CardState = 'qa' | 'complete' | 'archived'
28
+ export type HandoffState = 'completed' | 'failed' | 'refused'
29
+ export type SessionState = 'replied' | 'failed'
30
+ export type ThreadState = 'resolved' | 'stale'
31
+
32
+ export interface CardSnapshot { workStage?: string; checked?: boolean }
33
+ export interface HandoffSnapshot { id: string; status: string; result?: string | null }
34
+ export interface ThreadSnapshot { is_resolved?: boolean; is_stale?: boolean }
35
+
36
+ /** What a card's board row says, as a resolution state, or null for a move that only updates status silently. */
37
+ export function cardState(row: CardSnapshot | null | undefined): CardState | null {
38
+ if (row === null) return 'archived' // absent from the board: archived ([~]) or removed
39
+ if (!row) return null
40
+ if (row.checked === true || row.workStage === 'complete') return 'complete'
41
+ if (row.workStage === 'qa') return 'qa'
42
+ return null
43
+ }
44
+ export function handoffState(receipt: HandoffSnapshot | null | undefined): HandoffState | null {
45
+ const s = receipt?.status
46
+ return s === 'completed' || s === 'failed' || s === 'refused' ? s : null
47
+ }
48
+ export function threadState(t: ThreadSnapshot | null | undefined): ThreadState | null {
49
+ if (!t) return null
50
+ if (t.is_resolved === true) return 'resolved'
51
+ if (t.is_stale === true) return 'stale'
52
+ return null
53
+ }
54
+
55
+ /** The exactly-once key. */
56
+ export const resolutionKey = (dropId: string, link: Pick<DropLink, 'kind' | 'id'>, state: string) => `${dropId}:${link.kind}:${link.id}:${state}`
57
+
58
+ const OPEN_CARD = { id: 'open_card', label: 'Open card' }
59
+ const OPEN_RESULT = { id: 'open_result', label: 'Open result' }
60
+ const OPEN = { id: 'open', label: 'Open' }
61
+
62
+ /** "Re: send Gina the JCK numbers": the one-line quote of the drop a follow-up answers (plan section 1). */
63
+ export function quoteOf(drop: Pick<DropRecord, 'text'>): string {
64
+ return `Re: ${clip(drop.text, 80)}`
65
+ }
66
+
67
+ /** The follow-up for one link reaching one state. */
68
+ export function resolutionCandidate(
69
+ drop: Pick<DropRecord, 'id' | 'text'>,
70
+ link: DropLink,
71
+ state: CardState | HandoffState | SessionState | ThreadState,
72
+ nowMs: number,
73
+ extra: { report?: string | null; handoffId?: string } = {},
74
+ ): PingCandidate {
75
+ const label = clip(link.label, PING_TEXT.title) || 'it'
76
+ const base = { source: 'resolution' as const, key: resolutionKey(drop.id, extra.handoffId ? { kind: 'handoff', id: extra.handoffId } : link, state), title: quoteOf(drop), createdAt: nowMs, threadId: drop.id }
77
+ const report = extra.report ? clip(extra.report, RESOLUTION_REPORT_CHARS) : ''
78
+ switch (state) {
79
+ case 'complete': return { ...base, line: `Done: ${label} moved to Complete.`, links: [link], actions: [OPEN_CARD] }
80
+ case 'qa': return { ...base, line: `${label} is ready for your review.`, links: [link], actions: [OPEN_CARD], needsReply: true }
81
+ case 'archived': return { ...base, line: `${label} was archived.`, links: [link], actions: [] }
82
+ case 'completed': return { ...base, line: report ? `Done: ${label}. ${report}` : `Done: ${label}.`, links: [link], actions: [OPEN_RESULT, OPEN_CARD] }
83
+ case 'refused': return { ...base, line: `The agent refused ${label}.${report ? ` ${report}` : ''}`, links: [link], actions: [OPEN_RESULT, OPEN_CARD], needsReply: true }
84
+ case 'failed': return link.kind === 'session'
85
+ ? { ...base, line: `${label} stopped before it replied.`, links: [link], actions: [OPEN_RESULT], needsReply: true }
86
+ : { ...base, line: `${label} failed.${report ? ` ${report}` : ''}`, links: [link], actions: [OPEN_RESULT, OPEN_CARD], needsReply: true }
87
+ case 'replied': return { ...base, line: `${label} replied.`, links: [link], actions: [OPEN_RESULT] }
88
+ case 'resolved': return { ...base, line: `The ${label} thread is resolved.`, links: [link], actions: [OPEN] }
89
+ case 'stale': return { ...base, line: `The ${label} thread went quiet.`, links: [link], actions: [OPEN] }
90
+ }
91
+ }
92
+
93
+ /** Drops whose links the poll follows: filed, not undone, inside the window, with a card, thread or held session. */
94
+ export function followedDrops(drops: readonly DropRecord[], nowMs: number): DropRecord[] {
95
+ const cutoff = nowMs - RESOLUTION_WINDOW_DAYS * 86_400_000
96
+ return drops.filter(d => d.state !== 'undone' && Date.parse(d.createdAt) >= cutoff && d.links.some(l => l.kind === 'card' || l.kind === 'thread'))
97
+ }
98
+
99
+ export interface ResolutionSnapshot {
100
+ /** Board rows keyed `${domain}:${workIdentity}`. Absent means the board could not be read (nothing is inferred). */
101
+ cards?: ReadonlyMap<string, CardSnapshot>
102
+ /** Handoff receipts for a card, keyed `${domain}:${workIdentity}`, journal order. */
103
+ handoffs?: ReadonlyMap<string, readonly HandoffSnapshot[]>
104
+ /** Thread rows by id. Absent means the cache could not be read. */
105
+ threads?: ReadonlyMap<string, ThreadSnapshot>
106
+ }
107
+
108
+ /**
109
+ * The follow-ups a poll (or an event, as a one-card snapshot) finds. Pure: candidates out, and the ping engine posts
110
+ * each key at most once. A card missing from a board that WAS read counts as archived; an unread board says nothing.
111
+ */
112
+ export function pollResolutions(drops: readonly DropRecord[], snap: ResolutionSnapshot, nowMs: number): PingCandidate[] {
113
+ const out: PingCandidate[] = []
114
+ for (const drop of followedDrops(drops, nowMs)) {
115
+ for (const link of drop.links) {
116
+ if (link.kind === 'card' && link.domain && snap.cards) {
117
+ const key = `${link.domain}:${link.id}`
118
+ const row = snap.cards.has(key) ? snap.cards.get(key)! : null
119
+ const state = cardState(row)
120
+ if (state) out.push(resolutionCandidate(drop, link, state, nowMs))
121
+ for (const receipt of snap.handoffs?.get(key) ?? []) {
122
+ const h = handoffState(receipt)
123
+ if (h) out.push(resolutionCandidate(drop, link, h, nowMs, { report: receipt.result ?? null, handoffId: receipt.id }))
124
+ }
125
+ } else if (link.kind === 'thread' && snap.threads) {
126
+ const state = threadState(snap.threads.get(link.id))
127
+ if (state) out.push(resolutionCandidate(drop, link, state, nowMs))
128
+ }
129
+ }
130
+ }
131
+ return out
132
+ }
133
+
134
+ /** An event from the server's own card write: the same rule as the poll, for the drops linked to that one card. */
135
+ export function cardWriteResolutions(drops: readonly DropRecord[], domain: string, workIdentity: string, row: CardSnapshot | null, nowMs: number): PingCandidate[] {
136
+ const linked = drops.filter(d => d.links.some(l => l.kind === 'card' && l.domain === domain && l.id === workIdentity))
137
+ return pollResolutions(linked, { cards: new Map(row ? [[`${domain}:${workIdentity}`, row]] : []) }, nowMs)
138
+ .filter(c => !c.key.includes(':handoff:'))
139
+ }
140
+
141
+ /** A session hook event as a turn outcome: Stop is a reply; StopFailure and Interrupt stopped it. A subagent's never counts. */
142
+ export function sessionTurnOutcome(event: string, child: boolean): SessionState | null {
143
+ if (child) return null
144
+ if (event === 'Stop') return 'replied'
145
+ if (event === 'StopFailure' || event === 'Interrupt') return 'failed'
146
+ return null
147
+ }
148
+
149
+ /** A held drop names its session as `provider:native` or the bare native id; the hooks report the native id. */
150
+ export function sameHeldSession(held: string, hookSessionId: string): boolean {
151
+ const a = held.toLowerCase(), b = hookSessionId.toLowerCase()
152
+ return a === b || a.endsWith(`:${b}`)
153
+ }
154
+
155
+ /**
156
+ * The turn after Send to session ended (SessionSignalStore): replied or failed, for the drops held for it. Only a turn
157
+ * that ended after the drop was sent counts, so the end of the turn already running at Send is never its answer.
158
+ */
159
+ export function sessionTurnResolutions(drops: readonly DropRecord[], sessionId: string, outcome: SessionState, nowMs: number, endedAtMs = nowMs): PingCandidate[] {
160
+ const out: PingCandidate[] = []
161
+ for (const d of drops) {
162
+ if (d.state === 'undone' || d.dest !== 'session' || !d.hold?.sentAt || !sameHeldSession(d.hold.sessionId, sessionId)) continue
163
+ if (!(Date.parse(d.hold.sentAt) < endedAtMs)) continue
164
+ const link: DropLink = d.links.find(l => l.kind === 'session' && sameHeldSession(l.id, sessionId)) ?? { kind: 'session', id: d.hold.sessionId, label: d.hold.label }
165
+ out.push(resolutionCandidate(d, link, outcome, nowMs))
166
+ }
167
+ return out
168
+ }