@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -0,0 +1,272 @@
1
+ // No absolute Mac path reaches the browser through a session's words (7.0.4 S1).
2
+ //
3
+ // WHY. A reply is sent as the agent wrote it, and agents link the files they touched with full
4
+ // paths: `[app.ts](/Users/<name>/repo/server/app.ts#L42)`, Codex's `[app.ts](/abs/app.ts:42)`, a
5
+ // bare `~/Documents/...`, a path in backticks. A read-only scan of 14 days of transcripts found 111
6
+ // Claude links and 826 Codex links carrying `/Users/...` into Control Web. The path names the
7
+ // person, their folders and their projects, and the browser has no use for it: it cannot open it.
8
+ //
9
+ // WHAT. Every private local path in a string becomes its base name, keeping a line anchor
10
+ // (`app.ts:42`, from `:42` or `#L42`). A Markdown link to a local file becomes its words (or the
11
+ // base name when it has none). Web URLs are untouched; relative paths stay as written; a site path
12
+ // such as `/api/x` is not a Mac path and stays too. `redactLocalPathsDeep` applies this to every
13
+ // string in a JSON value, so a field added to the session detail later is covered without a list.
14
+ //
15
+ // WHAT COUNTS AS LOCAL (narrowed in 7.0.4 B1). Only places that name this Mac's people and data:
16
+ // a path under `/Users/<name>`, `/Volumes/<name>`, `/private/...` (where /var and /tmp really
17
+ // live) or `/var/folders/...` (per-user temp), a home shorthand (`~/`, `~name/` with a letter
18
+ // first, `$HOME/`, `${HOME}/`), a `file://` URL, or an editor URL that opens a file
19
+ // (`vscode://file/...`). The `.cos-glasses` data folder lives under the home folder, so it is
20
+ // covered. Everything else is ordinary text and stays exactly as written: `/dev/null`,
21
+ // `/usr/bin/env`, `/tmp/out`, `/opt/homebrew`, `/home`, `/run/start`, `~80/100`, `~1/3`, `A/B`,
22
+ // dates and fractions. The first cut treated every Unix top-level folder as private and `~<digit>`
23
+ // as home, and rewrote prose: "Pacing ~80/100 leads" read "Pacing 100 leads", "#!/usr/bin/env
24
+ // node" read "#!env node".
25
+ //
26
+ // SPACES. A folder name with spaces (`/Users/x/Ukaoma Chief Of Staff/MU-Chief-Staff/CLAUDE.md`)
27
+ // is followed across capitalised words, but only when the continuation ends at a real file
28
+ // extension or a `/`. A continuation that ends anywhere else ("/Users/x/out Next Steps: Review
29
+ // A/B results") is not taken, so the words after a path are never eaten. Either way the private
30
+ // head (`/Users/<name>`) is removed.
31
+
32
+ /** Editor and file URL schemes whose target is a file on this Mac. */
33
+ const LOCAL_SCHEMES = ['file', 'vscode', 'vscode-insiders', 'cursor', 'windsurf', 'zed', 'idea', 'subl', 'txmt', 'mvim']
34
+
35
+ /** Delimiters a path never runs through (besides whitespace) in prose, Markdown and code. */
36
+ const DELIMS = `<>"'\`“”‘’|)\\]}*,;`
37
+ const STOP = `\\s${DELIMS}`
38
+ /** A root name in any letter case: APFS is case-insensitive, so `/USERS/jane` is the same folder as `/Users/jane`. */
39
+ function anyCase(word: string): string {
40
+ return [...word].map(c => /[a-z]/i.test(c) ? `[${c.toUpperCase()}${c.toLowerCase()}]` : c).join('')
41
+ }
42
+ const USERS = anyCase('Users')
43
+ /** A private root, followed by at least one more name: `/Users` alone, or "the /Users folder", is not a path. `//Users` counts. */
44
+ const PRIVATE_ROOT = `/+(?:${USERS}|${anyCase('Volumes')}|${anyCase('private')})(?=/[^${STOP}/])|/+${anyCase('var')}/${anyCase('folders')}(?=/)`
45
+ /** A home shorthand: `~/`, `~name/` (a letter first, so `~80/100` and `~1/3` stay numbers), `$HOME/`, `${HOME}/`. */
46
+ const HOME_SHORTHAND = `(?:~(?:[A-Za-z][A-Za-z0-9._-]*)?|\\$HOME|\\$\\{HOME\\})(?=/)`
47
+ const HEAD = `(?:${HOME_SHORTHAND}|${PRIVATE_ROOT})`
48
+ /** A bare path follows prose punctuation, never a path segment (including ./ or ../). Web spans are handled separately. */
49
+ const BARE = new RegExp(`(^|[^\\p{L}\\p{N}_./-])(${HEAD})([^${STOP}]*)`, 'gu')
50
+ const SCHEME_URL = new RegExp(`\\b(?:${LOCAL_SCHEMES.join('|')}):\\/\\/[^${STOP}]*`, 'gi')
51
+ /** Complete HTTP(S) tokens, including query/fragment paths. Keep their original bytes rather than reserializing a URL. */
52
+ const WEB_URL = /\bhttps?:\/\/[^\s<>"'`“”‘’]+/gi
53
+ /** `[words](destination)` and `![words](destination)`, the destination angle-wrapped or not (spaces allowed). */
54
+ const LINK = /(!?)\[([^\]\n]{0,500})\]\(\s*(<[^>\n]{1,4096}>|[^\s()<>\n][^)\n]{0,4096}?)\s*\)/g
55
+ const LOCAL_DEST = new RegExp(`^(?:${HEAD})`)
56
+ const LOCAL_SCHEME_DEST = new RegExp(`^(?:${LOCAL_SCHEMES.join('|')}):\\/\\/`, 'i')
57
+ /** One word of a folder name with spaces: capitalised or a number, no colon, and not ending a sentence. */
58
+ const SPACED_WORD = `[A-Z0-9](?:[^\\s/:${DELIMS}]*[^\\s/:.!?${DELIMS}])?`
59
+ /** One more space-separated folder name, while the words are capitalised and lead to another `/`. */
60
+ const SPACED_TAIL = new RegExp(`^( (?:${SPACED_WORD} ){0,4}${SPACED_WORD}/[^${STOP}]*)`)
61
+ /** A real file extension (with a letter in it, so `v1.2` and `10.15` are not), plus any `:line` anchors. */
62
+ const HAS_EXTENSION = /\.(?=[A-Za-z0-9]{0,9}[A-Za-z])[A-Za-z0-9]{1,10}(?::\d+)*$/
63
+ const TRAILING_PUNCTUATION = /[.:!?]+$/
64
+
65
+ function decoded(value: string): string {
66
+ try { return decodeURIComponent(value) } catch { return value }
67
+ }
68
+
69
+ /** The base name of a path, with any line anchor it carries (`:42`, or `#L42` turned into `:42`). */
70
+ function baseName(path: string): string {
71
+ let value = path
72
+ let anchor = ''
73
+ const hash = value.indexOf('#')
74
+ if (hash >= 0) {
75
+ const line = /^L(\d+)/i.exec(value.slice(hash + 1))
76
+ if (line) anchor = ':' + line[1]
77
+ value = value.slice(0, hash)
78
+ }
79
+ const query = value.indexOf('?')
80
+ if (query >= 0) value = value.slice(0, query)
81
+ const plain = decoded(value)
82
+ // The home folder itself names the person: it reads `~`, never its base name.
83
+ if (/^\/+users\/[^/]+\/?$/i.test(plain)) return '~' + anchor
84
+ const parts = plain.split('/').filter(Boolean)
85
+ const last = parts.pop() || '~'
86
+ return /:\d+$/.test(last) ? last : last + anchor
87
+ }
88
+
89
+ /** A path that may be carried across spaces: it ends at a real file extension or at a folder separator. */
90
+ function endsAtFileOrFolder(path: string): boolean {
91
+ const core = path.replace(TRAILING_PUNCTUATION, '')
92
+ return core.endsWith('/') || HAS_EXTENSION.test(core)
93
+ }
94
+
95
+ function redactBare(text: string): string {
96
+ // Find the matches and their spaced tails first, then rebuild the string once.
97
+ const pieces: string[] = []
98
+ let cursor = 0
99
+ BARE.lastIndex = 0
100
+ for (let m = BARE.exec(text); m; m = BARE.exec(text)) {
101
+ const [match, before, head, body] = m
102
+ const start = m.index
103
+ if (start < cursor) continue
104
+ let path = head + body
105
+ let end = start + match.length
106
+ // A folder name with spaces is followed only when the continuation ends at a file extension or a `/`.
107
+ // A path that already ends that way, or that ends a sentence, is never extended.
108
+ if (!endsAtFileOrFolder(path) && !TRAILING_PUNCTUATION.test(path)) {
109
+ let probe = path
110
+ let probeEnd = end
111
+ for (let guard = 0; guard < 8; guard++) {
112
+ const tail = SPACED_TAIL.exec(text.slice(probeEnd))
113
+ if (!tail) break
114
+ probe += tail[1]
115
+ probeEnd += tail[1].length
116
+ if (endsAtFileOrFolder(probe)) { path = probe; end = probeEnd; break }
117
+ if (TRAILING_PUNCTUATION.test(probe)) break
118
+ }
119
+ }
120
+ const punctuation = TRAILING_PUNCTUATION.exec(path)?.[0] ?? ''
121
+ const core = punctuation ? path.slice(0, -punctuation.length) : path
122
+ pieces.push(text.slice(cursor, start), before, baseName(core), punctuation)
123
+ cursor = end
124
+ BARE.lastIndex = Math.max(end, start + 1)
125
+ }
126
+ pieces.push(text.slice(cursor))
127
+ return pieces.join('')
128
+ }
129
+
130
+ /** URL query values can look exactly like local paths; redact only the text between whole web tokens. */
131
+ function redactOutsideWebURLs(text: string): string {
132
+ const redact = (part: string) => redactBare(part.replace(SCHEME_URL,
133
+ url => baseName(url.replace(LOCAL_SCHEME_DEST, '').replace(/^[^/]*\//, '/'))))
134
+ const pieces: string[] = []
135
+ let cursor = 0
136
+ WEB_URL.lastIndex = 0
137
+ for (let match = WEB_URL.exec(text); match; match = WEB_URL.exec(text)) {
138
+ let token = match[0]
139
+ // A prose/Markdown closing parenthesis ends the token; balanced URL path
140
+ // parentheses remain part of it. Explicit angle/quote wrapping supplies its own end.
141
+ if (!/[<"'`“‘]/.test(text[match.index - 1] ?? '')) {
142
+ let depth = 0
143
+ for (let i = 0; i < token.length; i++) {
144
+ if (token[i] === '(') depth++
145
+ else if (token[i] === ')' && depth-- === 0) { token = token.slice(0, i); break }
146
+ }
147
+ }
148
+ try { new URL(token) } catch { continue }
149
+ pieces.push(redact(text.slice(cursor, match.index)), hideHomeInUrl(token))
150
+ cursor = match.index + token.length
151
+ WEB_URL.lastIndex = cursor
152
+ }
153
+ pieces.push(redact(text.slice(cursor)))
154
+ return pieces.join('')
155
+ }
156
+
157
+ /**
158
+ * `/Users/<name>` (any letter case, any number of leading slashes). In code spans, code blocks and command fields it
159
+ * reads as the visible marker `[home]`, keeping the rest of the path, so a reader sees where the path was and that it
160
+ * was changed. The result is a display, not a runnable command: `~` was not used because a quoted `'~/x'` does not
161
+ * expand, so it would read as exact while meaning something else. In a web URL's query it reads `~`, which keeps the
162
+ * URL well formed. `/Users/Shared` is not a person's home and stays.
163
+ */
164
+ const HOME_FOLDER = new RegExp(`(^|[^\\p{L}\\p{N}_.-])/+${USERS}/(?!Shared(?:/|$|[${STOP}]))[^${STOP}/&?#=]+`, 'gu')
165
+ /** The same inside a URL query, percent-encoded: `%2FUsers%2Fjane` is `~`. */
166
+ const ENCODED_HOME_FOLDER = /(?:%2F)+users%2F(?!shared(?:%2F|[&#\s]|$))[^%&?#=\s/]+/gi
167
+ /** What a hidden home folder reads as in code and commands. */
168
+ export const HOME_MARKER = '[home]'
169
+
170
+ /** Every `/Users/<name>` in this text as the visible `[home]` marker; nothing else changes. */
171
+ export function markHomeFolder(text: string): string {
172
+ if (typeof text !== 'string' || !text) return text
173
+ return text.replace(HOME_FOLDER, (_match, before: string) => before + HOME_MARKER)
174
+ }
175
+
176
+ /** A web URL exactly as written, except a home folder in its query or fragment (`?path=/Users/jane/a.pdf`). */
177
+ function hideHomeInUrl(url: string): string {
178
+ const at = url.search(/[?#]/)
179
+ if (at < 0) return url
180
+ return url.slice(0, at) + url.slice(at).replace(HOME_FOLDER, (_match, before: string) => before + '~').replace(ENCODED_HOME_FOLDER, '~')
181
+ }
182
+
183
+ /**
184
+ * Code in Markdown: fenced blocks (``` or ~~~, closed by a fence of the same kind at least as long, or by the end of the
185
+ * text) and inline code spans (a run of backticks closed by a run of the same length, within one paragraph).
186
+ */
187
+ function codeRanges(text: string): Array<[number, number]> {
188
+ const ranges: Array<[number, number]> = []
189
+ let open: { start: number; mark: string; length: number } | null = null
190
+ for (let pos = 0; pos <= text.length;) {
191
+ const newline = text.indexOf('\n', pos)
192
+ const end = newline < 0 ? text.length : newline
193
+ const line = text.slice(pos, end)
194
+ if (!open) {
195
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line)
196
+ if (fence && !(fence[1][0] === '`' && line.slice(fence[0].length).includes('`'))) open = { start: pos, mark: fence[1][0]!, length: fence[1].length }
197
+ } else {
198
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)
199
+ if (close && close[1][0] === open.mark && close[1].length >= open.length) { ranges.push([open.start, end]); open = null }
200
+ }
201
+ if (newline < 0) break
202
+ pos = newline + 1
203
+ }
204
+ if (open) ranges.push([open.start, text.length])
205
+ const inFence = (at: number) => ranges.some(([s, e]) => at >= s && at < e)
206
+ const INLINE = /(?<!`)(`+)(?!`)([\s\S]*?[^`])\1(?!`)/g
207
+ const spans: Array<[number, number]> = []
208
+ for (let m = INLINE.exec(text); m; m = INLINE.exec(text)) {
209
+ if (inFence(m.index)) { INLINE.lastIndex = m.index + 1; continue }
210
+ if (/\n[ \t]*\n/.test(m[2]!)) { INLINE.lastIndex = m.index + m[1]!.length; continue }
211
+ spans.push([m.index, m.index + m[0].length])
212
+ }
213
+ return [...ranges, ...spans].sort((x, y) => x[0] - y[0])
214
+ }
215
+
216
+ /**
217
+ * Every private local path in this text. In prose a path becomes its base name (Markdown links to a local file become
218
+ * their words). In code spans and code blocks the path is kept and only the home folder is replaced, by the visible
219
+ * `[home]` marker: the code is then a display of the command, not the command. Web URLs are untouched except a home folder in their query; relative paths, site
220
+ * paths and system paths (`/dev/null`, `/usr/bin/env`, `/tmp/x`) stay as written.
221
+ */
222
+ export function redactLocalPaths(text: string): string {
223
+ if (typeof text !== 'string' || !text) return text
224
+ const before = codeRanges(text)
225
+ const inCode = (at: number) => before.some(([s, e]) => at >= s && at < e)
226
+ const linked = text.replace(LINK, (match: string, _bang: string, words: string, rawDest: string, offset: number) => {
227
+ if (inCode(offset)) return match // a link written inside code is code
228
+ const dest = rawDest.startsWith('<') ? rawDest.slice(1, -1).trim() : rawDest.trim()
229
+ let target: string | null = null
230
+ if (LOCAL_SCHEME_DEST.test(dest)) target = dest.replace(LOCAL_SCHEME_DEST, '').replace(/^[^/]*\//, '/')
231
+ else if (LOCAL_DEST.test(dest)) target = dest
232
+ if (target === null) return match // a web URL, a relative path or a site path: left exactly as written
233
+ const name = baseName(target)
234
+ const label = words.trim()
235
+ if (!label) return name
236
+ // `[app.ts](/abs/app.ts#L42)` keeps its line: the words named the file, the anchor was in the link.
237
+ const anchor = /:\d+$/.exec(name)?.[0] ?? ''
238
+ return anchor && !/:\d+$/.test(label) && label.split('/').pop() === name.slice(0, -anchor.length) ? label + anchor : label
239
+ })
240
+ const pieces: string[] = []
241
+ let cursor = 0
242
+ for (const [start, end] of codeRanges(linked)) {
243
+ if (start < cursor) continue
244
+ pieces.push(redactOutsideWebURLs(linked.slice(cursor, start)), markHomeFolder(linked.slice(start, end)))
245
+ cursor = end
246
+ }
247
+ pieces.push(redactOutsideWebURLs(linked.slice(cursor)))
248
+ return pieces.join('')
249
+ }
250
+
251
+ /**
252
+ * Fields whose words are a command or a reason (a waiting permission's command, a failure): the path is kept and only
253
+ * the home folder reads `[home]`, so `rm -rf /Users/<name>/Documents` reads `rm -rf [home]/Documents`, never the
254
+ * harmless-looking `rm -rf Documents`. Control Web labels that line as a display. The exact command is on the approval
255
+ * card (`/api/session-questions`), which is not redacted.
256
+ */
257
+ export const COMMAND_KEYS: ReadonlySet<string> = new Set(['waiting_detail', 'failure'])
258
+
259
+ /** A copy of this JSON value with every string redacted (keys are left alone). Command fields keep their paths. */
260
+ export function redactLocalPathsDeep<T>(value: T): T {
261
+ const walk = (item: unknown, command: boolean): unknown => {
262
+ if (typeof item === 'string') return command ? markHomeFolder(item) : redactLocalPaths(item)
263
+ if (Array.isArray(item)) return item.map(entry => walk(entry, command))
264
+ if (item && typeof item === 'object') {
265
+ const out: Record<string, unknown> = {}
266
+ for (const [key, entry] of Object.entries(item as Record<string, unknown>)) out[key] = walk(entry, command || COMMAND_KEYS.has(key))
267
+ return out
268
+ }
269
+ return item
270
+ }
271
+ return walk(value, false) as T
272
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { callPython } from './python-bridge.js'
2
+ import { countsAsMeeting, readCalendar } from './calendar-source.js'
3
+ import { callPython, pythonBridgeAvailable } from './python-bridge.js'
3
4
  import { redactSecrets } from './lens-gist-engines.js'
4
5
  import { type MeetingAskCalendarMatch, type MeetingAskOrientation } from '../../shared/meeting-ask.js'
5
6
  import { domainNames, isSafeDomainName } from './domains.js'
@@ -8,13 +9,19 @@ import { domainLabel } from './domain-label.js'
8
9
  import { AskError } from './live-cos-ask.js'
9
10
  export const meetingDomains = () => domainNames(resolveCosOperationsDir()).map(id => ({id,label:domainLabel(id)}))
10
11
  export const emptyOrientation = (): MeetingAskOrientation => ({ revision: 0, domain: null, calendar: null })
12
+ type CandidateEvent = { match: MeetingAskCalendarMatch; event: any }
11
13
  export function calendarCandidates(raw: unknown, startedAt: number | null): MeetingAskCalendarMatch[] {
14
+ return candidateEvents(raw, startedAt).map(c => c.match)
15
+ }
16
+ // QA W23: the same prep filter as Next up and Today (countsAsMeeting), so a declined meeting or a family flight that
17
+ // covers the recording's start is never offered as "this meeting".
18
+ function candidateEvents(raw: unknown, startedAt: number | null): CandidateEvent[] {
12
19
  if (startedAt === null || !Number.isFinite(startedAt)) return []
13
20
  const data = raw as any
14
21
  if (!data || data.is_stale || !Array.isArray(data.today_events)) return []
15
22
  const seen = new Set<string>()
16
- return data.today_events.slice(0, 200).flatMap((event: any) => {
17
- if (!event || event.is_all_day || typeof event.title !== 'string' || event.title.length > 500) return []
23
+ return data.today_events.slice(0, 200).flatMap((event: any): CandidateEvent[] => {
24
+ if (!event || !countsAsMeeting(event) || typeof event.title !== 'string' || event.title.length > 500) return []
18
25
  const start = Date.parse(event.start_time), end = Date.parse(event.end_time)
19
26
  // Match the recording's start, never whichever event happens to be current when Ask runs.
20
27
  if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end - start > 24 * 3600000 || startedAt < start - 5 * 60000 || startedAt >= end) return []
@@ -22,14 +29,41 @@ export function calendarCandidates(raw: unknown, startedAt: number | null): Meet
22
29
  if (!title || /\booo\b|out of office|vacation|\bpto\b/i.test(title)) return []
23
30
  const match: MeetingAskCalendarMatch = { id: createHash('sha256').update(JSON.stringify([title, start, end])).digest('hex').slice(0, 24), title, start: new Date(start).toISOString(), end: new Date(end).toISOString(), ...(typeof event.domain === 'string' && isSafeDomainName(event.domain) ? { suggestedDomain: event.domain } : {}) }
24
31
  if (seen.has(match.id)) return []
25
- seen.add(match.id); return [match]
32
+ seen.add(match.id); return [{ match, event }]
26
33
  }).slice(0, 5)
27
34
  }
35
+ /**
36
+ * QA W21: snapshot rows carry domain: null (the bridge's classifier is Python people context), so ask the bridge's
37
+ * `calendar-classify` for the candidates that lack one: the exact rule its own calendar answer uses
38
+ * (classify_event_by_attendees on title, attendee names and calendar name). Names only over stdin, never addresses or
39
+ * domains. No bridge, an old bridge, a slow one or a bad answer: no suggestion, as before.
40
+ */
41
+ export async function classifyWithBridge(rows: Array<{ title: string; attendees: string[]; calendar_name: string | null }>): Promise<Array<string | null> | null> {
42
+ if (!rows.length || !pythonBridgeAvailable()) return null
43
+ const out = await callPython(['calendar-classify'], 3000, JSON.stringify({ events: rows })) as any
44
+ if (!out || !Array.isArray(out.results) || out.results.length !== rows.length) return null
45
+ return out.results.map((r: any) => (r && typeof r.domain === 'string' ? r.domain : null))
46
+ }
47
+ async function withSuggestedDomains(found: CandidateEvent[], classify = classifyWithBridge): Promise<MeetingAskCalendarMatch[]> {
48
+ const need = found.filter(f => !f.match.suggestedDomain)
49
+ if (!need.length) return found.map(f => f.match)
50
+ let domains: Array<string | null> | null = null
51
+ try {
52
+ domains = await classify(need.map(f => ({
53
+ title: f.match.title,
54
+ attendees: Array.isArray(f.event.attendees) ? f.event.attendees.filter((n: unknown): n is string => typeof n === 'string' && !n.includes('@')).slice(0, 50) : [],
55
+ calendar_name: typeof f.event.calendar_name === 'string' ? f.event.calendar_name : null,
56
+ })))
57
+ } catch { domains = null }
58
+ if (!domains) return found.map(f => f.match)
59
+ need.forEach((f, i) => { const d = domains![i]; if (typeof d === 'string' && isSafeDomainName(d)) f.match.suggestedDomain = d })
60
+ return found.map(f => f.match)
61
+ }
28
62
  export async function meetingCalendar(startedAt: number | null): Promise<{ candidates: MeetingAskCalendarMatch[]; notices: string[] }> {
29
63
  if (startedAt === null) return { candidates: [], notices: ['Calendar matching is available for a live recording. Choose a domain for this meeting.'] }
30
64
  try {
31
- const raw = await callPython(['calendar'], 5000) as any
32
- const candidates = calendarCandidates(raw, startedAt)
65
+ const raw = (await readCalendar(5000)).data as any
66
+ const candidates = await withSuggestedDomains(candidateEvents(raw, startedAt))
33
67
  return { candidates, notices: candidates.length ? [] : [raw?.error || raw?.is_stale ? 'Calendar context is unavailable or stale. Choose a domain manually.' : 'No calendar event matches this recording. Choose a domain manually.'] }
34
68
  } catch { return { candidates: [], notices: ['Calendar context is unavailable. Choose a domain manually.'] } }
35
69
  }
@@ -21,6 +21,8 @@ export interface MeetingAskRunBudget {
21
21
  deadlineMs?: number;
22
22
  }
23
23
  export const MEETING_ASK_DEFAULT_BUDGET = { maxOutputTokens: 1400, maxThinkingTokens: 0, deadlineMs: 45000 } as const;
24
+ export const MEETING_ASK_TERMINATION_PROOF_MS = 4500;
25
+ export const MEETING_ASK_LATE_PROOF_POLL_MS = 250;
24
26
  /** The child environment's token limits for a budget; the default is exactly '1400' and '0'. */
25
27
  export function meetingAskTokenEnv(budget?: MeetingAskRunBudget): { CLAUDE_CODE_MAX_OUTPUT_TOKENS: string; MAX_THINKING_TOKENS: string } {
26
28
  const output = budget?.maxOutputTokens ?? MEETING_ASK_DEFAULT_BUDGET.maxOutputTokens;
@@ -28,7 +30,9 @@ export function meetingAskTokenEnv(budget?: MeetingAskRunBudget): { CLAUDE_CODE_
28
30
  return { CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(output + thinking), MAX_THINKING_TOKENS: String(thinking) };
29
31
  }
30
32
  export class AskProcessError extends Error {
31
- constructor(message: string, readonly terminationUnconfirmed = false) { super(message); }
33
+ constructor(message: string, readonly terminationUnconfirmed = false,
34
+ /** Runner-issued proof: resolves only after the original child closed and its group is absent. Never an expiry. */
35
+ readonly terminationConfirmed?: Promise<void>) { super(message); }
32
36
  }
33
37
  export function runMeetingAskModel(model: MeetingAskModel, prompt: string, system: string, signal?: AbortSignal, adapter?: {
34
38
  workspace: string;
@@ -57,16 +61,17 @@ budget?: MeetingAskRunBudget): Promise<{
57
61
  const stdin = adapter?.stdin ?? (provider === 'claude' ? prompt : `${system}\n\n${prompt}`);
58
62
  return new Promise((resolve, reject) => {
59
63
  const child = spawn(binary.path, args, { cwd: workspace, detached: true, stdio: ['pipe', 'pipe', 'pipe'], env: { ...lensGistChildEnv(binary.path), ...meetingAskTokenEnv(budget) } });
64
+ const processGroupId = child.pid;
60
65
  let stdout = '', failure: string | null = null, done = false, closeSeen = false, code: number | null = null;
61
66
  let killTimer: NodeJS.Timeout | undefined, proofTimer: NodeJS.Timeout | undefined;
62
- const kill = (sig: NodeJS.Signals) => { if (child.pid)
67
+ const kill = (sig: NodeJS.Signals) => { if (processGroupId)
63
68
  try {
64
- process.kill(-child.pid, sig);
69
+ process.kill(-processGroupId, sig);
65
70
  }
66
71
  catch { } };
67
- const alive = () => { if (!child.pid)
72
+ const alive = () => { if (!processGroupId)
68
73
  return false; try {
69
- process.kill(-child.pid, 0);
74
+ process.kill(-processGroupId, 0);
70
75
  return true;
71
76
  }
72
77
  catch (e) {
@@ -99,8 +104,23 @@ budget?: MeetingAskRunBudget): Promise<{
99
104
  signal?.removeEventListener('abort', abort);
100
105
  if (!unconfirmed && !adapter)
101
106
  rmSync(workspace, { recursive: true, force: true });
102
- if (unconfirmed)
103
- return reject(new AskProcessError('child_exit_unconfirmed', true));
107
+ if (unconfirmed) {
108
+ // Answer the request within its shutdown budget, but keep observing the
109
+ // original child. An unconfirmed exit is not permission to admit another
110
+ // provider. This watcher is passive: no later signal can hit a reused PID.
111
+ let prove!: () => void;
112
+ const terminationConfirmed = new Promise<void>(resolve => { prove = resolve; });
113
+ const proof = setInterval(() => {
114
+ // A missing/reused/permission-denied group alone is not enough: the
115
+ // close event belongs to this original child, and ESRCH proves absence.
116
+ if (!closeSeen || !processGroupId || alive()) return;
117
+ clearInterval(proof);
118
+ if (!adapter) try { rmSync(workspace, { recursive: true, force: true }); } catch { /* proof does not depend on scratch cleanup */ }
119
+ prove();
120
+ }, MEETING_ASK_LATE_PROOF_POLL_MS);
121
+ proof.unref();
122
+ return reject(new AskProcessError('child_exit_unconfirmed', true, terminationConfirmed));
123
+ }
104
124
  if (failure || code !== 0)
105
125
  return reject(new AskProcessError(failure ?? 'provider_failed'));
106
126
  try {
@@ -135,7 +155,7 @@ budget?: MeetingAskRunBudget): Promise<{
135
155
  const start = Date.now();
136
156
  proofTimer = setInterval(() => { if (closeSeen && !alive())
137
157
  finish();
138
- else if (Date.now() - start > 4500)
158
+ else if (Date.now() - start > MEETING_ASK_TERMINATION_PROOF_MS)
139
159
  finish(true); }, 50);
140
160
  };
141
161
  const abort = () => stop('cancelled');
@@ -143,11 +163,11 @@ budget?: MeetingAskRunBudget): Promise<{
143
163
  signal?.addEventListener('abort', abort, { once: true });
144
164
  if (signal?.aborted)
145
165
  abort();
146
- child.stdout.on('data', (b: Buffer) => { stdout += b.toString(); if (stdout.length > 1000000)
166
+ child.stdout.on('data', (b: Buffer) => { if (done) return; stdout += b.toString(); if (stdout.length > 1000000)
147
167
  stop('output_limit'); });
148
168
  child.stderr.on('data', () => { }); // Never surface provider credentials/raw errors.
149
169
  child.stdin.on('error', () => stop('provider_input_failed'));
150
- child.on('error', () => { failure = 'provider_unavailable'; closeSeen = true; finish(); });
170
+ child.on('error', () => { failure = 'provider_unavailable'; finish(); });
151
171
  child.on('close', exit => { closeSeen = true; code = exit; if (failure) {
152
172
  if (!alive())
153
173
  finish();
@@ -42,6 +42,7 @@ import {
42
42
  import { importedLibraryMonths, supersededDayCounts } from './imported-library-rows.js'
43
43
  import { getMeetingStore } from './meeting-store.js'
44
44
  import { COS_SCRIPTS_DIR, callPython, contextSourceAvailable, pythonBridgeAvailable } from './python-bridge.js'
45
+ import { readCalendar } from './calendar-source.js'
45
46
  import { resolveProviderWorkDir } from './launch-dir.js'
46
47
 
47
48
  /** Highest stamped message number in the active era: live sessions, the day
@@ -113,8 +114,9 @@ export async function probeContext(): Promise<ContextProbe | null> {
113
114
  }
114
115
 
115
116
  export async function probeCalendar(): Promise<CalendarProbe | null> {
116
- if (!pythonBridgeAvailable()) return null
117
- const data = await callPython(['calendar'], 8_000) as { meetings_today_count?: unknown; today_events?: unknown; data_source?: unknown } | null
117
+ const read = await readCalendar(8_000)
118
+ if (read.source === 'none' || (read.source === 'bridge' && !pythonBridgeAvailable())) return null
119
+ const data = read.data as { meetings_today_count?: unknown; today_events?: unknown; data_source?: unknown } | null
118
120
  if (!data || typeof data !== 'object') return null
119
121
  const today = typeof data.meetings_today_count === 'number'
120
122
  ? data.meetings_today_count
@@ -0,0 +1,29 @@
1
+ // 7.0.4 W1: is this request COS Control's own helper on this Mac, rather than a browser?
2
+ //
3
+ // WHY. S1 redaction turns paths in a session's words into base names before they leave the Mac. COS Control
4
+ // (0.5.378 and 0.5.379, unchanged) recognises that a Work prompt it sent has ARRIVED by comparing the first 60
5
+ // letters and digits of its own, unredacted prompt with the user turns of `GET /api/agent-sessions/:p/:id?turns=N`
6
+ // (WorkProgress.promptArrival, through the helper's `session-recent-replies`). A prompt that opens with a path then
7
+ // never matched: no replies shown, Continue refused, a Cursor note stuck at queued.
8
+ //
9
+ // WHO. The helper calls `http://127.0.0.1:<port>` with the X-COS-Token header, from URLSession: no Origin, no
10
+ // Referer, no Sec-Fetch-* metadata, and a CFNetwork user agent. A browser always sends fetch metadata
11
+ // (Chrome 76, Firefox 90, Safari 16.4 and later) and a `Mozilla/` user agent; Control Web signs in with a cookie,
12
+ // not the header; a phone is never on loopback; a `tailscale serve` hop arrives on loopback but carries proxy
13
+ // headers (isLoopbackSocket refuses it). Any other local process holding the token can already read the files.
14
+ import type { Request, Response } from 'express'
15
+ import { API_AUTH_BY_LOCAL } from './api-auth.js'
16
+ import { isLoopbackSocket } from './network-policy.js'
17
+
18
+ const BROWSER_HEADERS = ['origin', 'referer', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'] as const
19
+
20
+ /** True only for a header-token request on this Mac's loopback, not proxied, carrying nothing a browser sends. */
21
+ export function isNativeMacCaller(req: Pick<Request, 'headers' | 'socket'>, res: Pick<Response, 'locals'>): boolean {
22
+ if (res.locals?.[API_AUTH_BY_LOCAL] !== 'token') return false
23
+ if (!isLoopbackSocket(req)) return false
24
+ const headers = req.headers ?? {}
25
+ for (const name of BROWSER_HEADERS) if (headers[name] !== undefined) return false
26
+ const agent = headers['user-agent']
27
+ if (typeof agent === 'string' && /\bMozilla\//.test(agent)) return false
28
+ return true
29
+ }
@@ -34,7 +34,7 @@ const POLLED: ReadonlySet<PingSource> = new Set<PingSource>(['prep', 'followups'
34
34
  export const PING_STATE_LIMITS = { held: 200, consumed: 5_000, consumedDays: 30, fileBytes: 4_000_000 } as const
35
35
 
36
36
  export interface PingSnapshot {
37
- nextEvent?: { title?: unknown; time?: unknown } | null
37
+ nextEvent?: { title?: unknown; time?: unknown; occurrence?: unknown } | null
38
38
  intakeOpen?: readonly IntakeRow[] | null
39
39
  threads?: readonly ThreadRow[] | null
40
40
  signals?: readonly SignalRow[] | null
@@ -19,6 +19,8 @@ import { createHash } from 'node:crypto'
19
19
  import { localClock } from './morning-brief-schedule.js'
20
20
  import { clockLabel } from './drop-when.js'
21
21
  import { PING_RANK, type PingSource } from './ping-budget.js'
22
+ import { parsePrepOccurrence } from './prep-store.js'
23
+ import type { PrepOccurrence } from '../../shared/prep.js'
22
24
 
23
25
  export interface PingLink { kind: 'card' | 'session' | 'handoff' | 'thread' | 'memory' | 'meeting' | 'file' | 'reminder' | 'ask'; id: string; label: string; domain?: string }
24
26
  export interface PingCandidate {
@@ -29,10 +31,10 @@ export interface PingCandidate {
29
31
  line: string
30
32
  links: PingLink[]
31
33
  /**
32
- * 6.99.19: the prep card's "Prep me" action also carries `prep` (title, startsAt and a null occurrence: the calendar
33
- * gives no event ids today), so a client can open a Prep draft for that event. Additive; a client may ignore it.
34
+ * 6.99.19: the prep card's "Prep me" action also carries `prep` (title, startsAt and the occurrence when the calendar
35
+ * gave one, else null), so a client can open the Prep draft for that event. Additive; a client may ignore it.
34
36
  */
35
- actions: Array<{ id: string; label: string; prep?: { title: string; startsAt: string; occurrence: null } }>
37
+ actions: Array<{ id: string; label: string; prep?: { title: string; startsAt: string; occurrence: PrepOccurrence | null } }>
36
38
  /** Epoch ms the source saw it. */
37
39
  createdAt: number
38
40
  /** Epoch ms after which it is no longer worth posting (prep: the meeting start). */
@@ -76,17 +78,20 @@ export function isoWeek(nowMs: number, timezone: string): string {
76
78
  }
77
79
 
78
80
  /** Prep: the next meeting, inside 15 minutes of its start. Expires at the start. */
79
- export function prepCandidates(next: { title?: unknown; time?: unknown } | null | undefined, nowMs: number, timezone: string): PingCandidate[] {
81
+ export function prepCandidates(next: { title?: unknown; time?: unknown; occurrence?: unknown } | null | undefined, nowMs: number, timezone: string): PingCandidate[] {
80
82
  if (!next) return []
83
+ let occurrence: PrepOccurrence | null = null
84
+ if (next.occurrence != null) { try { occurrence = parsePrepOccurrence(next.occurrence) } catch { occurrence = null } }
81
85
  const start = typeof next.time === 'string' ? Date.parse(next.time) : NaN
82
86
  const title = clip(next.title, PING_TEXT.title)
83
87
  if (!Number.isFinite(start) || !title) return []
84
88
  if (start <= nowMs || start - nowMs > PREP_LEAD_MS) return []
85
89
  const day = localClock(start, timezone).day
86
90
  return [{
87
- source: 'prep', key: `prep:${hash(`${title}\n${next.time}`)}:${day}`,
91
+ // An occurrence key is the real event; without one, title and time as before.
92
+ source: 'prep', key: `prep:${occurrence ? hash(occurrence.key) : hash(`${title}\n${next.time}`)}:${day}`,
88
93
  title, line: `${title} at ${clockLabel(localClock(start, timezone).minutes)}. Prep me?`,
89
- links: [], actions: [{ id: 'prep', label: 'Prep me', prep: { title, startsAt: new Date(start).toISOString(), occurrence: null } }, { id: 'dismiss', label: 'Not now' }],
94
+ links: [], actions: [{ id: 'prep', label: 'Prep me', prep: { title, startsAt: new Date(start).toISOString(), occurrence } }, { id: 'dismiss', label: 'Not now' }],
90
95
  createdAt: nowMs, expiresAt: start,
91
96
  }]
92
97
  }