@xingular/harness 0.1.7 → 2.0.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 (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +100 -10
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -5,6 +5,7 @@
5
5
  // their limit are condensed.
6
6
  import { readFileSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
+ import { createHash } from 'node:crypto'
8
9
 
9
10
  import type { Accounts } from '../accounts/accounts'
10
11
  import { log, type Bus } from '../core/bus'
@@ -20,7 +21,7 @@ import { callerBlocks, type CallerContext } from './caller'
20
21
  import * as facts from './facts'
21
22
  import { piMaintainerFactory, runMaintenance, type ExistingBlock, type ExistingMemory, type ExistingPage, type MaintainerFactory, type MaintenanceRequest } from './maintainer'
22
23
  import { inbox, undo } from './operations'
23
- import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type Trigger } from './proposals'
24
+ import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type ProposalSet, type SourceRef, type Trigger } from './proposals'
24
25
  import { looksSecret } from './secrets'
25
26
  import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
26
27
  import * as registry from '../skills/registry'
@@ -46,27 +47,30 @@ export function chatUpdatesPaused(settings: unknown): boolean {
46
47
  return (settings as { autoMaintenanceChat?: unknown } | null)?.autoMaintenanceChat === false
47
48
  }
48
49
 
50
+ /** A run that automatic maintenance leaves alone for a settled reason: a setting, a pause, or no project. Not an error. */
51
+ class NotEligible extends Error {}
52
+
49
53
  /** Where a settled run's knowledge is filed and what the job reads of it; throws the reason it is not eligible. */
50
54
  function runEvidence(root: string, runId: string): { namespace: string; transcript: string } {
51
55
  const run = findRun(root, runId)
52
56
  if (!run) throw new Error('Run was not found.')
53
57
  const settings = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)
54
- if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new Error('run excluded in settings')
58
+ if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new NotEligible('run excluded in settings')
55
59
  const projectId = run.mode === 'chat' || run.mode === 'code' ? run.projectId : null
56
60
  let namespace: string
57
61
  if (projectId) {
58
62
  const project = findProject(root, projectId)
59
- if (!project.autoMaintenance) throw new Error('automatic updates are paused for this project')
63
+ if (!project.autoMaintenance) throw new NotEligible('automatic updates are paused for this project')
60
64
  namespace = project.wikiNamespace
61
65
  } else if (run.mode === 'chat') {
62
- if (chatUpdatesPaused(settings)) throw new Error('automatic updates are paused for Chat')
66
+ if (chatUpdatesPaused(settings)) throw new NotEligible('automatic updates are paused for Chat')
63
67
  namespace = store.LIBRARY_NAMESPACE
64
68
  } else {
65
- throw new Error('run has no project')
69
+ throw new NotEligible('run has no project')
66
70
  }
67
71
  const text = transcript(run.goal ?? '', readEvents(root, runId))
68
- if (!text.includes('Assistant:')) throw new Error('no recorded assistant output to use as evidence')
69
- if (looksSecret(text)) throw new Error('the run contains text that looks like a credential')
72
+ if (!text.includes('Assistant:')) throw new NotEligible('no recorded assistant output to use as evidence')
73
+ if (looksSecret(text)) throw new NotEligible('the run contains text that looks like a credential')
70
74
  return { namespace, transcript: text }
71
75
  }
72
76
 
@@ -144,6 +148,12 @@ export function fullBlocks(wiki: string, tried: Set<string>): string[] {
144
148
 
145
149
  const blockValues = (found: readonly blocks.Block[]): ExistingBlock[] => found.map((block) => ({ id: block.id, label: block.front.label, description: block.front.description, limit: block.front.limit, body: block.body }))
146
150
 
151
+ export interface MaintenanceBatchDecision {
152
+ id: string
153
+ decision: 'apply' | 'reject' | 'needs_user'
154
+ reason: string
155
+ }
156
+
147
157
  export interface MaintenanceOptions {
148
158
  env: HarnessEnv
149
159
  bus: Bus
@@ -208,7 +218,12 @@ export class Maintenance {
208
218
  if (this.idle) clearTimeout(this.idle)
209
219
  if (this.options.external) {
210
220
  if (job.kind === 'run') {
211
- try { runEvidence(this.options.env.root, job.runId) } catch (error) { log('maintenance source skipped', error instanceof Error ? error.message : String(error)); return }
221
+ try {
222
+ runEvidence(this.options.env.root, job.runId)
223
+ } catch (error) {
224
+ if (!(error instanceof NotEligible)) log('maintenance source skipped', error instanceof Error ? error.message : String(error))
225
+ return
226
+ }
212
227
  const run = findRun(this.options.env.root, job.runId)
213
228
  const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
214
229
  enqueueCuration(this.options.env.root, { ...job, namespace })
@@ -233,6 +248,150 @@ export class Maintenance {
233
248
 
234
249
  jobs(): CurationJob[] { return listCuration(this.options.env.root) }
235
250
 
251
+ /** Reads every request frozen into the current host-assigned review batch. */
252
+ readBatch(context: CallerContext): { jobs: unknown[] } {
253
+ requireCurator(context)
254
+ const ids = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
255
+ return {
256
+ jobs: ids.map((id) => {
257
+ try {
258
+ return this.readJob(context, id)
259
+ } catch (error) {
260
+ return { id, status: 'failed', error: error instanceof Error ? error.message : String(error) }
261
+ }
262
+ })
263
+ }
264
+ }
265
+
266
+ /** Applies one evidence-checked Wiki change set for all accepted Wiki requests in a batch. */
267
+ applyBatch(context: CallerContext, decisions: readonly MaintenanceBatchDecision[], replacement: unknown): { operationId: string | null; jobs: CurationJob[] } {
268
+ requireCurator(context)
269
+ if (!decisions.length) throw invalid('Decide every assigned request in the batch.')
270
+ const ids = decisions.map((decision) => decision.id)
271
+ if (new Set(ids).size !== ids.length) throw invalid('Each request can appear only once in a batch decision.')
272
+ const assigned = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
273
+ const pending = assigned.map((id) => curationJob(this.options.env.root, id)).filter((job) => !['completed', 'rejected', 'needs_user'].includes(job.status))
274
+ if (pending.some((job) => !ids.includes(job.id))) throw invalid('Decide every assigned request in one batch.')
275
+
276
+ const entries = decisions.map((decision) => {
277
+ if (!decision.reason.trim()) throw invalid('Explain every batch decision.')
278
+ if (!['apply', 'reject', 'needs_user'].includes(decision.decision)) throw invalid('Choose apply, reject, or needs_user for every request.')
279
+ const job = curationJob(this.options.env.root, decision.id)
280
+ if (context.maintenanceJobIds !== undefined && !context.maintenanceJobIds.includes(decision.id) && !('caller' in job.input && job.input.caller.runId === context.runId)) throw invalid('This request is not assigned to this run.')
281
+ if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
282
+ return { decision, job }
283
+ })
284
+ const wikiEntries = entries.filter(({ decision, job }) => decision.decision === 'apply' && !['registry', 'plugin'].includes(job.input.kind) && !['completed', 'rejected', 'needs_user'].includes(job.status))
285
+ if (wikiEntries.length && replacement === undefined) throw invalid('Provide one combined, evidence-backed Wiki change set for the approved requests.')
286
+ const proposals: ProposalSet | null = wikiEntries.length ? parseProposalSet(replacement) : null
287
+ const operationId = wikiEntries.length ? `batch-${createHash('sha256').update(wikiEntries.map(({ job }) => job.id).sort().join('\0')).digest('hex').slice(0, 32)}` : null
288
+ const results = new Map<string, CurationJob>()
289
+
290
+ if (wikiEntries.length) {
291
+ const prepared = wikiEntries.map(({ job }) => this.claim(context, job.id))
292
+ const root = this.options.env.root
293
+ const wiki = store.wikiDir(root)
294
+ const evidence: Evidence = { runs: {}, sources: {}, blocks: [] }
295
+ const blocksSeen = new Set<string>()
296
+ const captured = new Map<string, string>()
297
+ const conflicts = new Set<string>()
298
+ for (const job of prepared) {
299
+ const input = job.input
300
+ for (const id of job.prepared?.evidence.blocks ?? (input.kind === 'proposal' ? input.proposals.blocks.map((proposal) => proposal.block) : [])) blocksSeen.add(id)
301
+ for (const [id, hash] of job.hashes) {
302
+ const previous = captured.get(id)
303
+ if (previous !== undefined && previous !== hash) conflicts.add(id)
304
+ else captured.set(id, hash)
305
+ }
306
+ }
307
+ for (const id of conflicts) captured.set(id, `conflicting-batch-snapshot:${id}`)
308
+ evidence.blocks = [...blocksSeen]
309
+
310
+ const refs: SourceRef[] = [...proposals!.pages, ...proposals!.memories, ...proposals!.blocks].flatMap((proposal) => proposal.sourceRefs)
311
+ const excluded = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)?.excludedSourceIds
312
+ const excludedIds = Array.isArray(excluded) ? excluded.filter((id): id is string => typeof id === 'string') : []
313
+ for (const ref of refs) {
314
+ if (ref.kind === 'run' && findRun(root, ref.ref)) {
315
+ const caller = readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)
316
+ if (!excludedIds.includes(ref.ref) && caller?.vaultAccess !== 'curator') {
317
+ const text = evidenceText(root, ref.ref)
318
+ if (!looksSecret(text)) evidence.runs![ref.ref] = takeChars(text, 60_000)
319
+ }
320
+ }
321
+ if (ref.kind === 'source') {
322
+ const source = store.listSources(wiki).find((item) => item.id === ref.ref)
323
+ if (source) {
324
+ try {
325
+ const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
326
+ if (!looksSecret(text)) evidence.sources![ref.ref] = takeChars(text, 60_000)
327
+ } catch {
328
+ // Missing or unreadable evidence is rejected by the proposal validator.
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ const namespaceFor = (sourceRefs: readonly SourceRef[]): string => {
335
+ for (const ref of sourceRefs) {
336
+ const sourceJob = prepared.find((job) => {
337
+ if (job.input.kind === 'proposal') return job.input.caller.runId === ref.ref
338
+ if (job.input.kind === 'run') return job.input.runId === ref.ref
339
+ if (job.input.kind === 'ingest') return job.input.sourceId === ref.ref
340
+ return false
341
+ })
342
+ const namespace = sourceJob?.prepared?.namespace ?? (sourceJob?.input.kind === 'proposal' ? sourceJob.input.namespace : undefined)
343
+ if (namespace) return namespace
344
+ }
345
+ return prepared[0]?.prepared?.namespace ?? (prepared[0]?.input.kind === 'proposal' ? prepared[0].input.namespace : undefined) ?? store.LIBRARY_NAMESPACE
346
+ }
347
+ const batchProposals: ProposalSet = {
348
+ ...proposals!,
349
+ pages: proposals!.pages.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) })),
350
+ memories: proposals!.memories.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) }))
351
+ }
352
+ const trigger: Trigger = prepared.some((job) => job.input.kind === 'consolidate')
353
+ ? { kind: 'consolidate' }
354
+ : prepared.some((job) => job.input.kind === 'run')
355
+ ? { kind: 'run', runId: (prepared.find((job) => job.input.kind === 'run')!.input as Extract<Job, { kind: 'run' }>).runId }
356
+ : prepared.some((job) => job.input.kind === 'ingest')
357
+ ? (prepared.find((job) => job.input.kind === 'ingest')!.prepared?.trigger ?? { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' })
358
+ : { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' }
359
+ const outcome = apply(wiki, namespaceFor(refs), trigger, batchProposals, captured, evidence, operationId!, context.agentName ?? 'Curator')
360
+ if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
361
+ reindexQuietly(root)
362
+ this.options.bus.emit({ type: 'wiki.changed' })
363
+ let operationAssigned = false
364
+ for (const { decision, job: original } of wikiEntries) {
365
+ const job = curationJob(root, original.id)
366
+ const jobReason = decision.reason.trim()
367
+ job.status = outcome.parked ? 'needs_user' : 'completed'
368
+ job.reason = jobReason
369
+ job.receipt = {
370
+ operationId: outcome.operation && !operationAssigned ? outcome.operation.id : null,
371
+ notes: [...outcome.notes, ...(outcome.operation ? [`Combined batch operation ${outcome.operation.id}.`] : ['The combined review produced no Wiki changes.'])],
372
+ parked: outcome.parked,
373
+ flagged: outcome.flagged
374
+ }
375
+ if (outcome.operation && !operationAssigned) operationAssigned = true
376
+ results.set(job.id, saveCuration(root, job))
377
+ }
378
+ }
379
+
380
+ for (const { decision, job } of entries) {
381
+ if (results.has(job.id) || ['completed', 'rejected', 'needs_user'].includes(job.status)) {
382
+ results.set(job.id, curationJob(this.options.env.root, job.id))
383
+ continue
384
+ }
385
+ if (decision.decision === 'apply') {
386
+ results.set(job.id, this.applyJob(context, job.id, undefined, decision.reason))
387
+ } else {
388
+ results.set(job.id, this.decide(context, job.id, decision.decision, decision.reason))
389
+ }
390
+ }
391
+ const receiptOperationId = [...results.values()].find((job) => job.receipt?.operationId)?.receipt?.operationId ?? null
392
+ return { operationId: receiptOperationId, jobs: decisions.map(({ id }) => results.get(id) ?? curationJob(this.options.env.root, id)) }
393
+ }
394
+
236
395
  claim(context: CallerContext, id: string, prepare = true): CurationJob {
237
396
  requireCurator(context)
238
397
  const root = this.options.env.root
@@ -448,6 +607,8 @@ export class Maintenance {
448
607
  const wiki = store.wikiDir(root)
449
608
  let prepared: ReturnType<Maintenance['prepare']>
450
609
  try { prepared = this.prepare(job) } catch (error) {
610
+ // A run that is not eligible is an expected outcome, not a problem to report.
611
+ if (error instanceof NotEligible) return
451
612
  const message = error instanceof Error ? error.message : String(error)
452
613
  if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
453
614
  else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
@@ -3,6 +3,7 @@
3
3
  // and events stay canonical; the recall index is rebuilt from `runs/`.
4
4
  import type { DatabaseSync } from 'node:sqlite'
5
5
 
6
+ import { logLine } from '../core/bus'
6
7
  import type { HarnessEnv } from '../core/env'
7
8
  import { invalid, notFound } from '../core/errors'
8
9
  import { all, run as execute, transaction, withIndex } from '../core/index'
@@ -67,7 +68,7 @@ export function indexRun(root: string, runId: string): void {
67
68
  try {
68
69
  withIndex(root, (database) => writeRun(database, run, found))
69
70
  } catch (error) {
70
- process.stderr.write(`[recall] run ${runId} was not indexed: ${error instanceof Error ? error.message : String(error)}\n`)
71
+ logLine(`[recall] run ${runId} was not indexed: ${error instanceof Error ? error.message : String(error)}`)
71
72
  }
72
73
  }
73
74
 
@@ -0,0 +1,178 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, expect, test } from 'vitest'
5
+
6
+ import { createEnv } from '../core/env'
7
+ import { closeIndex } from '../core/index'
8
+ import { writeJson } from '../core/fs'
9
+ import { runRecordSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
10
+ import { appendEvent, saveRun } from '../sessions/runStore'
11
+ import { WIKI_AGENT_TOOLS } from '../tools/definitions'
12
+ import type { CallerContext } from './caller'
13
+ import { dispatch } from './dispatch'
14
+ import { readTool, searchTool } from './trajectory'
15
+
16
+ const roots: string[] = []
17
+ afterEach(() => roots.splice(0).forEach(closeIndex))
18
+
19
+ function setup() {
20
+ const root = mkdtempSync(join(tmpdir(), 'harness-trajectory-'))
21
+ roots.push(root)
22
+ writeJson(join(root, 'projects.json'), [
23
+ { id: 'p1', name: 'App', slug: 'app', rootPath: '/tmp/app', autoMaintenance: true, createdAt: 't' },
24
+ { id: 'p2', name: 'Site', slug: 'site', rootPath: '/tmp/site', autoMaintenance: true, createdAt: 't' }
25
+ ])
26
+ const env = createEnv(root, {
27
+ directory: {
28
+ agentName: (id) => ({ 'agent-a': 'Nora', 'agent-b': 'Kai', 'agent-hidden': 'Hidden' })[id] ?? null,
29
+ sessionAgent: (id) => id === 'hidden-session' ? 'agent-hidden' : null
30
+ }
31
+ })
32
+ return { root, env }
33
+ }
34
+
35
+ function run(id: string, overrides: Partial<RunRecord> = {}): RunRecord {
36
+ return runRecordSchema.parse({
37
+ id,
38
+ projectId: null,
39
+ mode: 'chat',
40
+ harness: 'pi',
41
+ sessionType: 'structured',
42
+ sessionId: `session-${id}`,
43
+ goal: `Goal for ${id}`,
44
+ startedAt: '2026-10-18T10:00:00.000Z',
45
+ endedAt: null,
46
+ status: 'running',
47
+ agentId: 'agent-a',
48
+ ...overrides
49
+ })
50
+ }
51
+
52
+ function event(runId: string, sequence: number, type: RunEvent['type'], payload: unknown, native?: unknown): RunEvent {
53
+ return {
54
+ id: `${runId}-event-${sequence}`,
55
+ runId,
56
+ sessionId: `session-${runId}`,
57
+ sequence,
58
+ timestamp: `2026-10-18T10:00:${String(sequence).padStart(2, '0')}.000Z`,
59
+ type,
60
+ payload,
61
+ ...(native === undefined ? {} : { native })
62
+ } as RunEvent
63
+ }
64
+
65
+ function save(root: string, record: RunRecord, events: RunEvent[] = []): void {
66
+ saveRun(root, record)
67
+ for (const entry of events) appendEvent(root, entry)
68
+ }
69
+
70
+ const agent: CallerContext = { agentId: 'agent-a', actor: 'Chat' }
71
+
72
+ test('trajectory search filters runs and paginates deterministically, including active runs', () => {
73
+ const { root, env } = setup()
74
+ save(root, run('z-run', { projectId: 'p2', agentId: 'agent-b' }))
75
+ save(root, run('b-run', { projectId: 'p2', agentId: 'agent-b' }))
76
+ save(root, run('a-run', { projectId: 'p1', agentId: 'agent-a', startedAt: '2026-10-17T10:00:00.000Z', status: 'completed', endedAt: '2026-10-17T10:01:00.000Z' }))
77
+
78
+ const first = JSON.parse(searchTool(env, agent, { project: 'site', agent: 'Kai', limit: 1 }))
79
+ expect(first.runs.map((item: { id: string }) => item.id)).toEqual(['z-run'])
80
+ expect(first.runs[0]).toMatchObject({ agentId: 'agent-b', agentName: 'Kai', project: { id: 'p2', name: 'Site', slug: 'site' }, status: 'running' })
81
+ expect(first.hasMore).toBe(true)
82
+
83
+ const second = JSON.parse(searchTool(env, agent, { project: 'p2', agent: 'agent-b', limit: 1, cursor: first.nextCursor }))
84
+ expect(second.runs.map((item: { id: string }) => item.id)).toEqual(['b-run'])
85
+ expect(second.hasMore).toBe(false)
86
+
87
+ const bounded = JSON.parse(searchTool(env, agent, { since: '2026-10-18', until: '2026-10-18', status: 'running' }))
88
+ expect(bounded.runs.map((item: { id: string }) => item.id)).toEqual(['z-run', 'b-run'])
89
+ })
90
+
91
+ test('trajectory search and read keep host-excluded agents hidden', () => {
92
+ const { root, env } = setup()
93
+ save(root, run('hidden-agent-run', { agentId: 'agent-hidden' }))
94
+ save(root, run('hidden-session-run', { agentId: null, sessionId: 'hidden-session' }))
95
+ save(root, run('visible-run'))
96
+ const caller = { ...agent, recallExcludeAgents: ['agent-hidden'] }
97
+
98
+ const results = JSON.parse(searchTool(env, caller, {}))
99
+ expect(results.runs.map((item: { id: string }) => item.id)).toEqual(['visible-run'])
100
+ expect(() => readTool(env, caller, { run: 'hidden-agent-run' })).toThrow()
101
+ expect(() => readTool(env, caller, { run: 'hidden-session-run' })).toThrow()
102
+ })
103
+
104
+ test('trajectory_read resolves current and returns bounded event pages without native data', () => {
105
+ const { root, env } = setup()
106
+ const longInput = 'x'.repeat(20_000)
107
+ save(root, run('active-run'), [
108
+ event('active-run', 0, 'context.snapshot', { systemPrompt: 'Be useful', tools: [] }, { privateNative: true }),
109
+ event('active-run', 1, 'tool.started', { toolCallId: 't1', name: 'read', input: longInput }),
110
+ event('active-run', 2, 'tool.completed', { toolCallId: 't1', name: 'read', output: 'file contents' })
111
+ ])
112
+ const caller = { ...agent, runId: 'active-run' }
113
+
114
+ const initial = JSON.parse(readTool(env, caller, { run: 'current', limit: 1 }))
115
+ expect(initial.events[0]).toMatchObject({ sequence: 0, type: 'context.snapshot', payload: { systemPrompt: 'Be useful', tools: [] } })
116
+ expect(JSON.stringify(initial)).not.toContain('privateNative')
117
+
118
+ const first = JSON.parse(readTool(env, caller, { run: 'current', afterSequence: 0, limit: 1 }))
119
+ expect(first).toMatchObject({ active: true, run: { id: 'active-run', status: 'running' }, hasMore: true, nextAfterSequence: 1 })
120
+ expect(first.events[0]).toMatchObject({ sequence: 1, type: 'tool.started', truncatedFields: ['payload.input'] })
121
+ expect(JSON.stringify(first)).not.toContain('privateNative')
122
+ expect(first.events[0].payload.input.length).toBeLessThan(longInput.length)
123
+
124
+ const second = JSON.parse(readTool(env, caller, { run: 'active-run', afterSequence: first.nextAfterSequence, limit: 1 }))
125
+ expect(second.events[0]).toMatchObject({ sequence: 2, type: 'tool.completed', payload: { output: 'file contents' } })
126
+ expect(second.hasMore).toBe(false)
127
+ })
128
+
129
+ test('trajectory_read follows execution events through the first outcome only', () => {
130
+ const { root, env } = setup()
131
+ save(root, run('settled-run', { status: 'completed', endedAt: '2026-10-18T10:01:00.000Z' }), [
132
+ event('settled-run', 0, 'started', {}),
133
+ event('settled-run', 1, 'completed', {}),
134
+ event('settled-run', 2, 'tool.started', { toolCallId: 'late', name: 'ignored' }, { hostIgnored: 'run_already_ended' })
135
+ ])
136
+
137
+ const result = JSON.parse(readTool(env, agent, { run: 'settled-run' }))
138
+ expect(result.active).toBe(false)
139
+ expect(result.events.map((item: { sequence: number }) => item.sequence)).toEqual([0, 1])
140
+ expect(JSON.stringify(result)).not.toContain('ignored')
141
+ })
142
+
143
+ test('trajectory_read keeps account identifiers and credential fields out of event payloads', () => {
144
+ const { root, env } = setup()
145
+ save(root, run('safe-run'), [
146
+ event('safe-run', 0, 'account.status', { accountId: 'private-account-id', category: 'quota', message: 'Limit reached for sk-abcdefghijklmnopqrstuvwxyz012345' }),
147
+ event('safe-run', 1, 'tool.started', { toolCallId: 't1', name: 'login', input: '{"username":"ada","password":"private-password"}' }),
148
+ event('safe-run', 2, 'usage.updated', { inputTokens: 10, request: { index: 1, reasoningSummary: 'private reasoning summary' } })
149
+ ])
150
+
151
+ const result = JSON.parse(readTool(env, agent, { run: 'safe-run' }))
152
+ expect(result.events[0].omittedFields).toContain('payload.accountId')
153
+ expect(JSON.stringify(result)).not.toContain('private-account-id')
154
+ expect(JSON.stringify(result)).not.toContain('sk-abcdefghijklmnopqrstuvwxyz012345')
155
+ expect(JSON.stringify(result)).not.toContain('private-password')
156
+ expect(result.events[1].payload.input).not.toContain('password')
157
+ expect(result.events[2].omittedFields).toContain('payload.request.reasoningSummary')
158
+ expect(JSON.stringify(result)).not.toContain('private reasoning summary')
159
+ })
160
+
161
+ test('trajectory calls reject malformed filters and missing current runs', () => {
162
+ const { env } = setup()
163
+ expect(() => searchTool(env, agent, { status: 'unknown' })).toThrow()
164
+ expect(() => searchTool(env, agent, { since: 'not-a-date' })).toThrow()
165
+ expect(() => searchTool(env, agent, { cursor: 'not-a-cursor' })).toThrow()
166
+ expect(() => readTool(env, agent, { run: 'current' })).toThrow()
167
+ expect(() => readTool(env, agent, { run: 'missing' })).toThrow()
168
+ })
169
+
170
+ test('the model tool registry exposes both trajectory calls through Wiki dispatch', () => {
171
+ const { root, env } = setup()
172
+ save(root, run('registered-run'))
173
+ const calls = WIKI_AGENT_TOOLS.map((tool) => tool.call)
174
+ expect(calls).toContain('trajectory.search')
175
+ expect(calls).toContain('trajectory.read')
176
+ expect(JSON.parse(dispatch(env, agent, 'trajectory.search', {})).runs[0].id).toBe('registered-run')
177
+ expect(JSON.parse(dispatch(env, agent, 'trajectory.read', { run: 'registered-run' })).run.id).toBe('registered-run')
178
+ })
@@ -0,0 +1,220 @@
1
+ // Read-only views over the canonical run records and validated event logs.
2
+ import type { HarnessEnv } from '../core/env'
3
+ import { invalid, notFound } from '../core/errors'
4
+ import { listProjects } from '../core/projects'
5
+ import { compareText } from '../core/text'
6
+ import { scrub } from '../accounts/store'
7
+ import { scrubSensitive } from '../mcp/connections'
8
+ import { isActiveStatus, runStatusSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
9
+ import { readAllRuns } from '../sessions/runIndex'
10
+ import { findRun, snapshot } from '../sessions/runStore'
11
+ import type { CallerContext } from './caller'
12
+
13
+ const SEARCH_DEFAULT = 10
14
+ const SEARCH_MAX = 30
15
+ const READ_DEFAULT = 8
16
+ const READ_MAX = 10
17
+ const EVENT_MAX_CHARS = 18_000
18
+ const PAGE_MAX_CHARS = 48_000
19
+
20
+ export type Params = Record<string, unknown> | null | undefined
21
+
22
+ function textParam(params: Params, key: string): string {
23
+ const value = params?.[key]
24
+ return typeof value === 'string' ? value.trim() : ''
25
+ }
26
+
27
+ function boundedInteger(params: Params, key: string, fallback: number, minimum: number, maximum: number): number {
28
+ const value = params?.[key]
29
+ if (value === undefined) return fallback
30
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum || value > maximum) {
31
+ throw invalid(`${key} must be an integer from ${minimum} to ${maximum}.`)
32
+ }
33
+ return value
34
+ }
35
+
36
+ function dateValue(params: Params, key: string): { text: string; timestamp: number } | null {
37
+ const value = textParam(params, key)
38
+ if (!value) return null
39
+ if (!/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value)) throw invalid(`${key} must be a YYYY-MM-DD date or ISO timestamp.`)
40
+ const timestamp = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}${key === 'until' ? 'T23:59:59.999Z' : 'T00:00:00.000Z'}` : value)
41
+ if (!Number.isFinite(timestamp)) throw invalid(`${key} must be a valid date or ISO timestamp.`)
42
+ return { text: value, timestamp }
43
+ }
44
+
45
+ interface Cursor {
46
+ startedAt: string
47
+ id: string
48
+ }
49
+
50
+ function decodeCursor(value: string): Cursor | null {
51
+ if (!value) return null
52
+ try {
53
+ const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial<Cursor>
54
+ if (typeof parsed.startedAt !== 'string' || !parsed.startedAt || typeof parsed.id !== 'string' || !parsed.id) throw new Error('invalid cursor')
55
+ return { startedAt: parsed.startedAt, id: parsed.id }
56
+ } catch {
57
+ throw invalid('The trajectory cursor is invalid. Start a new search without it.')
58
+ }
59
+ }
60
+
61
+ function encodeCursor(run: RunRecord): string {
62
+ return Buffer.from(JSON.stringify({ startedAt: run.startedAt, id: run.id })).toString('base64url')
63
+ }
64
+
65
+ function hidden(env: HarnessEnv, context: CallerContext, run: RunRecord): boolean {
66
+ const excluded = context.recallExcludeAgents ?? []
67
+ if (!excluded.length) return false
68
+ return excluded.includes(run.agentId ?? '') || excluded.includes(run.sessionId) || excluded.includes(env.directory.sessionAgent(run.sessionId) ?? '')
69
+ }
70
+
71
+ function afterCursor(run: RunRecord, cursor: Cursor): boolean {
72
+ return run.startedAt < cursor.startedAt || (run.startedAt === cursor.startedAt && run.id < cursor.id)
73
+ }
74
+
75
+ function summary(env: HarnessEnv, run: RunRecord, projects: ReturnType<typeof listProjects>) {
76
+ const project = projects.find((entry) => entry.id === run.projectId)
77
+ const goal = run.goal?.trim() ?? ''
78
+ return {
79
+ id: run.id,
80
+ agentId: run.agentId,
81
+ agentName: run.agentId ? env.directory.agentName(run.agentId) : null,
82
+ project: project ? { id: project.id, name: project.name, slug: project.slug } : null,
83
+ mode: run.mode,
84
+ status: run.status,
85
+ goal: goal.length > 500 ? `${goal.slice(0, 497)}…` : goal || null,
86
+ startedAt: run.startedAt,
87
+ endedAt: run.endedAt,
88
+ parentRunId: run.parentRunId,
89
+ relation: run.relation
90
+ }
91
+ }
92
+
93
+ /** `trajectory.search`: structured list/search over the current Harness data root. */
94
+ export function searchTool(env: HarnessEnv, context: CallerContext, params: Params): string {
95
+ const agent = textParam(params, 'agent')
96
+ const projectKey = textParam(params, 'project')
97
+ const mode = textParam(params, 'mode')
98
+ const status = textParam(params, 'status')
99
+ const since = dateValue(params, 'since')
100
+ const until = dateValue(params, 'until')
101
+ const limit = boundedInteger(params, 'limit', SEARCH_DEFAULT, 1, SEARCH_MAX)
102
+ const cursor = decodeCursor(textParam(params, 'cursor'))
103
+
104
+ if (status && !runStatusSchema.safeParse(status).success) throw invalid(`Unknown run status ${status}.`)
105
+ const projects = listProjects(env.root)
106
+ const project = projectKey ? projects.find((entry) => entry.id === projectKey || entry.slug === projectKey || entry.name.toLowerCase() === projectKey.toLowerCase()) : null
107
+ if (projectKey && !project) throw invalid(`There is no project called ${projectKey}.`)
108
+
109
+ const matching = readAllRuns(env.root)
110
+ .filter((run) => !hidden(env, context, run))
111
+ .filter((run) => !agent || run.agentId === agent || (run.agentId !== null && env.directory.agentName(run.agentId)?.toLowerCase() === agent.toLowerCase()))
112
+ .filter((run) => !projectKey || run.projectId === project?.id)
113
+ .filter((run) => !mode || run.mode === mode)
114
+ .filter((run) => !status || run.status === status)
115
+ .filter((run) => !since || Date.parse(run.startedAt) >= since.timestamp)
116
+ .filter((run) => !until || Date.parse(run.startedAt) <= until.timestamp)
117
+ .filter((run) => !cursor || afterCursor(run, cursor))
118
+ .sort((left, right) => compareText(right.startedAt, left.startedAt) || compareText(right.id, left.id))
119
+
120
+ const page = matching.slice(0, limit)
121
+ const hasMore = matching.length > page.length
122
+ return JSON.stringify({
123
+ runs: page.map((run) => summary(env, run, projects)),
124
+ hasMore,
125
+ nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null
126
+ })
127
+ }
128
+
129
+ function boundedValue(value: unknown, path: string, truncated: string[], stringLimit: number, arrayLimit: number, depth = 0): unknown {
130
+ if (typeof value === 'string') {
131
+ const fieldLimit = path.endsWith('.systemPrompt') || path.endsWith('.contextUpdate') ? Math.min(12_000, stringLimit) :
132
+ path.endsWith('.input') || path.endsWith('.output') || path.endsWith('.summary') ? Math.min(10_000, stringLimit) : stringLimit
133
+ if (value.length <= fieldLimit) return value
134
+ truncated.push(path)
135
+ return `${value.slice(0, Math.max(0, fieldLimit - 16))}… [truncated]`
136
+ }
137
+ if (Array.isArray(value)) {
138
+ const kept = value.slice(0, arrayLimit).map((item, index) => boundedValue(item, `${path}[${index}]`, truncated, stringLimit, arrayLimit, depth + 1))
139
+ if (value.length > arrayLimit) truncated.push(path)
140
+ return kept
141
+ }
142
+ if (!value || typeof value !== 'object' || depth >= 8) return value
143
+ const entries = Object.entries(value as Record<string, unknown>)
144
+ const kept = entries.slice(0, 40).map(([key, item]) => [key, boundedValue(item, `${path}.${key}`, truncated, stringLimit, arrayLimit, depth + 1)])
145
+ if (entries.length > 40) truncated.push(path)
146
+ return Object.fromEntries(kept)
147
+ }
148
+
149
+ function publicEvent(event: RunEvent): Record<string, unknown> {
150
+ let stringLimit = 12_000
151
+ let arrayLimit = 20
152
+ while (true) {
153
+ const truncated: string[] = []
154
+ const omitted: string[] = []
155
+ let payload = scrubSensitive(event.payload)
156
+ if (event.type === 'account.status' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
157
+ const account = payload as Record<string, unknown>
158
+ if (Object.hasOwn(account, 'accountId')) {
159
+ delete account.accountId
160
+ omitted.push('payload.accountId')
161
+ }
162
+ if (typeof account.message === 'string') account.message = scrub(account.message)
163
+ }
164
+ if (event.type === 'usage.updated' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
165
+ const request = (payload as Record<string, unknown>).request
166
+ if (request && typeof request === 'object' && !Array.isArray(request) && Object.hasOwn(request, 'reasoningSummary')) {
167
+ delete (request as Record<string, unknown>).reasoningSummary
168
+ omitted.push('payload.request.reasoningSummary')
169
+ }
170
+ }
171
+ const value: Record<string, unknown> = {
172
+ id: event.id,
173
+ sequence: event.sequence,
174
+ timestamp: event.timestamp,
175
+ type: event.type,
176
+ payload: boundedValue(payload, 'payload', truncated, stringLimit, arrayLimit)
177
+ }
178
+ if (event.coalescedFrom !== undefined) value.coalescedFrom = event.coalescedFrom
179
+ if (truncated.length) value.truncatedFields = truncated.slice(0, 40)
180
+ if (omitted.length) value.omittedFields = omitted
181
+ if (JSON.stringify(value).length <= EVENT_MAX_CHARS || (stringLimit <= 500 && arrayLimit <= 1)) return value
182
+ stringLimit = Math.max(500, Math.floor(stringLimit / 2))
183
+ arrayLimit = Math.max(1, Math.floor(arrayLimit / 2))
184
+ }
185
+ }
186
+
187
+ /** `trajectory.read`: a bounded page of the events that affected a run. */
188
+ export function readTool(env: HarnessEnv, context: CallerContext, params: Params): string {
189
+ const requested = textParam(params, 'run')
190
+ if (!requested) throw invalid('Give a run id or "current".')
191
+ const runId = requested === 'current' ? context.runId ?? '' : requested
192
+ if (!runId) throw invalid('This caller has no current run.')
193
+ const run = findRun(env.root, runId)
194
+ if (!run || hidden(env, context, run)) throw notFound('Run')
195
+
196
+ const afterSequence = boundedInteger(params, 'afterSequence', -1, -1, Number.MAX_SAFE_INTEGER)
197
+ const limit = boundedInteger(params, 'limit', READ_DEFAULT, 1, READ_MAX)
198
+ const captured = snapshot(env.root, run.id)
199
+ const candidates = captured.events.filter((event) => event.sequence > afterSequence)
200
+ const events: Record<string, unknown>[] = []
201
+ let eventChars = 0
202
+ for (const event of candidates.slice(0, limit)) {
203
+ const bounded = publicEvent(event)
204
+ const size = JSON.stringify(bounded).length
205
+ if (events.length && eventChars + size > PAGE_MAX_CHARS) break
206
+ events.push(bounded)
207
+ eventChars += size
208
+ }
209
+ const lastSequence = events.length ? Number(events[events.length - 1].sequence) : afterSequence
210
+ const hasMore = candidates.some((event) => event.sequence > lastSequence)
211
+ const projects = listProjects(env.root)
212
+ const result = {
213
+ run: summary(env, captured.run, projects),
214
+ active: isActiveStatus(captured.run.status),
215
+ events,
216
+ hasMore,
217
+ nextAfterSequence: lastSequence
218
+ }
219
+ return JSON.stringify(result)
220
+ }