@gotcos/glasses-server 6.50.4 → 6.52.0

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 (41) hide show
  1. package/.env.example +16 -0
  2. package/CHANGELOG.md +78 -0
  3. package/README.md +56 -3
  4. package/bin/hooks/cos-session-hook +24 -1
  5. package/managed-runtime-contract.json +1 -0
  6. package/package.json +1 -1
  7. package/server/index.ts +83 -5
  8. package/server/lib/activity-preview.ts +45 -2
  9. package/server/lib/agent-session-search.ts +3 -1
  10. package/server/lib/agent-session-store.ts +35 -5
  11. package/server/lib/agent-session-turns.ts +4 -1
  12. package/server/lib/claude-bridge.ts +9 -0
  13. package/server/lib/client-liveness.ts +50 -0
  14. package/server/lib/codex-bridge.ts +6 -0
  15. package/server/lib/codex-live-queue-deps.ts +135 -0
  16. package/server/lib/codex-live-queue.ts +232 -0
  17. package/server/lib/codex-turn-clock.ts +36 -0
  18. package/server/lib/cursor-bridge.ts +6 -0
  19. package/server/lib/cursor-stop-followup.ts +134 -0
  20. package/server/lib/job-trail.ts +758 -0
  21. package/server/lib/occupancy-probes.ts +52 -0
  22. package/server/lib/ollama-bridge.ts +17 -0
  23. package/server/lib/permission-broker.ts +1156 -0
  24. package/server/lib/query-job-coordinator.ts +17 -1
  25. package/server/lib/query-job-feature.ts +10 -0
  26. package/server/lib/query-job-runtime.ts +9 -1
  27. package/server/lib/query-job-store.ts +214 -5
  28. package/server/lib/query-job-types.ts +36 -0
  29. package/server/lib/session-signal-store.ts +42 -10
  30. package/server/lib/session-state-derive.ts +14 -4
  31. package/server/lib/session-stream-events.ts +600 -15
  32. package/server/lib/session-stream-producer.ts +25 -6
  33. package/server/lib/session-transcript-watcher.ts +120 -3
  34. package/server/lib/thread-occupancy.ts +30 -7
  35. package/server/routes/agent-session-bindings.ts +98 -6
  36. package/server/routes/agent-sessions.ts +24 -2
  37. package/server/routes/cursor-stop-followup.ts +74 -0
  38. package/server/routes/health.ts +34 -1
  39. package/server/routes/permission-broker.ts +176 -0
  40. package/server/routes/query-jobs.ts +84 -13
  41. package/server/routes/thread-turn-queue.ts +15 -5
@@ -18,6 +18,7 @@ import {
18
18
  type QueryJobStoreHealth,
19
19
  } from './query-job-types.js'
20
20
  import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
21
+ import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
21
22
 
22
23
  const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
23
24
 
@@ -41,6 +42,9 @@ export interface QueryJobRunnerCallbacks {
41
42
  onChunk: (text: string) => void
42
43
  onToolStatus: (text: string) => void
43
44
  onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => void
45
+ /** One readable step of the run (6.52.0). Filtered by the job's `activityToolMode`
46
+ * here, redacted by the store, journaled as a `trail` event. Never on the display bus. */
47
+ onTrail: (draft: JobTrailDraft) => void
44
48
  /** True only when this callback durably won the answer-ready transition. */
45
49
  onAnswerReady: (text: string, linkage?: QueryJobProviderLinkage) => boolean | Promise<boolean>
46
50
  /** True only when this callback won and durably projected the terminal. */
@@ -349,6 +353,15 @@ export class QueryJobCoordinator {
349
353
  await this.store.appendActivity(active.jobId, line.kind, line.text)
350
354
  })
351
355
  },
356
+ onTrail: (draft) => {
357
+ // The user's opt-out is decided HERE, from the job's own request: `off` keeps no
358
+ // tool steps, `status` keeps steps without raw output (see applyTrailToolMode).
359
+ const allowed = applyTrailToolMode(draft, active.request.activityToolMode)
360
+ if (!allowed) return
361
+ void this.enqueueCallback(active, async () => {
362
+ await this.store.appendTrail(active.jobId, allowed)
363
+ })
364
+ },
352
365
  onAnswerReady: (text, linkage = {}) => this.answerReady(active, text, linkage),
353
366
  onDone: completion => this.completeActive(active, completion),
354
367
  onError: error => this.failActive(active, error),
@@ -540,8 +553,11 @@ export class QueryJobCoordinator {
540
553
  generation: number,
541
554
  after: number,
542
555
  listener: Parameters<QueryJobStore['subscribe']>[3],
556
+ trail?: Parameters<QueryJobStore['subscribe']>[4],
543
557
  ): Promise<QueryJobSubscription> {
544
- return this.store.subscribe(jobId, generation, after, listener)
558
+ return trail
559
+ ? this.store.subscribe(jobId, generation, after, listener, trail)
560
+ : this.store.subscribe(jobId, generation, after, listener)
545
561
  }
546
562
 
547
563
  async shutdown(reason = 'server_shutdown'): Promise<void> {
@@ -9,6 +9,16 @@ export function durableQueryJobsEnabled(env: NodeJS.ProcessEnv = process.env): b
9
9
  return env.COS_DURABLE_QUERY_JOBS !== '0'
10
10
  }
11
11
 
12
+ /**
13
+ * The Messages trail (6.52.0) is on by default. COS_MESSAGES_TRAIL=0 is the rollback
14
+ * that needs no downgrade: the job runner then hands the bridges no trail callback, so
15
+ * no second stdout reader is attached, no `trail` event is journaled, and every stream,
16
+ * snapshot and answer is exactly the 6.51.0 one. Read per job, so a restart applies it.
17
+ */
18
+ export function messagesTrailEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
19
+ return env.COS_MESSAGES_TRAIL !== '0'
20
+ }
21
+
12
22
  export function durableQueryJobsCapability(): {
13
23
  enabled: boolean
14
24
  protocolVersion: number
@@ -7,7 +7,7 @@ import { resolveQueryAttachments } from './query-attachments.js'
7
7
  import { getMediaStore } from './media-store.js'
8
8
  import { dataPath } from './data-dir.js'
9
9
  import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
10
- import { durableQueryJobsEnabled } from './query-job-feature.js'
10
+ import { durableQueryJobsEnabled, messagesTrailEnabled } from './query-job-feature.js'
11
11
  import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
12
12
  import { QueryJobStore } from './query-job-store.js'
13
13
  import {
@@ -33,6 +33,7 @@ import {
33
33
  type QueryJobSnapshot,
34
34
  } from './query-job-types.js'
35
35
  import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
36
+ import type { JobTrailDraft } from './job-trail.js'
36
37
  import { registerMessageReservationSource } from './message-reservations.js'
37
38
 
38
39
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
@@ -243,6 +244,13 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
243
244
  ...(request.activityToolMode === 'preview' ? {
244
245
  onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => callbacks.onActivityLine(line),
245
246
  } : {}),
247
+ // 6.52.0, the Messages trail. Journaled only (the coordinator applies the user's
248
+ // tool-activity mode and the store redacts); it NEVER goes on the display bus, which
249
+ // any paired display may read. Absent entirely with COS_MESSAGES_TRAIL=0, so the
250
+ // bridges attach no second stdout reader at all.
251
+ ...(messagesTrailEnabled() ? {
252
+ onTrail: (draft: JobTrailDraft) => callbacks.onTrail(draft),
253
+ } : {}),
246
254
  onAnswerReady: text => callbacks.onAnswerReady(text, {
247
255
  ...(activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
248
256
  }),
@@ -28,7 +28,36 @@ import {
28
28
  type QueryJobSnapshot,
29
29
  type QueryJobStatus,
30
30
  type QueryJobStoreHealth,
31
+ type QueryJobTrailEntry,
31
32
  } from './query-job-types.js'
33
+ import {
34
+ jobTrailTextChars,
35
+ parseJobTrailEventData,
36
+ sanitizeJobTrailDraft,
37
+ type JobTrailDraft,
38
+ } from './job-trail.js'
39
+
40
+ const QUERY_JOB_STATUSES: readonly QueryJobStatus[] = [
41
+ 'accepted', 'starting', 'running', 'answer_ready', 'completed', 'failed', 'canceled', 'interrupted',
42
+ ]
43
+
44
+ /**
45
+ * Every event type hydration accepts. A row of any other type counts as malformed and is
46
+ * skipped, which is exactly what a 6.51.0 server does with the `trail` rows this build
47
+ * writes: those jobs still load and only `malformedRows` rises (the rollback contract, so
48
+ * QUERY_JOB_SCHEMA_VERSION stays 1; a bump would drop every row on rollback). THIS build
49
+ * must list `trail`, or it would count its own rows as malformed on every restart and the
50
+ * trail would vanish after a reboot.
51
+ *
52
+ * A `trail` row is NOT a job event (QA round 1). It carries the job's CURRENT eventSeq and
53
+ * never advances it, takes no replay-ring slot, never moves `updatedAt`, and is applied
54
+ * idempotently by its own dense `trailSeq`. So an old client's stream is exactly the
55
+ * 6.51.0 one, and a rolled-back 6.51.0 server, which skips these rows, numbers its next
56
+ * event after the last event it CAN read without colliding with anything written here.
57
+ */
58
+ export const QUERY_JOB_JOURNAL_EVENT_TYPES: readonly QueryJobEventType[] = [
59
+ ...QUERY_JOB_STATUSES, 'chunk', 'tool_status', 'activity_line', 'acknowledged', 'trail',
60
+ ]
32
61
 
33
62
  const PARTITION_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/
34
63
  const MAX_JOURNAL_RECORD_BYTES = 512 * 1024
@@ -59,6 +88,9 @@ interface HydratedQueryJob {
59
88
  snapshot: QueryJobSnapshot
60
89
  events: QueryJobEvent[]
61
90
  lastBootId: string
91
+ /** The last `trailSeq` this job journaled. The next trail row is this plus one,
92
+ * assigned inside the serialized append, so the persisted order is dense 1..n. */
93
+ trailSeq: number
62
94
  }
63
95
 
64
96
  interface QueryJobIdentity {
@@ -95,9 +127,41 @@ export interface QueryJobExecutionRecord {
95
127
 
96
128
  export interface QueryJobSubscription {
97
129
  replay: QueryJobReplay
130
+ /** Present only when the subscriber asked for the trail. */
131
+ trailReplay?: QueryJobTrailReplay
98
132
  unsubscribe: () => void
99
133
  }
100
134
 
135
+ /** One trail entry as a subscriber receives it: its own cursor, never the job eventSeq. */
136
+ export interface QueryJobTrailFrame {
137
+ type: 'trail'
138
+ trailSeq: number
139
+ jobId: string
140
+ clientJobId: string
141
+ generation: number
142
+ at: string
143
+ data: JobTrailDraft
144
+ }
145
+
146
+ /**
147
+ * What a trail subscriber missed. `gap` means entries after its cursor are no longer held
148
+ * (`snapshot.trail` is bounded) or the cursor is ahead: the client resets its trail to
149
+ * `entries` and continues from `latestTrailSeq`. Otherwise `entries` are exactly those
150
+ * after the cursor, in order.
151
+ */
152
+ export interface QueryJobTrailReplay {
153
+ entries: QueryJobTrailEntry[]
154
+ gap: boolean
155
+ reason?: 'trail_gap' | 'cursor_ahead'
156
+ oldestTrailSeq: number
157
+ latestTrailSeq: number
158
+ }
159
+
160
+ export interface QueryJobTrailSubscribe {
161
+ after: number
162
+ listener: (frame: QueryJobTrailFrame) => void
163
+ }
164
+
101
165
  export interface QueryJobJournalStorage {
102
166
  prepare(root: string): Promise<void>
103
167
  listPartitions(root: string): Promise<string[]>
@@ -162,6 +226,14 @@ export interface QueryJobStoreOptions {
162
226
  maxHydratedJobs?: number
163
227
  maxReplayEvents?: number
164
228
  maxActivityEntries?: number
229
+ maxTrailEntries?: number
230
+ maxTrailChars?: number
231
+ /**
232
+ * Replaces the hydration allowlist. Production never passes it. It exists so a test
233
+ * can hydrate a journal through the exact 6.51.0 list (no `trail`) and prove the
234
+ * rollback contract on real bytes without shipping a second copy of this class.
235
+ */
236
+ journalEventTypes?: readonly QueryJobEventType[]
165
237
  }
166
238
 
167
239
  export class QueryJobStoreError extends Error {
@@ -290,10 +362,15 @@ export class QueryJobStore {
290
362
  private readonly maxHydratedJobs: number
291
363
  private readonly maxReplayEvents: number
292
364
  private readonly maxActivityEntries: number
365
+ private readonly maxTrailEntries: number
366
+ private readonly maxTrailChars: number
367
+ private readonly journalEventTypes: readonly QueryJobEventType[]
293
368
  private readonly jobs = new Map<string, HydratedQueryJob>()
294
369
  private readonly identitiesByJobId = new Map<string, QueryJobIdentity>()
295
370
  private readonly identitiesByKey = new Map<string, QueryJobIdentity>()
296
371
  private readonly emitter = new EventEmitter()
372
+ /** Trail frames, apart from job events: only a subscriber that asked for them listens. */
373
+ private readonly trailEmitter = new EventEmitter()
297
374
  private appendTail: Promise<void> = Promise.resolve()
298
375
  private initPromise: Promise<QueryJobStoreHealth> | null = null
299
376
  private partitions: string[] = []
@@ -307,7 +384,11 @@ export class QueryJobStore {
307
384
  this.maxHydratedJobs = Math.max(1, options.maxHydratedJobs ?? QUERY_JOB_LIMITS.hydratedJobs)
308
385
  this.maxReplayEvents = Math.max(1, options.maxReplayEvents ?? QUERY_JOB_LIMITS.replayEvents)
309
386
  this.maxActivityEntries = Math.max(1, options.maxActivityEntries ?? QUERY_JOB_LIMITS.activityEntries)
387
+ this.maxTrailEntries = Math.max(1, options.maxTrailEntries ?? QUERY_JOB_LIMITS.trailEntries)
388
+ this.maxTrailChars = Math.max(1, options.maxTrailChars ?? QUERY_JOB_LIMITS.trailChars)
389
+ this.journalEventTypes = options.journalEventTypes ?? QUERY_JOB_JOURNAL_EVENT_TYPES
310
390
  this.emitter.setMaxListeners(1_000)
391
+ this.trailEmitter.setMaxListeners(1_000)
311
392
  this.health = {
312
393
  state: 'new',
313
394
  bootId: options.bootId,
@@ -429,9 +510,10 @@ export class QueryJobStore {
429
510
  || typeof r.status !== 'string'
430
511
  || !r.patch || typeof r.patch !== 'object'
431
512
  || !r.eventData || typeof r.eventData !== 'object') return null
432
- const statuses: QueryJobStatus[] = ['accepted', 'starting', 'running', 'answer_ready', 'completed', 'failed', 'canceled', 'interrupted']
433
- const eventTypes: QueryJobEventType[] = [...statuses, 'chunk', 'tool_status', 'activity_line', 'acknowledged']
434
- if (!statuses.includes(r.status as QueryJobStatus) || !eventTypes.includes(r.type as QueryJobEventType)) return null
513
+ if (!QUERY_JOB_STATUSES.includes(r.status as QueryJobStatus)
514
+ || !this.journalEventTypes.includes(r.type as QueryJobEventType)) return null
515
+ // A trail row whose data is not a trail entry is malformed, not a hole in the trail.
516
+ if (r.type === 'trail' && !parseJobTrailEventData(r.eventData)) return null
435
517
  return r as unknown as QueryJobJournalRecord
436
518
  }
437
519
 
@@ -466,6 +548,7 @@ export class QueryJobStore {
466
548
  request,
467
549
  events: [],
468
550
  lastBootId: record.bootId,
551
+ trailSeq: 0,
469
552
  snapshot: {
470
553
  schemaVersion: QUERY_JOB_SCHEMA_VERSION,
471
554
  jobId: record.jobId,
@@ -494,6 +577,11 @@ export class QueryJobStore {
494
577
  }
495
578
  this.jobs.set(record.jobId, hydrated)
496
579
  }
580
+ // A trail row is not a job event: its own cursor, nothing else of the job moves.
581
+ if (record.type === 'trail') {
582
+ this.applyTrail(hydrated, record, publish)
583
+ return undefined
584
+ }
497
585
  if (record.eventSeq <= hydrated.snapshot.eventSeq) return undefined
498
586
  if (record.type !== 'accepted'
499
587
  && record.type !== 'acknowledged'
@@ -604,6 +692,45 @@ export class QueryJobStore {
604
692
  if (snapshot.activity.length > this.maxActivityEntries) snapshot.activity.shift()
605
693
  }
606
694
 
695
+ /**
696
+ * One trail row into `snapshot.trail`, the page a client rebuilds from. Idempotent by
697
+ * `trailSeq` (a row at or below the job's last one is history, whichever boot wrote it),
698
+ * never on a terminal job, and it touches NOTHING else of the job: not eventSeq, not the
699
+ * replay ring, not `updatedAt`, not the identity. Bounded twice: the newest
700
+ * `maxTrailEntries` entries, and no more than `maxTrailChars` characters of text across
701
+ * them (the oldest go first; the newest entry always stays). The array exists only once a
702
+ * row does. A live append is published on the trail channel, which only a subscriber that
703
+ * asked for the trail listens to.
704
+ */
705
+ private applyTrail(hydrated: HydratedQueryJob, record: QueryJobJournalRecord, publish: boolean): void {
706
+ const parsed = parseJobTrailEventData(record.eventData)
707
+ if (!parsed) return
708
+ if (record.generation !== hydrated.snapshot.generation) return
709
+ if (isTerminalQueryJobStatus(hydrated.snapshot.status)) return
710
+ if (parsed.trailSeq <= hydrated.trailSeq) return
711
+ hydrated.trailSeq = parsed.trailSeq
712
+ const trail = hydrated.snapshot.trail ?? (hydrated.snapshot.trail = [])
713
+ const entry: QueryJobTrailEntry = { ...parsed.draft, trailSeq: parsed.trailSeq, at: record.persistedAt }
714
+ trail.push(entry)
715
+ let chars = trail.reduce((sum, e) => sum + jobTrailTextChars(e), 0)
716
+ while (trail.length > 1 && (trail.length > this.maxTrailEntries || chars > this.maxTrailChars)) {
717
+ chars -= jobTrailTextChars(trail.shift()!)
718
+ }
719
+ if (publish) {
720
+ const { trailSeq: _seq, at: _at, ...draft } = entry
721
+ const frame: QueryJobTrailFrame = {
722
+ type: 'trail',
723
+ trailSeq: entry.trailSeq,
724
+ jobId: hydrated.snapshot.jobId,
725
+ clientJobId: hydrated.snapshot.clientJobId,
726
+ generation: hydrated.snapshot.generation,
727
+ at: entry.at,
728
+ data: draft as JobTrailDraft,
729
+ }
730
+ this.trailEmitter.emit(hydrated.snapshot.jobId, frame)
731
+ }
732
+ }
733
+
607
734
  private async appendRecord(record: QueryJobJournalRecord): Promise<void> {
608
735
  const serialized = JSON.stringify(record)
609
736
  if (Buffer.byteLength(serialized, 'utf8') > MAX_JOURNAL_RECORD_BYTES) {
@@ -771,6 +898,55 @@ export class QueryJobStore {
771
898
  return this.mutateSameStatus(jobId, type, {}, { kind, text: safe.text, truncated: safe.truncated })
772
899
  }
773
900
 
901
+ /**
902
+ * One trail draft into the journal (6.52.0).
903
+ *
904
+ * Redacted and bounded here, at the journal boundary, whatever the caller already did
905
+ * (`sanitizeJobTrailDraft`). `trailSeq` is assigned INSIDE the serialized append from the
906
+ * job's own last one, so the persisted trail is dense 1..n no matter how trail, chunk and
907
+ * activity writes interleave. A draft with nothing to say, or a job already terminal,
908
+ * writes nothing and is never an error: a trail row must not be able to fail a run.
909
+ *
910
+ * NOT A JOB EVENT (QA round 1). The row carries the job's current eventSeq without
911
+ * advancing it and goes to the trail channel only: job subscribers, the replay ring and
912
+ * every snapshot field but `trail` are exactly what they would be without it.
913
+ */
914
+ async appendTrail(jobId: string, raw: unknown): Promise<QueryJobMutationResult> {
915
+ const draft = sanitizeJobTrailDraft(raw)
916
+ await this.ensureInitialized()
917
+ await this.ensureHydrated(jobId)
918
+ return this.enqueue(async () => {
919
+ this.assertWritable()
920
+ const hydrated = this.jobs.get(jobId)
921
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
922
+ if (!draft || isTerminalQueryJobStatus(hydrated.snapshot.status)) {
923
+ return { applied: false, job: clone(hydrated.snapshot) }
924
+ }
925
+ const snapshot = hydrated.snapshot
926
+ const record: QueryJobJournalRecord = {
927
+ schemaVersion: QUERY_JOB_SCHEMA_VERSION,
928
+ recordId: randomUUID(),
929
+ partitionDay: localPartitionDay(this.now()),
930
+ persistedAt: this.now().toISOString(),
931
+ bootId: this.options.bootId,
932
+ jobId: snapshot.jobId,
933
+ clientJobId: snapshot.clientJobId,
934
+ generation: snapshot.generation,
935
+ turnId: snapshot.turnId,
936
+ requestFingerprint: snapshot.requestFingerprint,
937
+ // The event this row follows. Never `+ 1`: the job cursor is not the trail's.
938
+ eventSeq: snapshot.eventSeq,
939
+ type: 'trail',
940
+ status: snapshot.status,
941
+ patch: {},
942
+ eventData: { ...draft, trailSeq: hydrated.trailSeq + 1 },
943
+ }
944
+ await this.appendRecord(record)
945
+ this.applyTrail(hydrated, record, true)
946
+ return { applied: true, job: clone(hydrated.snapshot) }
947
+ })
948
+ }
949
+
774
950
  async markAnswerReady(
775
951
  jobId: string,
776
952
  fullText: string,
@@ -1109,31 +1285,64 @@ export class QueryJobStore {
1109
1285
  generation: number,
1110
1286
  after: number,
1111
1287
  listener: (event: QueryJobEvent) => void,
1288
+ trail?: QueryJobTrailSubscribe,
1112
1289
  ): Promise<QueryJobSubscription> {
1113
1290
  await this.getSnapshot(jobId, generation)
1114
1291
  let closed = false
1115
1292
  const wrapped = (event: QueryJobEvent) => {
1116
1293
  if (!closed && event.generation === generation && event.eventSeq > after) listener(clone(event))
1117
1294
  }
1295
+ // The live trail cursor. It starts at the client's, and a rebuild moves it (below).
1296
+ let trailCursor = trail ? trail.after : 0
1297
+ const trailWrapped = trail
1298
+ ? (frame: QueryJobTrailFrame) => {
1299
+ if (!closed && frame.generation === generation && frame.trailSeq > trailCursor) trail.listener(clone(frame))
1300
+ }
1301
+ : null
1118
1302
  this.emitter.on(jobId, wrapped)
1303
+ if (trailWrapped) this.trailEmitter.on(jobId, trailWrapped)
1119
1304
  this.subscriberCount++
1120
1305
  this.refreshHealth()
1121
1306
  // Register first, then take the synchronous replay snapshot. An append can
1122
- // therefore be in replay OR arrive live (and clients dedupe by eventSeq),
1123
- // but can never fall into an await-sized gap between the two.
1307
+ // therefore be in replay OR arrive live (and clients dedupe by eventSeq, or by
1308
+ // trailSeq), but can never fall into an await-sized gap between the two.
1124
1309
  const replay = this.buildReplay(jobId, after)
1310
+ const trailReplay = trail ? this.buildTrailReplay(jobId, trail.after) : undefined
1311
+ // A rebuild (`cursor_ahead` or `trail_gap`) resets the client's trail to the held entries
1312
+ // and continues from `latestTrailSeq`, so the live filter does too (6.52.0 app QA). A
1313
+ // retried Messages run keeps its queue row but starts a new job whose trail restarts at 1;
1314
+ // subscribed with the failed attempt's `trailAfter=5` to a trail at 2, the stale cursor
1315
+ // dropped entries 3, 4 and 5 as already seen. Still synchronous with the registration.
1316
+ if (trailReplay?.gap) trailCursor = trailReplay.latestTrailSeq
1125
1317
  return {
1126
1318
  replay,
1319
+ ...(trailReplay ? { trailReplay } : {}),
1127
1320
  unsubscribe: () => {
1128
1321
  if (closed) return
1129
1322
  closed = true
1130
1323
  this.emitter.off(jobId, wrapped)
1324
+ if (trailWrapped) this.trailEmitter.off(jobId, trailWrapped)
1131
1325
  this.subscriberCount = Math.max(0, this.subscriberCount - 1)
1132
1326
  this.refreshHealth()
1133
1327
  },
1134
1328
  }
1135
1329
  }
1136
1330
 
1331
+ /** The trail entries after `after`, or the whole held trail when some are gone. */
1332
+ private buildTrailReplay(jobId: string, after: number): QueryJobTrailReplay {
1333
+ const hydrated = this.jobs.get(jobId)!
1334
+ const trail = hydrated.snapshot.trail ?? []
1335
+ const latestTrailSeq = hydrated.trailSeq
1336
+ const oldestTrailSeq = trail[0]?.trailSeq ?? latestTrailSeq + 1
1337
+ if (after > latestTrailSeq) {
1338
+ return { entries: clone(trail), gap: true, reason: 'cursor_ahead', oldestTrailSeq, latestTrailSeq }
1339
+ }
1340
+ if (after < oldestTrailSeq - 1) {
1341
+ return { entries: clone(trail), gap: true, reason: 'trail_gap', oldestTrailSeq, latestTrailSeq }
1342
+ }
1343
+ return { entries: clone(trail.filter(entry => entry.trailSeq > after)), gap: false, oldestTrailSeq, latestTrailSeq }
1344
+ }
1345
+
1137
1346
  private trimHydratedJobs(protectedJobId?: string): void {
1138
1347
  if (this.jobs.size <= this.maxHydratedJobs) return
1139
1348
  const candidates = [...this.jobs.values()]
@@ -4,6 +4,7 @@ import {
4
4
  parseMediaIdList,
5
5
  type MediaAttachmentRef,
6
6
  } from '../../shared/media-attachment.js'
7
+ import type { JobTrailDraft } from './job-trail.js'
7
8
 
8
9
  export const QUERY_JOB_SCHEMA_VERSION = 1 as const
9
10
  export const QUERY_JOB_PROTOCOL_VERSION = 1 as const
@@ -17,6 +18,12 @@ export const QUERY_JOB_LIMITS = Object.freeze({
17
18
  errorChars: 2_000,
18
19
  activityChars: 2_000,
19
20
  activityEntries: 64,
21
+ /** `snapshot.trail` keeps the newest 200 entries (6.52.0)... */
22
+ trailEntries: 200,
23
+ /** ...and at most this many characters of text across them, whichever bound
24
+ * bites first, so a replay-gap snapshot stays small on a phone. Each field is
25
+ * capped on its own too (see `sanitizeJobTrailDraft`). */
26
+ trailChars: 32_000,
20
27
  replayEvents: 256,
21
28
  retainedDays: 7,
22
29
  hydratedJobs: 500,
@@ -43,6 +50,11 @@ export type QueryJobEventType =
43
50
  | 'tool_status'
44
51
  | 'activity_line'
45
52
  | 'acknowledged'
53
+ // 6.52.0: one readable step of a Messages run (see lib/job-trail.ts), a JOURNAL row
54
+ // type only: it is never a job event, never takes an eventSeq or a replay-ring slot,
55
+ // and reaches only a subscriber that asked for the trail. Its data is one draft plus
56
+ // `trailSeq`, a dense 1..n per job.
57
+ | 'trail'
46
58
 
47
59
  export type QueryJobActivityMode = 'off' | 'status' | 'preview'
48
60
  export type QueryJobActivityKind = 'status' | 'input' | 'output' | 'gap'
@@ -147,6 +159,13 @@ export interface QueryJobActivity {
147
159
  repeatCount?: number
148
160
  }
149
161
 
162
+ /** One entry of `snapshot.trail`: the journaled draft, its dense `trailSeq` (the trail's
163
+ * own cursor, never the job eventSeq) and when it was written. */
164
+ export type QueryJobTrailEntry = JobTrailDraft & {
165
+ trailSeq: number
166
+ at: string
167
+ }
168
+
150
169
  export interface QueryJobSnapshot extends QueryJobProviderLinkage {
151
170
  schemaVersion: typeof QUERY_JOB_SCHEMA_VERSION
152
171
  jobId: string
@@ -172,6 +191,11 @@ export interface QueryJobSnapshot extends QueryJobProviderLinkage {
172
191
  outputImageStats?: QueryJobOutputImageStats
173
192
  error?: QueryJobError
174
193
  activity: QueryJobActivity[]
194
+ /** The readable trail (6.52.0), bounded by `QUERY_JOB_LIMITS.trailEntries` and
195
+ * `trailChars`, rebuilt from the journal on restart. ABSENT, never `[]`, on a job
196
+ * with no trail rows, and stripped from every snapshot sent to a client that did not
197
+ * ask for the trail (`?trail=1`), so such a client sees exactly the 6.51.0 shape. */
198
+ trail?: QueryJobTrailEntry[]
175
199
  acceptedAt: string
176
200
  startedAt?: string
177
201
  answerReadyAt?: string
@@ -477,6 +501,18 @@ export function parseQueryJobOutputImageStats(raw: unknown): QueryJobOutputImage
477
501
  return { published, attached, rejected }
478
502
  }
479
503
 
504
+ /** The store's own credential and local-path patterns, applied to free text with
505
+ * nothing else changed: no whitespace collapsing, no bound, newlines kept. The trail
506
+ * sanitizer (lib/job-trail.ts) runs this after `redactSecretText`, so a trail field
507
+ * passes BOTH redaction layers before it reaches the journal. */
508
+ export function redactQueryJobText(value: string): string {
509
+ if (typeof value !== 'string' || value.length === 0) return ''
510
+ let text = value
511
+ for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[redacted]')
512
+ for (const pattern of PATH_PATTERNS) text = text.replace(pattern, '[path]')
513
+ return text
514
+ }
515
+
480
516
  /** Second-line redaction even for bridge-produced "safe" activity. This is
481
517
  * intentionally conservative: replay never needs a credential or local path. */
482
518
  export function sanitizeQueryJobActivity(value: unknown): { text: string; truncated: boolean } {
@@ -149,6 +149,18 @@ function turnOpenedByTool(prev: SessionSignal, p: Record<string, unknown>, at: n
149
149
  /** Waiting kinds a PostToolUse of the same tool resolves without a fingerprint match. */
150
150
  const TOOL_WAITING: Record<string, WaitingKind> = { AskUserQuestion: 'question', ExitPlanMode: 'plan' }
151
151
 
152
+ /** The tool whose PermissionRequest is a question card, not an approval (6.52.0). */
153
+ export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
154
+
155
+ /**
156
+ * 6.52.0: the permission broker's id survives a re-announcement of the SAME request. The
157
+ * async PreToolUse can land after the PermissionRequest (and the spool after the broker
158
+ * parked it), and either rewrite of `waiting` would otherwise drop the id the rows carry.
159
+ */
160
+ function keptRequestId(prev: WaitingSignal | null, fingerprint: string): string | null {
161
+ return prev?.requestId && fingerprint && prev.fingerprint === fingerprint ? prev.requestId : null
162
+ }
163
+
152
164
  function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName: string, fingerprint: string): boolean {
153
165
  if (waiting.fingerprint && waiting.fingerprint === fingerprint) return true
154
166
  // Question/plan tools carry no meaningful input to fingerprint; the tool name is the key.
@@ -210,7 +222,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
210
222
  return {
211
223
  ...next,
212
224
  turnOpen: true,
213
- waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: null },
225
+ waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: keptRequestId(next.waiting, tool.fingerprint) },
214
226
  }
215
227
  }
216
228
  return { ...next, ...turnOpenedByTool(next, p, env.ts), lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
@@ -221,12 +233,15 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
221
233
  ...next,
222
234
  turnOpen: true,
223
235
  waiting: {
224
- kind: 'permission',
236
+ // 6.52.0: an AskUserQuestion stays a QUESTION through its PermissionRequest. As
237
+ // `permission` it cleared only on a fingerprint match, and the answered tool's
238
+ // PostToolUse carries `answers` in its input, so the row could stay waiting.
239
+ kind: tool.name === ASK_USER_QUESTION_TOOL ? 'question' : 'permission',
225
240
  detail: tool.target ? `${tool.name} ${tool.target}` : tool.name,
226
241
  toolName: tool.name,
227
242
  fingerprint: tool.fingerprint,
228
243
  since: env.ts,
229
- requestId: null,
244
+ requestId: keptRequestId(next.waiting, tool.fingerprint),
230
245
  },
231
246
  }
232
247
  }
@@ -314,19 +329,36 @@ export class SessionSignalStore {
314
329
  this.signals.set(sessionId, { ...current, entrypoint })
315
330
  }
316
331
 
317
- /** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. Attach the broker's
318
- * minted id so rows can carry `pending_permission_id`. */
319
- attachPermissionRequestId(sessionId: string, requestId: string | null): void {
332
+ /**
333
+ * The permission broker (6.52.0) attaches its item id so rows can carry
334
+ * `pending_permission_id` (an approval) or `pending_question_id` (an AskUserQuestion).
335
+ * Widened to `question` in 6.52.0. With a fingerprint, only the wait for THAT request
336
+ * takes the id: a different dialog now standing must never advertise it. True when set.
337
+ */
338
+ attachPermissionRequestId(sessionId: string, requestId: string | null, fingerprint?: string): boolean {
320
339
  const current = this.signals.get(sessionId)
321
- if (!current?.waiting || current.waiting.kind !== 'permission') return
340
+ if (!current?.waiting || (current.waiting.kind !== 'permission' && current.waiting.kind !== 'question')) return false
341
+ if (fingerprint !== undefined && current.waiting.fingerprint !== fingerprint) return false
322
342
  this.signals.set(sessionId, { ...current, waiting: { ...current.waiting, requestId } })
343
+ return true
344
+ }
345
+
346
+ /** The broker let go of a request without deciding it: drop its id, keep the wait. */
347
+ detachPermissionRequestId(sessionId: string, requestId: string): void {
348
+ const current = this.signals.get(sessionId)
349
+ if (!current?.waiting || current.waiting.requestId !== requestId) return
350
+ this.signals.set(sessionId, { ...current, waiting: { ...current.waiting, requestId: null } })
323
351
  }
324
352
 
325
- /** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. The broker decided
326
- * (allow or deny): that is resolution evidence. */
327
- resolveWaiting(sessionId: string): void {
353
+ /**
354
+ * The broker decided (allow or deny): that is resolution evidence. With `match` (6.52.0),
355
+ * only the wait that carries the broker's id, or that is the same request by fingerprint,
356
+ * is cleared; a different dialog standing now is left alone.
357
+ */
358
+ resolveWaiting(sessionId: string, match?: { requestId: string; fingerprint: string }): void {
328
359
  const current = this.signals.get(sessionId)
329
360
  if (!current?.waiting) return
361
+ if (match && current.waiting.requestId !== match.requestId && current.waiting.fingerprint !== match.fingerprint) return
330
362
  this.signals.set(sessionId, { ...current, waiting: null })
331
363
  }
332
364
 
@@ -36,7 +36,10 @@ export interface DerivedSessionState {
36
36
  waiting_detail?: string
37
37
  failure?: string
38
38
  last_reply?: string
39
+ /** 6.52.0: the permission broker holds this approval; `GET /api/session-questions` has it. */
39
40
  pending_permission_id?: string
41
+ /** 6.52.0: the permission broker holds this AskUserQuestion; `GET /api/session-questions` has it. */
42
+ pending_question_id?: string
40
43
  /** Consecutive derives that saw a dead registry pid; carried by the caller between polls. */
41
44
  deadScans: number
42
45
  /** When the pid was first seen dead, so two observations must also be DEAD_GRACE_MS apart. */
@@ -160,14 +163,20 @@ function deriveFromSignal(signal: SessionSignal, registry: RegistryFacts | undef
160
163
  if (signal.ended && !(registry?.alive)) {
161
164
  return { agent_state: 'ended', state_source: 'hook', state_since: iso(signal.ended.at), ...reply }
162
165
  }
163
- if (signal.waiting && waitStillStands(signal.waiting.since, registry, transcript, now)) {
166
+ // 6.52.0: a wait the permission broker still holds stands whatever the registry or the
167
+ // transcript say. The broker is positive evidence the hook is blocked on it right now,
168
+ // and it drops the id on every way the request ends (answer, desk, deadline, hook gone),
169
+ // all bounded by its deadline.
170
+ if (signal.waiting && (signal.waiting.requestId || waitStillStands(signal.waiting.since, registry, transcript, now))) {
171
+ const requestId = signal.waiting.requestId
164
172
  return {
165
173
  agent_state: 'waiting',
166
174
  state_source: 'hook',
167
175
  state_since: iso(signal.waiting.since),
168
176
  waiting_kind: signal.waiting.kind,
169
177
  waiting_detail: signal.waiting.detail,
170
- ...(signal.waiting.requestId ? { pending_permission_id: signal.waiting.requestId } : {}),
178
+ ...(requestId && signal.waiting.kind === 'question' ? { pending_question_id: requestId } : {}),
179
+ ...(requestId && signal.waiting.kind !== 'question' ? { pending_permission_id: requestId } : {}),
171
180
  ...reply,
172
181
  }
173
182
  }
@@ -208,8 +217,8 @@ function waitStillStands(since: number, registry: RegistryFacts | undefined, tra
208
217
  }
209
218
 
210
219
  /**
211
- * The fields a `status` draft carries (6.48.1): the row fields minus the permission id
212
- * (slice 4's, never on the stream) and with `last_reply` only on an idle state, where a
220
+ * The fields a `status` draft carries (6.48.1): the row fields minus the broker ids
221
+ * (`pending_permission_id`, `pending_question_id`: never on the stream) and with `last_reply` only on an idle state, where a
213
222
  * feed renders "Idle, last reply: …". A typed projection, so a new row field cannot reach
214
223
  * the wire by accident.
215
224
  */
@@ -245,5 +254,6 @@ export function derivedRowFields(derived: DerivedSessionState | undefined): Reco
245
254
  ...(derived.failure ? { failure: derived.failure } : {}),
246
255
  ...(derived.last_reply ? { last_reply: derived.last_reply } : {}),
247
256
  ...(derived.pending_permission_id ? { pending_permission_id: derived.pending_permission_id } : {}),
257
+ ...(derived.pending_question_id ? { pending_question_id: derived.pending_question_id } : {}),
248
258
  }
249
259
  }