@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
@@ -166,7 +166,15 @@ export interface MediaRecord {
166
166
  textPath?: string
167
167
  derivativePaths?: string[]
168
168
  bytes: number
169
+ /** SHA-256 of the bytes stored NOW. The x265 pass replaces it with the compressed file's hash. */
169
170
  sha256: string
171
+ /**
172
+ * QA 6.99.29: SHA-256 of the bytes as ingested. Set once at publication and never changed by
173
+ * compression, so a queued turn sealed to (id, original hash) still matches after the x265 pass
174
+ * replaced the bytes under the same id. Absent only on a legacy record that was already compressed
175
+ * before this field existed (its compressed hash is then its stable identity: compression never reruns).
176
+ */
177
+ originalSha256?: string
170
178
  lifecycle: MediaLifecycle
171
179
  /** Set once the background x265 pass replaced the stored original with a
172
180
  * smaller file. Kept so the size drop is explainable and never retried. */
@@ -281,6 +289,8 @@ function sanitizeVideoCompression(raw: unknown): VideoCompressionRecord | null {
281
289
  return { label: r.label.slice(0, 64), originalBytes: r.originalBytes, bytes: r.bytes, atMs: r.atMs }
282
290
  }
283
291
 
292
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/
293
+
284
294
  function sanitizeRecord(raw: unknown): MediaRecord | null {
285
295
  if (!raw || typeof raw !== 'object') return null
286
296
  const r = raw as Record<string, unknown>
@@ -309,6 +319,12 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
309
319
  ? r.derivativePaths.filter(isOwnedPath).slice(0, VIDEO_SUMMARY_FRAMES_MAX)
310
320
  : undefined
311
321
  const videoCompression = sanitizeVideoCompression(r.videoCompression)
322
+ const sha256 = typeof r.sha256 === 'string' ? r.sha256 : ''
323
+ // A record never compressed still holds its ingested bytes, so its current hash IS the original (backfills
324
+ // records written before originalSha256 existed). A compressed legacy record has no original to recover.
325
+ const originalSha256 = typeof r.originalSha256 === 'string' && SHA256_HEX_RE.test(r.originalSha256)
326
+ ? r.originalSha256
327
+ : !videoCompression && SHA256_HEX_RE.test(sha256) ? sha256 : undefined
312
328
  return {
313
329
  ref,
314
330
  storagePath: r.storagePath,
@@ -316,7 +332,8 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
316
332
  ...(textPath ? { textPath } : {}),
317
333
  ...(derivativePaths?.length ? { derivativePaths } : {}),
318
334
  bytes: typeof r.bytes === 'number' && r.bytes >= 0 ? r.bytes : 0,
319
- sha256: typeof r.sha256 === 'string' ? r.sha256 : '',
335
+ sha256,
336
+ ...(originalSha256 ? { originalSha256 } : {}),
320
337
  lifecycle,
321
338
  ...(videoCompression ? { videoCompression } : {}),
322
339
  contentRemoved: r.contentRemoved === true,
@@ -690,6 +707,7 @@ export class MediaStore {
690
707
  ...(derivativePaths.length ? { derivativePaths } : {}),
691
708
  bytes: prepared.originalBytes,
692
709
  sha256,
710
+ originalSha256: sha256,
693
711
  lifecycle: 'staged',
694
712
  ...(input.sessionId ? { sessionId: input.sessionId } : {}),
695
713
  ...(input.videoUploadId ? { videoUploadId: input.videoUploadId } : {}),
@@ -857,12 +875,14 @@ export class MediaStore {
857
875
  throw err
858
876
  }
859
877
 
878
+ const normalizedSha256 = createHash('sha256').update(normalized.normalized).digest('hex')
860
879
  const record: MediaRecord = {
861
880
  ref,
862
881
  storagePath: join('assets', id, 'original-normalized.jpg'),
863
882
  thumbPath: join('assets', id, 'thumb.jpg'),
864
883
  bytes: normalized.normalized.length,
865
- sha256: createHash('sha256').update(normalized.normalized).digest('hex'),
884
+ sha256: normalizedSha256,
885
+ originalSha256: normalizedSha256,
866
886
  lifecycle: 'staged',
867
887
  ...(input.sessionId ? { sessionId: input.sessionId } : {}),
868
888
  createdAtMs: now,
@@ -1109,6 +1129,25 @@ export class MediaStore {
1109
1129
  return { record: rec, path: content.path }
1110
1130
  }
1111
1131
 
1132
+ /**
1133
+ * QA 6.99.29: the content identity a queued turn's files are sealed to: the hash of the bytes as
1134
+ * ingested (`originalSha256`), stable across the x265 pass that rewrites a video under the same id.
1135
+ * The stored bytes are still hashed, and must be what the index says is stored (the compressed or
1136
+ * the original file): anything else returns the hash of what is really there, so a file swapped
1137
+ * under the id no longer matches its seal. Throws as resolveUsable does.
1138
+ */
1139
+ async contentIdentitySha256(id: string, clientQueueItemId?: string): Promise<string> {
1140
+ const { path } = this.resolveUsable(id, clientQueueItemId)
1141
+ const actual = await sha256OfFile(path)
1142
+ // Read the record AFTER hashing: a compression that swapped the bytes meanwhile has updated it.
1143
+ const rec = this.getRecord(id)
1144
+ if (!rec) throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
1145
+ const original = rec.originalSha256 && SHA256_HEX_RE.test(rec.originalSha256) ? rec.originalSha256 : null
1146
+ const current = SHA256_HEX_RE.test(rec.sha256) ? rec.sha256 : null
1147
+ if (actual === current || actual === original) return original ?? current ?? actual
1148
+ return actual
1149
+ }
1150
+
1112
1151
  /** Resolve private model derivatives after lifecycle/ownership validation. */
1113
1152
  resolveModelDerivatives(
1114
1153
  id: string,
@@ -91,6 +91,24 @@
91
91
  // while Miles is at the desk is still `{}` at once (`desk_active`); every path still ends.
92
92
  // Every request answered `{}` is logged with why, the presence age and the window.
93
93
  //
94
+ // COS Control Web joins the away hold, never the liveness (6.99.26, Miles 11/07: "Away rule, like
95
+ // phone"; he answers from wherever he is, sometimes the Mac itself through Screens from the phone).
96
+ // A browser poll `?client=web&hold=1&presenceAgeMs=<int>`, the ms since a touch or a key while the
97
+ // page was in front, is its own presence (`webPresenceAgeMs`, lib/client-liveness), read here in
98
+ // exactly two places: it can make a request away (so held up to the away hold while the desk is idle,
99
+ // and answerable from the web), and it keeps an away-held request through the re-check while it is
100
+ // recent. It never makes a client live: not the 6.59.0 liveness, not a `hold=1` client, not a
101
+ // connected client for a stamped question. So with the away hold off, a hook that waits no longer than
102
+ // 120 s, a stale touch (past the same recent window), or a desk in use, the web holds nothing and the
103
+ // Mac's own pop-up shows at once, as Miles chose for an open tab in 6.99.13.
104
+ // QA round 1 (6.99.26): the web touch admits only what nothing else would (`webOnly`), and changes
105
+ // nothing about a request a glasses or phone client admits: same deadline, flag and hand-back as 6.99.25,
106
+ // and it never keeps one through the re-check. A web-only STAMPED question needs the desk idle at admit
107
+ // (only Claude Desktop is proven to keep its own prompt up while the hook waits, D1) and goes back to the
108
+ // Mac the moment the desk is used, like any away-held request. A touch from the Mac itself never counts
109
+ // (routes/permission-broker.ts requestFromThisMac), and `COS_WEB_AWAY_HOLD=0` turns the web part off
110
+ // alone (`webAwayHoldEnabled`).
111
+ //
94
112
  // NEVER past the hook's own wait. The hold is clamped to `holdCeilingS`: the PermissionRequest
95
113
  // `timeout` in the Claude settings file (read, not assumed) and the wait the hook stamped on
96
114
  // THIS request (`hookWaitS`, 6.60.0 script), whichever is less, minus 10 s; settings that
@@ -129,6 +147,16 @@ import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signa
129
147
 
130
148
  export type BrokerMode = 'off' | 'questions' | 'all'
131
149
 
150
+ /**
151
+ * 6.99.26 (QA round 1): `COS_WEB_AWAY_HOLD`: `0`/`false`/`off` turns off COS Control Web's part of the
152
+ * away hold alone, so it can be rolled back without touching the glasses and phone. Default on; read per
153
+ * request.
154
+ */
155
+ export function webAwayHoldEnabled(env: NodeJS.ProcessEnv): boolean {
156
+ const raw = (env.COS_WEB_AWAY_HOLD ?? '').trim().toLowerCase()
157
+ return !(raw === '0' || raw === 'false' || raw === 'off')
158
+ }
159
+
132
160
  /**
133
161
  * `COS_PERMISSION_BROKER`: `0`/`false`/`off` answers `{}` to everything (the kill switch,
134
162
  * no reinstall needed); `questions` brokers AskUserQuestion only; unset or anything else
@@ -716,6 +744,9 @@ export function approvalCard(toolName: string, toolInput: Record<string, unknown
716
744
  // ---------------------------------------------------------------------------
717
745
 
718
746
  export type BrokerItemKind = 'question' | 'approval'
747
+ /** 6.99.27: the door an answer came through: the Even app with the API token, or COS Control Web's browser session. */
748
+ export type AnswerSurface = 'app' | 'web'
749
+
719
750
  export type BrokerResolution = 'answered' | 'handed_to_desk' | 'expired' | 'hook_gone' | 'retracted' | 'drained' | 'no_client'
720
751
  export type BrokerFastPath =
721
752
  | 'broker_off' | 'draining' | 'malformed' | 'cursor' | 'approvals_off' | 'unsupported_tool'
@@ -771,6 +802,14 @@ export interface PermissionBrokerDeps {
771
802
  * lib/client-liveness). Absent: null.
772
803
  */
773
804
  presenceAgeMs?(): number | null
805
+ /**
806
+ * 6.99.26: how long ago COS Control Web's newest touch or key was (`webPresenceAgeMs`,
807
+ * lib/client-liveness), or null when none since boot. Read for the away hold only, never as a live
808
+ * client. Absent: null (the web holds nothing).
809
+ */
810
+ webPresenceAgeMs?(): number | null
811
+ /** 6.99.26 (QA round 1): the web part's own switch (`webAwayHoldEnabled`). Absent: on. */
812
+ webAwayHold?(): boolean
774
813
  /**
775
814
  * 6.60.0: how recent that presence must be for the away hold, in ms
776
815
  * (`permissionBrokerAwayRecentClientMs`), never below the 30 s live window. Absent: 30 s.
@@ -810,6 +849,10 @@ export interface HoldFacts {
810
849
  recentWindowS: number
811
850
  /** 6.60.0: why this request is not away-held, or null when it is. */
812
851
  notAwayReason: NotAwayReason | null
852
+ /** 6.99.26: held for COS Control Web's touch alone; absent for every other request. */
853
+ webOnly?: true
854
+ /** 6.99.26: the age of that touch, present only with `webOnly`. */
855
+ webPresenceAgeMs?: number
813
856
  }
814
857
 
815
858
  /**
@@ -841,6 +884,8 @@ export interface ParkRequest {
841
884
  awayHold?: boolean
842
885
  askHold?: boolean
843
886
  lensVisible?: boolean
887
+ /** 6.99.26: admitted for COS Control Web's recent touch alone (see THE AWAY HOLD). */
888
+ webOnly?: boolean
844
889
  hold?: HoldFacts
845
890
  }
846
891
 
@@ -874,6 +919,8 @@ interface BrokerItem {
874
919
  awayHold: boolean
875
920
  askHold: boolean
876
921
  lensVisible: boolean
922
+ /** 6.99.26: admitted for the web touch alone: kept only while a touch is recent, and the desk hands it back. */
923
+ webOnly: boolean
877
924
  toolUseId: string | null
878
925
  questionFingerprint: string
879
926
  /**
@@ -1024,6 +1071,13 @@ function sessionMovedPast(item: Pick<BrokerItem, 'kind' | 'fingerprint' | 'toolN
1024
1071
  return false
1025
1072
  }
1026
1073
 
1074
+ /** A presence age from a dep: a finite number of ms at or above 0, else null (absent, throwing or nonsense). */
1075
+ function readAgeMs(read: () => number | null): number | null {
1076
+ let ageMs: number | null = null
1077
+ try { ageMs = read() } catch { ageMs = null }
1078
+ return typeof ageMs === 'number' && Number.isFinite(ageMs) && ageMs >= 0 ? ageMs : null
1079
+ }
1080
+
1027
1081
  export class PermissionBroker {
1028
1082
  private readonly deps: PermissionBrokerDeps
1029
1083
  private readonly items = new Map<string, BrokerItem>()
@@ -1058,7 +1112,7 @@ export class PermissionBroker {
1058
1112
  }
1059
1113
 
1060
1114
  /** A request answered `{}` without being parked, counted by why. */
1061
- noteFastPath(reason: BrokerFastPath, context: { notAway?: NotAwayReason; presence?: { ageMs: number | null; windowMs: number }; clientLive?: boolean } = {}): { ok: false; reason: BrokerFastPath } {
1115
+ noteFastPath(reason: BrokerFastPath, context: { notAway?: NotAwayReason; presence?: { ageMs: number | null; windowMs: number; webAgeMs?: number | null }; clientLive?: boolean } = {}): { ok: false; reason: BrokerFastPath } {
1062
1116
  const key = camelKey(reason)
1063
1117
  this.fastPaths[key] = (this.fastPaths[key] ?? 0) + 1
1064
1118
  this.lastFastPath = reason
@@ -1068,7 +1122,9 @@ export class PermissionBroker {
1068
1122
  let presence = context.presence
1069
1123
  if (!presence) { try { presence = this.presence(this.deps.now()) } catch { presence = undefined } }
1070
1124
  const age = presence?.ageMs === null || presence?.ageMs === undefined ? 'null' : String(Math.round(presence.ageMs))
1071
- this.log(`fast path ${reason}${context.notAway ? ` not_away=${context.notAway}` : ''} presenceAgeMs=${age} windowMs=${presence?.windowMs ?? 'null'}${context.clientLive === undefined ? '' : ` clientLive=${context.clientLive}`}`)
1125
+ // 6.99.26: the web's touch age only once the web has reported one, so every older line reads as before.
1126
+ const web = typeof presence?.webAgeMs === 'number' ? ` webPresenceAgeMs=${Math.round(presence.webAgeMs)}` : ''
1127
+ this.log(`fast path ${reason}${context.notAway ? ` not_away=${context.notAway}` : ''} presenceAgeMs=${age} windowMs=${presence?.windowMs ?? 'null'}${web}${context.clientLive === undefined ? '' : ` clientLive=${context.clientLive}`}`)
1072
1128
  return { ok: false, reason }
1073
1129
  }
1074
1130
 
@@ -1110,16 +1166,19 @@ export class PermissionBroker {
1110
1166
  * - `holdLive`: a `hold=1` client polled within the live window AND its presence is
1111
1167
  * recent, so it counts as a live client the way an older client's poll does. A
1112
1168
  * connected lens nobody touches never counts (QA round 1, Skeptic W1).
1169
+ * - 6.99.26: `webAgeMs` and `webRecent`, COS Control Web's touch on its own: for the away
1170
+ * hold only, so it is never part of `recent` or `holdLive`.
1113
1171
  */
1114
- private presence(now: number): { ageMs: number | null; recent: boolean; holdLive: boolean; windowMs: number } {
1115
- let ageMs: number | null = null
1116
- try { ageMs = this.deps.presenceAgeMs?.() ?? null } catch { ageMs = null }
1117
- if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0) ageMs = null
1172
+ private presence(now: number): { ageMs: number | null; recent: boolean; holdLive: boolean; windowMs: number; webAgeMs: number | null; webRecent: boolean } {
1173
+ const ageMs = readAgeMs(() => this.deps.presenceAgeMs?.() ?? null)
1174
+ let webOn = true
1175
+ try { webOn = this.deps.webAwayHold?.() !== false } catch { webOn = false }
1176
+ const webAgeMs = webOn ? readAgeMs(() => this.deps.webPresenceAgeMs?.() ?? null) : null
1118
1177
  const windowMs = this.recentWindowMs()
1119
1178
  const recent = ageMs !== null && ageMs <= windowMs
1120
1179
  let holdPollAt: number | null = null
1121
1180
  try { holdPollAt = this.deps.lastHoldPollAt?.() ?? null } catch { holdPollAt = null }
1122
- return { ageMs, recent, holdLive: recent && this.polledWithin(holdPollAt, now), windowMs }
1181
+ return { ageMs, recent, holdLive: recent && this.polledWithin(holdPollAt, now), windowMs, webAgeMs, webRecent: webAgeMs !== null && webAgeMs <= windowMs }
1123
1182
  }
1124
1183
 
1125
1184
  /** 6.60.0: the installed hook timeout, or null when absent or unreadable. */
@@ -1207,13 +1266,19 @@ export class PermissionBroker {
1207
1266
  // window (Miles, 19:54; QA round 1).
1208
1267
  const switchOn = this.awayHoldMs() > 0
1209
1268
  const ceilingAllowsAway = this.holdMs(facts.hookWaitS, false).ceilingAllowsAway
1210
- const away = switchOn && ceilingAllowsAway && (askHold || presence.recent)
1269
+ // 6.60.0 to 6.99.25, unchanged: what a live client and a wearer's presence allow.
1270
+ const deviceAway = switchOn && ceilingAllowsAway && (askHold || presence.recent)
1271
+ const deviceAdmits = askHold ? clientLive || presence.recent : clientLive || deviceAway
1272
+ // 6.99.26 (QA round 1): a recent touch on COS Control Web admits only what nothing else would, for the away
1273
+ // hold alone. A request a glasses or phone client admits keeps its 6.99.25 deadline, flag and hand-back.
1274
+ const webOnly = !deviceAdmits && switchOn && ceilingAllowsAway && presence.webRecent
1275
+ const away = deviceAway || webOnly
1211
1276
  const notAwayReason: NotAwayReason | null = away ? null
1212
1277
  : !switchOn ? 'hold_off'
1213
1278
  : !ceilingAllowsAway ? 'ceiling_at_base'
1214
1279
  : presence.ageMs === null ? 'no_hold_client'
1215
1280
  : 'stale_presence'
1216
- if (askHold && !clientLive && !presence.recent) return this.noteFastPath('no_client', { presence, clientLive })
1281
+ if (askHold && !deviceAdmits && !webOnly) return this.noteFastPath('no_client', { presence, clientLive })
1217
1282
  const hold = this.holdMs(facts.hookWaitS, away)
1218
1283
  // With it, a request is held whether or not a client is polling right now, so a lens or
1219
1284
  // phone that comes back can still answer it. Without it, no client is `{}`, as in 6.59.0.
@@ -1228,6 +1293,10 @@ export class PermissionBroker {
1228
1293
  try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null }
1229
1294
  if (!askHold && idle === null) return this.noteFastPath('desk_unknown')
1230
1295
  if (!askHold && idle! < this.deskIdleThreshold()) return this.noteFastPath('desk_active', { notAway: 'desk_active', presence, clientLive })
1296
+ // 6.99.26 (QA round 1): a stamped question the web touch alone admits needs the desk idle now. Only Claude
1297
+ // Desktop is proven to keep its own prompt up while the hook waits (D1); at the desk, the Mac answers.
1298
+ if (webOnly && idle === null) return this.noteFastPath('desk_unknown')
1299
+ if (webOnly && idle! < this.deskIdleThreshold()) return this.noteFastPath('desk_active', { notAway: 'desk_active', presence, clientLive })
1231
1300
  const mode = this.deps.mode()
1232
1301
  if (mode === 'off') return this.noteFastPath('broker_off')
1233
1302
  if (!this.deps.admissionsOpen()) return this.noteFastPath('draining')
@@ -1258,8 +1327,9 @@ export class PermissionBroker {
1258
1327
  presenceAgeMs: presence.ageMs === null ? null : Math.round(presence.ageMs),
1259
1328
  recentWindowS: Math.round(presence.windowMs / 1000),
1260
1329
  notAwayReason,
1330
+ ...(webOnly ? { webOnly: true as const, webPresenceAgeMs: Math.round(presence.webAgeMs ?? 0) } : {}),
1261
1331
  }
1262
- return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || (idle !== null && idle >= this.deskIdleThreshold()), hold: holdFacts } }
1332
+ return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || (idle !== null && idle >= this.deskIdleThreshold()), ...(webOnly ? { webOnly: true } : {}), hold: holdFacts } }
1263
1333
  }
1264
1334
 
1265
1335
  /** Hold the hook's response until exactly one resolution. Returns the item id. */
@@ -1289,6 +1359,7 @@ export class PermissionBroker {
1289
1359
  channel,
1290
1360
  timer: null,
1291
1361
  awayHold: request.awayHold === true,
1362
+ webOnly: request.webOnly === true,
1292
1363
  askHold: request.askHold === true,
1293
1364
  lensVisible: request.lensVisible !== false,
1294
1365
  toolUseId: facts.toolUseId ?? null,
@@ -1438,7 +1509,7 @@ export class PermissionBroker {
1438
1509
  * clientAnswerId) replays its 200. `handBack` wins over any answer fields in the same body:
1439
1510
  * it is the safe direction, since it only shows the dialog that would have shown anyway.
1440
1511
  */
1441
- answer(id: string, body: unknown): AnswerResult {
1512
+ answer(id: string, body: unknown, by: AnswerSurface = 'app'): AnswerResult {
1442
1513
  const item = BROKER_ID_RE.test(id) ? this.items.get(id) : undefined
1443
1514
  if (!item) return { status: 404, body: { error: 'not_found' } }
1444
1515
  const input = isRecord(body) ? body : {}
@@ -1490,7 +1561,9 @@ export class PermissionBroker {
1490
1561
  item.timer = null
1491
1562
  this.counters.answered++
1492
1563
  try { this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint) } catch { /* rows are advisory */ }
1493
- if (item.kind === 'question') this.log(`glasses_won ${item.id}`)
1564
+ // 6.99.27: who answered, by the door it came through: the Even app (lens or phone, the API token) or COS Control
1565
+ // Web (the browser session). A web answer once logged as glasses_won.
1566
+ if (item.kind === 'question') this.log(`${by === 'web' ? 'web_won' : 'glasses_won'} ${item.id}`)
1494
1567
  this.log(`answered ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
1495
1568
  this.prune()
1496
1569
  return { status: 200, body: { ok: true, id: item.id, kind: item.kind, ...chosen } }
@@ -1631,11 +1704,14 @@ export class PermissionBroker {
1631
1704
  // 6.60.0: an item held by the away hold outlives the client going quiet, but only while
1632
1705
  // the wearer's latest presence is still within the window (QA round 1, Skeptic W2: a
1633
1706
  // single glance must not hold a question 10 more minutes). The desk below still hands
1634
- // it back.
1635
- this.settleAll('no_client', item => !item.askHold && (!item.awayHold || !presence.recent))
1707
+ // it back. 6.99.26 (QA round 1): a recent web touch keeps only what it admitted; a glasses or
1708
+ // phone hold reads exactly as in 6.99.25.
1709
+ this.settleAll('no_client', item => item.webOnly
1710
+ ? !(presence.recent || presence.webRecent)
1711
+ : !item.askHold && (!item.awayHold || !presence.recent))
1636
1712
  if (this.pendingCount() === 0) return
1637
1713
  }
1638
- if (![...this.items.values()].some(item => item.state === 'pending' && (!item.askHold || !item.lensVisible))) return
1714
+ if (![...this.items.values()].some(item => item.state === 'pending' && (!item.askHold || !item.lensVisible || item.webOnly))) return
1639
1715
  if (this.pollInFlight) return
1640
1716
  this.pollInFlight = true
1641
1717
  let idle: number | null = null
@@ -1643,7 +1719,7 @@ export class PermissionBroker {
1643
1719
  if (idle === null) {
1644
1720
  this.counters.deskUnreadable++
1645
1721
  this.deskUnreadableStreak++
1646
- if (this.deskUnreadableStreak >= 2) this.settleAll('handed_to_desk', item => !item.askHold)
1722
+ if (this.deskUnreadableStreak >= 2) this.settleAll('handed_to_desk', item => !item.askHold || item.webOnly)
1647
1723
  return
1648
1724
  }
1649
1725
  this.deskUnreadableStreak = 0
@@ -1656,7 +1732,8 @@ export class PermissionBroker {
1656
1732
  }
1657
1733
  return
1658
1734
  }
1659
- this.settleAll('handed_to_desk', item => !item.askHold)
1735
+ // 6.99.26 (QA round 1): a web-only stamped question goes back to the Mac as any away-held request does.
1736
+ this.settleAll('handed_to_desk', item => !item.askHold || item.webOnly)
1660
1737
  }
1661
1738
 
1662
1739
  private settleAll(resolution: 'handed_to_desk' | 'drained' | 'no_client', only: (item: BrokerItem) => boolean = () => true): void {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { cancelVoidsOpenTurn, type ThreadCancel } from './session-cancel.js'
19
19
  import { unwrapPeerMessage } from './session-stream-events.js'
20
+ import { stripG2TurnFooter, withoutTurnFooter } from './turn-footer.js'
20
21
  import type { TurnAttemptLookup } from './agent-session-binding-registry.js'
21
22
  import type { QueuedThreadTurn } from './thread-turn-queue.js'
22
23
 
@@ -215,13 +216,29 @@ function textOf(content: unknown): string | null {
215
216
  return parts.length > 0 ? parts.join('\n') : null
216
217
  }
217
218
 
218
- /** The words, as typed or out of Claude Code's peer wrapper, equal the prompt exactly. */
219
+ /**
220
+ * The words, as typed or out of Claude Code's peer wrapper, equal the prompt exactly.
221
+ * 6.99.29: a glasses, phone or web turn reaches the session with one trailing verify footer line
222
+ * (lib/g2-turn-provenance.ts) that the queue row and the turns route never held, so that one
223
+ * line is taken off the transcript's text before the exact comparison. Nothing else is.
224
+ */
219
225
  function sameWords(text: string | null, want: string): boolean {
220
226
  if (text === null) return false
221
227
  if (text.trim() === want) return true
228
+ if (footedWords(text, want)) return true
222
229
  return unwrapPeerMessage(text) === want
223
230
  }
224
231
 
232
+ /** A row that carries a verify footer, as typed or inside the peer wrapper, whose words are `want`. */
233
+ function footedWords(text: string, want: string): boolean {
234
+ for (const candidate of [text, unwrapPeerMessage(text)]) {
235
+ if (typeof candidate !== 'string') continue
236
+ const stripped = stripG2TurnFooter(candidate)
237
+ if (stripped.turnId !== null && stripped.text.trim() === want) return true
238
+ }
239
+ return false
240
+ }
241
+
225
242
  /**
226
243
  * Does this transcript tail show the session took exactly these words, at or after
227
244
  * `sinceMs`? WRAPPER-AWARE AND EXACT: the whole prompt, trimmed, either as typed or inside
@@ -286,7 +303,7 @@ export function makeDeliveryEvidence(deps: DeliveryEvidenceDeps): (turn: QueuedT
286
303
  let codexQueueMatch = false
287
304
  if (!transcriptMatch && turn.provider === 'codex' && typeof deps.codexQueueTexts === 'function') {
288
305
  const texts = await deps.codexQueueTexts(turn.threadId)
289
- codexQueueMatch = texts.some(text => typeof text === 'string' && text.trim() === turn.prompt.trim())
306
+ codexQueueMatch = texts.some(text => typeof text === 'string' && withoutTurnFooter(text).trim() === turn.prompt.trim())
290
307
  }
291
308
  return reconcileVerdict({ row: turn, ledgerOutcome, transcriptMatch, codexQueueMatch, attempt })
292
309
  } catch {
@@ -39,6 +39,7 @@ import { atomicWriteFileSync } from './atomic-fs.js'
39
39
  import { dataPath } from './data-dir.js'
40
40
  import { isValidNativeThreadId } from './native-thread-id.js'
41
41
  import { unwrapPeerMessage } from './session-stream-events.js'
42
+ import { stripG2TurnFooter, withoutTurnFooter } from './turn-footer.js'
42
43
 
43
44
  /**
44
45
  * How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
@@ -263,14 +264,27 @@ export function normalizePromptWords(text: unknown): string {
263
264
  export function isDeliveredPrompt(prompt: unknown, delivered: string, options: { verbatim?: boolean } = {}): boolean {
264
265
  if (options.verbatim === true) {
265
266
  // 6.62.0: Codex's queued turn arrives as its own text. No wrapper to take off.
267
+ // 6.99.29: a glasses, phone or web turn arrives with one trailing verify footer line the
268
+ // route's own `prompt` never held; that line, and only that line, comes off first.
266
269
  const want = typeof delivered === 'string' ? delivered.trim() : ''
267
- return want.length > 0 && typeof prompt === 'string' && prompt.trim() === want
270
+ return want.length > 0 && typeof prompt === 'string' && withoutTurnFooter(prompt).trim() === want
268
271
  }
269
272
  const want = normalizePromptWords(delivered)
270
273
  if (want.length === 0 || typeof prompt !== 'string') return false
274
+ if (footedPromptIs(prompt, want)) return true
271
275
  return normalizePromptWords(unwrapPeerMessage(prompt) ?? prompt) === want
272
276
  }
273
277
 
278
+ /** 6.99.29: a prompt that carries a verify footer, as typed or inside the peer wrapper, whose words are `want`. */
279
+ function footedPromptIs(prompt: string, want: string): boolean {
280
+ for (const candidate of [prompt, unwrapPeerMessage(prompt)]) {
281
+ if (typeof candidate !== 'string') continue
282
+ const stripped = stripG2TurnFooter(candidate)
283
+ if (stripped.turnId !== null && normalizePromptWords(stripped.text) === want) return true
284
+ }
285
+ return false
286
+ }
287
+
274
288
  /**
275
289
  * A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
276
290
  * the session's marker. The FIRST such prompt after the send spends the pending re-arm: when
@@ -17,6 +17,8 @@
17
17
  // carrying its real name in `target`, so a provider adding a tool tomorrow degrades to
18
18
  // a readable line instead of an unrenderable one.
19
19
 
20
+ import { withoutTurnFooter } from './turn-footer.js'
21
+
20
22
  /** Closed set. A provider tool name that is not in the table maps to `other`. */
21
23
  export type SessionStreamVerb = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'task' | 'other'
22
24
 
@@ -557,6 +559,8 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
557
559
  let text = parts.join(' ')
558
560
  // A live Continue's words, out of the peer wrapper (see `unwrapPeerMessage`).
559
561
  text = unwrapPeerMessage(text) ?? text
562
+ // 6.99.29: the one trailing verify footer line of a glasses, phone or web turn is for the agent.
563
+ text = withoutTurnFooter(text)
560
564
  // Wrapper blocks out, whole. A partial strip would leave the tag names on the lens.
561
565
  text = text.replace(/<(system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args)>[\s\S]*?<\/\1>/g, ' ')
562
566
  const flat = oneLine(text, PROMPT_MAX_CHARS)
@@ -0,0 +1,155 @@
1
+ /**
2
+ * 6.99.26 candidate: who a session has working for it, for the Today summary (lib/today-brief.ts). Read-only display
3
+ * evidence: never imported by an attach, queue or write gate.
4
+ *
5
+ * Claude Code: nothing on the server read sub-agent NAMES before this (the hooks give counts only:
6
+ * session-work-observation.ts). This reads the session's own transcript TAIL (the last 768 KB) and follows each Agent
7
+ * or Task tool call:
8
+ * - the call gives the name (`name`, else `subagent_type`) and the one-line task (`description`);
9
+ * - its tool result says how it started: `async_launched` and `teammate_spawned` are running, any other result is a
10
+ * finished foreground agent, an error result is failed;
11
+ * - a `<task-notification>` naming the call's tool-use id ends a background agent (completed, failed or killed);
12
+ * - a `<teammate-message teammate_id="name">` moves a teammate: an `idle_notification` makes it idle, a shutdown
13
+ * approval makes it done, anything else is it reporting; a SendMessage to it from the session makes it running again.
14
+ * Agents spawned before the tail are not seen; `partial` says the read started mid-file.
15
+ *
16
+ * Codex: codex-subagent-activity.ts readCodexSubagentDetails (the same spawn-edge table and child lifecycle reader the
17
+ * list already uses for its counts).
18
+ *
19
+ * Opens are non-blocking and refuse symlinks (O_NONBLOCK | O_NOFOLLOW, then a regular-file check); a read is memoized
20
+ * on mtime and size like readSessionActivity, and the memo holds only names, tasks, states and times.
21
+ */
22
+ import { constants } from 'node:fs'
23
+ import { open } from 'node:fs/promises'
24
+
25
+ export type SubagentStatus = 'running' | 'idle' | 'done' | 'failed' | 'unknown'
26
+ export interface SubagentRow { name: string; task: string; status: SubagentStatus; at: number }
27
+ export interface SessionSubagents { rows: SubagentRow[]; running: number; idle: number; done: number; failed: number; total: number; partial: boolean }
28
+
29
+ export const SUBAGENTS_PER_SESSION = 6
30
+ export const CLAUDE_SUBAGENT_TAIL_BYTES = 768 * 1024
31
+ const MAX_TRACKED = 256
32
+ const MEMO_MAX = 64
33
+ const NAME_MAX = 60
34
+ const TASK_MAX = 160
35
+
36
+ const clip = (v: unknown, max: number) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
37
+ const AGENT_TOOLS = new Set(['Agent', 'Task'])
38
+
39
+ interface Tracked extends SubagentRow { team: boolean }
40
+
41
+ /** The plain text of a user line or a queued message: a string, or its text blocks joined. */
42
+ function textOf(content: unknown): string {
43
+ if (typeof content === 'string') return content
44
+ if (!Array.isArray(content)) return ''
45
+ return content.map(b => (b && typeof b === 'object' && (b as any).type === 'text' && typeof (b as any).text === 'string' ? (b as any).text : '')).join('\n')
46
+ }
47
+
48
+ const NOTIFICATION = /<task-notification>[\s\S]*?<tool-use-id>([^<\s]{1,120})<\/tool-use-id>[\s\S]*?<status>([a-z_]{1,24})<\/status>/g
49
+ const TEAMMATE = /<teammate-message\s+teammate_id="([^"]{1,80})"[^>]*>([\s\S]{0,600})/g
50
+
51
+ /**
52
+ * Sub-agents from a transcript's tail, most recently moved first, at most `limit` rows, with counts over every one
53
+ * the tail shows. `startsMidFile` drops the first (partial) line.
54
+ */
55
+ export function parseClaudeSubagents(text: string, startsMidFile = false, limit = SUBAGENTS_PER_SESSION): SessionSubagents {
56
+ // A read that starts mid-file has a cut first line; it fails to parse and is skipped like any bad line. It is not
57
+ // dropped blindly: a tail that starts exactly on a line boundary would lose a whole event. `partial` says so either way.
58
+ const lines = text.split('\n')
59
+ const byTool = new Map<string, Tracked>()
60
+ const byName = new Map<string, Tracked>()
61
+ const move = (t: Tracked, status: SubagentStatus | null, at: number) => {
62
+ if (at < t.at) return
63
+ t.at = at
64
+ if (status) t.status = status
65
+ }
66
+ for (const line of lines) {
67
+ if (!line || line.length > 4_000_000) continue
68
+ let row: any
69
+ try { row = JSON.parse(line) } catch { continue }
70
+ const at = Date.parse(row?.timestamp) || 0
71
+ if (row?.type === 'assistant' && Array.isArray(row.message?.content)) {
72
+ for (const b of row.message.content) {
73
+ if (b?.type !== 'tool_use' || typeof b.id !== 'string') continue
74
+ const input = b.input && typeof b.input === 'object' ? b.input : {}
75
+ if (AGENT_TOOLS.has(b.name)) {
76
+ if (byTool.size >= MAX_TRACKED) continue
77
+ const name = clip(input.name, NAME_MAX) || clip(input.subagent_type, NAME_MAX) || 'agent'
78
+ const t: Tracked = { name, task: clip(input.description, TASK_MAX), status: 'running', at, team: typeof input.name === 'string' && !!input.name.trim() }
79
+ byTool.set(b.id, t)
80
+ if (t.team) byName.set(name, t)
81
+ } else if (b.name === 'SendMessage' && typeof input.to === 'string') {
82
+ const t = byName.get(input.to.trim())
83
+ if (t && t.status !== 'done' && t.status !== 'failed') move(t, 'running', at)
84
+ }
85
+ }
86
+ continue
87
+ }
88
+ if (row?.type === 'user' && Array.isArray(row.message?.content)) {
89
+ for (const b of row.message.content) {
90
+ if (b?.type !== 'tool_result' || typeof b.tool_use_id !== 'string') continue
91
+ const t = byTool.get(b.tool_use_id)
92
+ if (!t) continue
93
+ const result = row.toolUseResult && typeof row.toolUseResult === 'object' ? row.toolUseResult : {}
94
+ if (b.is_error === true) move(t, 'failed', at)
95
+ else if (result.status === 'async_launched' || result.status === 'teammate_spawned') move(t, 'running', at)
96
+ else move(t, 'done', at)
97
+ if (result.status === 'teammate_spawned' && typeof result.name === 'string' && result.name.trim()) {
98
+ t.team = true
99
+ byName.set(clip(result.name, NAME_MAX), t)
100
+ }
101
+ }
102
+ }
103
+ // Notifications arrive as a user line, and as a queued message before it is delivered.
104
+ const text = row?.type === 'user' ? textOf(row.message?.content) : row?.type === 'queue-operation' ? textOf(row.content) : ''
105
+ if (!text || (text.indexOf('<task-notification>') < 0 && text.indexOf('<teammate-message') < 0)) continue
106
+ for (const m of text.matchAll(NOTIFICATION)) {
107
+ const t = byTool.get(m[1]!)
108
+ if (t) move(t, m[2] === 'failed' ? 'failed' : 'done', at)
109
+ }
110
+ for (const m of text.matchAll(TEAMMATE)) {
111
+ const t = byName.get(m[1]!)
112
+ if (!t) continue
113
+ const body = m[2] || ''
114
+ if (/"type"\s*:\s*"shutdown_(?:approved|response)"[\s\S]*?"approve"\s*:\s*true|"type"\s*:\s*"shutdown_approved"/.test(body)) move(t, 'done', at)
115
+ else if (/"type"\s*:\s*"idle_notification"/.test(body)) move(t, 'idle', at)
116
+ else move(t, null, at)
117
+ }
118
+ }
119
+ return summarize([...byTool.values()], startsMidFile, limit)
120
+ }
121
+
122
+ export function summarize(all: SubagentRow[], partial: boolean, limit = SUBAGENTS_PER_SESSION): SessionSubagents {
123
+ const out: SessionSubagents = { rows: [], running: 0, idle: 0, done: 0, failed: 0, total: all.length, partial }
124
+ for (const a of all) if (a.status !== 'unknown') out[a.status]++
125
+ // Live ones first, then by when each last moved.
126
+ const rank = (s: SubagentStatus) => (s === 'running' ? 0 : s === 'idle' ? 1 : 2)
127
+ out.rows = [...all].sort((a, b) => rank(a.status) - rank(b.status) || b.at - a.at).slice(0, limit)
128
+ .map(a => ({ name: a.name, task: a.task, status: a.status, at: a.at }))
129
+ return out
130
+ }
131
+
132
+ const memo = new Map<string, { mtimeMs: number; size: number; value: SessionSubagents }>()
133
+
134
+ /** One Claude transcript's sub-agents, or null when it cannot be read. Never throws. */
135
+ export async function readClaudeSubagents(file: string, tailBytes = CLAUDE_SUBAGENT_TAIL_BYTES): Promise<SessionSubagents | null> {
136
+ let handle: Awaited<ReturnType<typeof open>> | null = null
137
+ try {
138
+ handle = await open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0))
139
+ const st = await handle.stat()
140
+ if (!st.isFile()) return null
141
+ const hit = memo.get(file)
142
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.value
143
+ const length = Math.min(st.size, tailBytes), start = st.size - length
144
+ const buffer = Buffer.alloc(length)
145
+ const read = await handle.read(buffer, 0, length, start)
146
+ const value = parseClaudeSubagents(buffer.subarray(0, read.bytesRead).toString('utf8'), start > 0)
147
+ if (memo.size >= MEMO_MAX) memo.delete(memo.keys().next().value!)
148
+ memo.set(file, { mtimeMs: st.mtimeMs, size: st.size, value })
149
+ return value
150
+ } catch {
151
+ return null
152
+ } finally {
153
+ await handle?.close().catch(() => {})
154
+ }
155
+ }