@gotcos/glasses-server 6.99.25 → 6.99.29

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 (44) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +2 -0
  33. package/server/web-control/app.js +738 -102
  34. package/server/web-control/index.html +5 -1
  35. package/server/web-control/panes/cos-chat.css +18 -0
  36. package/server/web-control/panes/cos-chat.js +239 -45
  37. package/server/web-control/panes/meetings.js +20 -9
  38. package/server/web-control/panes/questions.css +39 -1
  39. package/server/web-control/panes/questions.js +368 -51
  40. package/server/web-control/panes/reader-media.js +154 -0
  41. package/server/web-control/panes/sessions.css +2 -0
  42. package/server/web-control/panes/settings.js +34 -2
  43. package/server/web-control/panes/today-actions.css +62 -4
  44. package/server/web-control/panes/today-actions.js +384 -53
@@ -12,6 +12,7 @@ import { join } from 'node:path'
12
12
  import { createInterface } from 'node:readline'
13
13
  import { promisify } from 'node:util'
14
14
  import { codexUserAsk, unwrapPeerMessage } from './session-stream-events.js'
15
+ import { withoutTurnFooter } from './turn-footer.js'
15
16
  import { profileCodexHomeSubdirs } from './codex-homes.js'
16
17
 
17
18
  const execFileAsync = promisify(execFile)
@@ -1831,7 +1832,8 @@ export async function parseAgentSession(
1831
1832
  // frame. It IS the user's prompt (dictated on the glasses), so it is the
1832
1833
  // turn's query and never an injected row; the frame is stripped.
1833
1834
  const peer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? unwrapPeerMessage(text) : null
1834
- const query = peer ?? text
1835
+ // 6.99.29: the verify footer of a glasses, phone or web turn is never the turn's query or title.
1836
+ const query = withoutTurnFooter(peer ?? text)
1835
1837
  collectTurn(query, tail, peer === null && (obj.isMeta === true || obj.isCompactSummary === true))
1836
1838
  if (!firstPrompt) firstPrompt = firstLineTitle(query)
1837
1839
  if (!title) title = firstLineTitle(query)
@@ -33,15 +33,21 @@ import {
33
33
  payloadText,
34
34
  type AgentProvider,
35
35
  } from './agent-session-store.js'
36
+ import type { SessionOutputImage } from './agent-session-output-images.js'
36
37
  import { unwrapPeerMessage } from './session-stream-events.js'
38
+ import { withoutTurnFooter } from './turn-footer.js'
37
39
 
38
40
  export interface SessionTurn {
39
41
  role: 'user' | 'assistant'
40
42
  text: string
41
43
  /** The record's ISO timestamp, when the provider writes one (Cursor does not). */
42
44
  at?: string
45
+ /** Opt-in native-session output previews. No local paths or COS message identities. */
46
+ outputImages?: SessionOutputImage[]
43
47
  }
44
48
 
49
+ export type SessionTurnProjection = (provider: AgentProvider, record: Record<string, unknown>, turn: SessionTurn | null) => SessionTurn | null
50
+
45
51
  /** What a client may ask for in one read. Forty messages is ~10 lens pages of words. */
46
52
  export const SESSION_TURNS_MAX = 40
47
53
 
@@ -103,10 +109,12 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
103
109
  // (a teammate's report, an idle notification, another session's SendMessage),
104
110
  // written as a plain user row with no origin. QA found 20 of those in one session
105
111
  // rendering as "YOU".
106
- const peer = unwrapPeerMessage(text)
112
+ // 6.99.29: the verify footer a glasses, phone or web turn carries is for the agent, not the reader.
113
+ const wrapped = unwrapPeerMessage(text)
114
+ const peer = wrapped === null ? null : withoutTurnFooter(wrapped)
107
115
  if (peer !== null) return (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? shaped('user', peer, at) : null
108
116
  if (obj.isMeta === true || obj.isCompactSummary === true) return null
109
- return shaped('user', text, at)
117
+ return shaped('user', withoutTurnFooter(text), at)
110
118
  }
111
119
  if (provider === 'codex') {
112
120
  if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return null
@@ -117,22 +125,24 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
117
125
  // and an attached-file message reads as the request that was typed.
118
126
  const text = payload.role === 'user' ? codexUserText(payload) : payloadText(payload)
119
127
  if (!text) return null
120
- return shaped(payload.role, text, iso(obj.timestamp))
128
+ return shaped(payload.role, payload.role === 'user' ? withoutTurnFooter(text) : text, iso(obj.timestamp))
121
129
  }
122
130
  if (obj.role !== 'user' && obj.role !== 'assistant') return null
123
131
  const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
124
132
  const text = message ? payloadText(message) : null
125
133
  if (!text) return null
126
- return shaped(obj.role, obj.role === 'user' ? cursorQuery(text) : text, iso(obj.timestamp))
134
+ // QA 6.99.29: a Cursor user row carries the verify footer inside its <user_query> block, like any other turn.
135
+ return shaped(obj.role, obj.role === 'user' ? withoutTurnFooter(cursorQuery(text)) : text, iso(obj.timestamp))
127
136
  }
128
137
 
129
138
  /** The newest `limit` messages among these lines (file order in, file order out). */
130
- export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number): SessionTurn[] {
139
+ export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number, project?: SessionTurnProjection): SessionTurn[] {
131
140
  const out: SessionTurn[] = []
132
141
  for (const line of lines) {
133
142
  const obj = parseJsonLine(line)
134
143
  if (!obj) continue
135
- const turn = sessionTurnFromRecord(provider, obj)
144
+ const parsed = sessionTurnFromRecord(provider, obj)
145
+ const turn = project ? project(provider, obj, parsed) : parsed
136
146
  if (turn) out.push(turn)
137
147
  }
138
148
  return limit > 0 ? out.slice(-limit) : []
@@ -155,6 +165,7 @@ export async function readRecentSessionTurns(
155
165
  path: string,
156
166
  limit: number,
157
167
  windows: readonly number[] = SESSION_TURNS_WINDOWS,
168
+ project?: SessionTurnProjection,
158
169
  ): Promise<RecentTurnsRead> {
159
170
  const want = Math.max(0, Math.min(SESSION_TURNS_MAX, Math.floor(limit)))
160
171
  const { size } = await stat(path)
@@ -177,7 +188,7 @@ export async function readRecentSessionTurns(
177
188
  const lines = buffer.toString('utf-8').split('\n')
178
189
  if (start > 0) lines.shift()
179
190
  // One more than wanted, so `more` can be answered from what was read.
180
- found = recentSessionTurns(provider, lines, want + 1)
191
+ found = recentSessionTurns(provider, lines, want + 1, project)
181
192
  if (found.length > want || start === 0) break
182
193
  }
183
194
  const more = found.length > want || start > 0
@@ -44,6 +44,14 @@
44
44
  // while the Mac sleeps, so on its own it would read a pre-sleep presence as younger
45
45
  // than it is; the wall clock does advance. A wall clock stepped forward reads older
46
46
  // (no hold), never younger.
47
+ //
48
+ // 6.99.26 (Miles 11/07, "Away rule, like phone"): COS Control Web's presence, kept apart from every
49
+ // stamp above. A browser poll `?client=web&hold=1&presenceAgeMs=<int>` (the ms since a touch or a
50
+ // key while the page was in front) records it, and the broker reads it for the away hold ONLY: it
51
+ // never moves the newest poll (/api/health), the 6.59.0 liveness, the `hold=1` poll stamp (which
52
+ // makes a stamped question's client connected without presence), or the wearer presence above. So
53
+ // an open tab nobody touches holds nothing, and at the desk the Mac's own pop-up still shows at once
54
+ // (6.99.13). Same contract and both clocks, forward-only, as the wearer presence.
47
55
 
48
56
  /** 6.60.0: the most a client may report as `presenceAgeMs`. Anything else is no presence. */
49
57
  export const PRESENCE_AGE_MAX_MS = 86_400_000
@@ -53,6 +61,8 @@ let lastLegacyPollAt: number | null = null
53
61
  let lastHoldPollAt: number | null = null
54
62
  let lastPresenceMono: number | null = null
55
63
  let lastPresenceWall: number | null = null
64
+ let lastWebPresenceMono: number | null = null
65
+ let lastWebPresenceWall: number | null = null
56
66
 
57
67
  /** 6.60.0: the monotonic clock. Milliseconds since this process started; paused while the Mac sleeps. */
58
68
  export function monotonicNowMs(): number {
@@ -109,12 +119,35 @@ export function noteQuestionsPoll(at: number, poll: QuestionsPollFacts = {}, mon
109
119
  * and the wall-clock age, so a Mac that slept never makes it read younger.
110
120
  */
111
121
  export function presenceAgeMs(mono: number = monotonicNowMs(), wall: number = Date.now()): number | null {
112
- if (lastPresenceMono === null || !Number.isFinite(mono)) return null
113
- const monoAge = Math.max(0, mono - lastPresenceMono)
114
- const wallAge = lastPresenceWall !== null && Number.isFinite(wall) ? wall - lastPresenceWall : 0
122
+ return ageOn(lastPresenceMono, lastPresenceWall, mono, wall)
123
+ }
124
+
125
+ /** The age of a presence stamped on both clocks: the larger of the two, so a Mac that slept never reads younger. */
126
+ function ageOn(stampMono: number | null, stampWall: number | null, mono: number, wall: number): number | null {
127
+ if (stampMono === null || !Number.isFinite(mono)) return null
128
+ const monoAge = Math.max(0, mono - stampMono)
129
+ const wallAge = stampWall !== null && Number.isFinite(wall) ? wall - stampWall : 0
115
130
  return Math.max(monoAge, wallAge)
116
131
  }
117
132
 
133
+ /**
134
+ * 6.99.26: a COS Control Web poll answered at `at` (server clock) that reported `presenceAgeMs`, the ms
135
+ * since a touch or a key while the page was in front. Anything outside the contract is no presence and
136
+ * records nothing. Forward-only on each clock, like the wearer presence.
137
+ */
138
+ export function noteWebPresence(at: number, presenceAgeMs: number, mono: number = monotonicNowMs()): void {
139
+ if (!Number.isFinite(at) || !validPresenceAgeMs(presenceAgeMs) || !Number.isFinite(mono)) return
140
+ const presenceMono = mono - presenceAgeMs
141
+ const presenceWall = at - presenceAgeMs
142
+ if (lastWebPresenceMono === null || presenceMono > lastWebPresenceMono) lastWebPresenceMono = presenceMono
143
+ if (lastWebPresenceWall === null || presenceWall > lastWebPresenceWall) lastWebPresenceWall = presenceWall
144
+ }
145
+
146
+ /** 6.99.26: how long ago COS Control Web's newest touch or key was, or null when none since this process started. */
147
+ export function webPresenceAgeMs(mono: number = monotonicNowMs(), wall: number = Date.now()): number | null {
148
+ return ageOn(lastWebPresenceMono, lastWebPresenceWall, mono, wall)
149
+ }
150
+
118
151
  /** When the newest authenticated questions poll of any kind arrived, or null since boot (/api/health). */
119
152
  export function lastQuestionsPollAt(): number | null {
120
153
  return lastPollAt
@@ -136,4 +169,6 @@ export function __resetClientLivenessForTests(): void {
136
169
  lastHoldPollAt = null
137
170
  lastPresenceMono = null
138
171
  lastPresenceWall = null
172
+ lastWebPresenceMono = null
173
+ lastWebPresenceWall = null
139
174
  }
@@ -139,3 +139,39 @@ export async function readCodexSubagents(
139
139
  }
140
140
  return out
141
141
  }
142
+
143
+ /**
144
+ * 6.99.26 candidate (the Today summary, lib/session-subagents.ts): each parent's direct children by name, most recently
145
+ * updated first, at most `perParent`. The same read-only snapshot and child lifecycle reader as readCodexSubagents.
146
+ * Name is the child's nickname, else its role; the task is its thread title. Text only goes to the summary's prompt,
147
+ * cleaned there; ids and paths never leave this function. A failed read is an empty map, never a made-up zero.
148
+ */
149
+ export interface CodexSubagentDetail { name: string; task: string; state: ChildState; at: number }
150
+ export async function readCodexSubagentDetails(
151
+ parents: readonly string[], sessionsRoot: string, now = Date.now(), perParent = 6,
152
+ ): Promise<Map<string, CodexSubagentDetail[]>> {
153
+ const ids = [...new Set(parents.filter(id => UUID.test(id)).map(id => id.toLowerCase()))].slice(0, 12)
154
+ const out = new Map<string, CodexSubagentDetail[]>()
155
+ if (!ids.length) return out
156
+ try {
157
+ const home = dirname(sessionsRoot)
158
+ const files = (await readdir(home)).filter(n => /^state_\d+\.sqlite$/.test(n)).sort((a,b) => Number(b.match(/\d+/)![0])-Number(a.match(/\d+/)![0]))
159
+ if (!files.length) return out
160
+ // UUID validation above is the SQL parameter boundary.
161
+ const values = ids.map(id => `'${id}'`).join(',')
162
+ const query = `SELECT e.parent_thread_id AS root, t.rollout_path, t.archived, t.agent_nickname AS nick, t.agent_role AS role, t.title, t.updated_at_ms AS at
163
+ FROM thread_spawn_edges e LEFT JOIN threads t ON t.id=e.child_thread_id
164
+ WHERE e.status='open' AND e.parent_thread_id IN (${values}) ORDER BY t.updated_at_ms DESC LIMIT ${ids.length * perParent * 4}`
165
+ const { stdout } = await exec('/usr/bin/sqlite3', ['-readonly', '-json', join(home,files[0]), query], { timeout: 2000, maxBuffer: 2*1024*1024 })
166
+ const rows = JSON.parse(stdout || '[]') as Array<{root:string;rollout_path?:string;archived?:number;nick?:string;role?:string;title?:string;at?:number}>
167
+ const budget = { bytes: MAX_SNAPSHOT_BYTES }
168
+ for (const row of rows) {
169
+ const list = out.get(row.root) ?? []
170
+ if (list.length >= perParent) continue
171
+ const state: ChildState = row.archived === 1 ? 'completed' : typeof row.rollout_path === 'string' ? await childState(row.rollout_path, sessionsRoot, now, budget) : 'unknown'
172
+ list.push({ name: String(row.nick || row.role || 'agent').slice(0, 60), task: String(row.title || '').slice(0, 160), state, at: Number(row.at) || 0 })
173
+ out.set(row.root, list)
174
+ }
175
+ } catch { return new Map() }
176
+ return out
177
+ }