@xingular/harness 0.1.6 → 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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -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,13 +21,20 @@ 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'
26
+ import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
27
+ import * as registry from '../skills/registry'
28
+ import * as attachments from '../plugins/attachments'
29
+ import { parkPluginProposal, pluginProposalSchema } from '../plugins/proposal'
30
+ import { registryProposalSchema } from './proposals'
31
+ import { writeJson } from '../core/fs'
32
+ import { invalid } from '../core/errors'
25
33
  import * as store from './store'
26
34
  import { namespaces, searchEntries } from './wiki'
27
35
  import { reindexQuietly } from './wikiIndex'
28
36
 
29
- export type Job = { kind: 'ingest'; namespace: string; sourceId: string } | { kind: 'run'; runId: string } | { kind: 'consolidate'; blockIds: string[] }
37
+ export type Job = { kind: 'ingest'; namespace: string; sourceId: string } | { kind: 'run'; runId: string; namespace?: string } | { kind: 'consolidate'; blockIds: string[] } | { kind: 'review'; requestId?: string; manual?: boolean }
30
38
 
31
39
  const FULL_PAGES = 5
32
40
  const FULL_PAGE_LIMIT = 6000
@@ -39,27 +47,30 @@ export function chatUpdatesPaused(settings: unknown): boolean {
39
47
  return (settings as { autoMaintenanceChat?: unknown } | null)?.autoMaintenanceChat === false
40
48
  }
41
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
+
42
53
  /** Where a settled run's knowledge is filed and what the job reads of it; throws the reason it is not eligible. */
43
54
  function runEvidence(root: string, runId: string): { namespace: string; transcript: string } {
44
55
  const run = findRun(root, runId)
45
56
  if (!run) throw new Error('Run was not found.')
46
57
  const settings = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)
47
- 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')
48
59
  const projectId = run.mode === 'chat' || run.mode === 'code' ? run.projectId : null
49
60
  let namespace: string
50
61
  if (projectId) {
51
62
  const project = findProject(root, projectId)
52
- 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')
53
64
  namespace = project.wikiNamespace
54
65
  } else if (run.mode === 'chat') {
55
- 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')
56
67
  namespace = store.LIBRARY_NAMESPACE
57
68
  } else {
58
- throw new Error('run has no project')
69
+ throw new NotEligible('run has no project')
59
70
  }
60
71
  const text = transcript(run.goal ?? '', readEvents(root, runId))
61
- if (!text.includes('Assistant:')) throw new Error('no recorded assistant output to use as evidence')
62
- 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')
63
74
  return { namespace, transcript: text }
64
75
  }
65
76
 
@@ -137,6 +148,12 @@ export function fullBlocks(wiki: string, tried: Set<string>): string[] {
137
148
 
138
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 }))
139
150
 
151
+ export interface MaintenanceBatchDecision {
152
+ id: string
153
+ decision: 'apply' | 'reject' | 'needs_user'
154
+ reason: string
155
+ }
156
+
140
157
  export interface MaintenanceOptions {
141
158
  env: HarnessEnv
142
159
  bus: Bus
@@ -144,6 +161,10 @@ export interface MaintenanceOptions {
144
161
  factory?: MaintainerFactory
145
162
  /** How long the queue stays empty before full blocks are condensed. */
146
163
  idleMs?: number
164
+ external?: boolean
165
+ excludedTriggers?: string[]
166
+ /** False: a settled run queues no consolidation of its own. */
167
+ runTrigger?: boolean
147
168
  }
148
169
 
149
170
  export class Maintenance {
@@ -157,6 +178,13 @@ export class Maintenance {
157
178
  /** Starts the idle check. Jobs can be queued whether or not it runs. */
158
179
  start(): void {
159
180
  this.stopped = false
181
+ if (this.options.external) {
182
+ for (const job of this.jobs().filter((job) => job.status === 'running')) {
183
+ job.status = 'failed'
184
+ job.reason = 'The previous process stopped before this request settled. Retry it explicitly.'
185
+ saveCuration(this.options.env.root, job)
186
+ }
187
+ }
160
188
  this.rest()
161
189
  }
162
190
 
@@ -188,6 +216,22 @@ export class Maintenance {
188
216
 
189
217
  enqueue(job: Job): void {
190
218
  if (this.idle) clearTimeout(this.idle)
219
+ if (this.options.external) {
220
+ if (job.kind === 'run') {
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
+ }
227
+ const run = findRun(this.options.env.root, job.runId)
228
+ const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
229
+ enqueueCuration(this.options.env.root, { ...job, namespace })
230
+ } else enqueueCuration(this.options.env.root, job)
231
+ this.options.bus.emit({ type: 'maintenance.queued' })
232
+ this.rest()
233
+ return
234
+ }
191
235
  this.chain = this.chain
192
236
  .then(() => this.process(job))
193
237
  .catch((error) => log('maintenance', error instanceof Error ? error.message : String(error)))
@@ -196,11 +240,302 @@ export class Maintenance {
196
240
 
197
241
  /** A settled run is evidence; whether it is eligible is decided when its job runs. */
198
242
  onRunEvent(event: RunEvent): void {
243
+ if (this.options.runTrigger === false) return
244
+ const caller = readJsonOr<CallerContext | null>(join(this.options.env.root, 'runs', event.runId, 'caller.json'), null)
245
+ if (caller?.vaultAccess === 'curator' || this.options.excludedTriggers?.includes(findRun(this.options.env.root, event.runId)?.trigger ?? '')) return
199
246
  if (event.type === 'completed') this.enqueue({ kind: 'run', runId: event.runId })
200
247
  }
201
248
 
202
- private async process(job: Job): Promise<void> {
203
- const { env, bus } = this.options
249
+ jobs(): CurationJob[] { return listCuration(this.options.env.root) }
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
+
395
+ claim(context: CallerContext, id: string, prepare = true): CurationJob {
396
+ requireCurator(context)
397
+ const root = this.options.env.root
398
+ const job = curationJob(root, id)
399
+ if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
400
+ if (context.maintenanceJobIds !== undefined && !context.maintenanceJobIds.includes(id) && !('caller' in job.input && job.input.caller.runId === context.runId)) throw invalid('This request is not assigned to this run.')
401
+ if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
402
+ job.runId = context.runId!
403
+ if (prepare && job.input.kind !== 'proposal' && job.input.kind !== 'registry' && job.input.kind !== 'plugin' && !job.prepared) {
404
+ let prepared: ReturnType<Maintenance['prepare']>
405
+ try { prepared = this.prepare(job.input) } catch (error) { job.status = 'failed'; job.reason = error instanceof Error ? error.message : String(error); saveCuration(root, job); throw error }
406
+ job.prepared = { request: prepared.request, evidence: prepared.evidence, namespace: prepared.namespace, trigger: prepared.trigger }
407
+ job.hashes = [...prepared.captured]
408
+ if (job.prepared.evidence.sourceText) job.prepared.evidence.sourceText = takeChars(job.prepared.evidence.sourceText, 60_000)
409
+ }
410
+ if (prepare && job.input.kind === 'proposal' && !job.prepared) {
411
+ const input = job.input
412
+ const origin = input.caller.runId
413
+ const attached = input.proposals.blocks.map((proposal) => blocks.find(store.wikiDir(root), proposal.block))
414
+ const evidence = origin && input.caller.vaultAccess !== 'curator' ? evidenceText(root, origin) : ''
415
+ const context = maintainerContext(root, input.namespace, evidence)
416
+ job.prepared = { request: { jobId: job.id, namespace: input.namespace, trigger: { kind: 'run', runId: origin ?? '', transcript: takeChars(evidence, 24_000) }, pages: context.pages, memories: context.memories, blocks: blockValues(attached), index: '' }, evidence: { runText: evidence, blocks: attached.map((block) => block.id) }, namespace: input.namespace, trigger: { kind: 'agent', runId: origin ?? null, actor: 'Curator' } }
417
+ }
418
+ job.status = 'running'
419
+ job.runId = context.runId!
420
+ return saveCuration(root, job)
421
+ }
422
+
423
+ readJob(context: CallerContext, id: string): unknown {
424
+ const job = this.claim(context, id)
425
+ return { id: job.id, status: job.status, input: job.input, context: job.prepared?.request ?? null, receipt: job.receipt }
426
+ }
427
+
428
+ decide(context: CallerContext, id: string, decision: 'reject' | 'needs_user', reason: string): CurationJob {
429
+ if (!reason.trim()) throw invalid('Explain the decision.')
430
+ const job = this.claim(context, id, false)
431
+ if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
432
+ job.status = decision === 'reject' ? 'rejected' : 'needs_user'
433
+ job.reason = reason.trim()
434
+ job.receipt = { operationId: null, notes: [job.reason], parked: decision === 'needs_user' ? 1 : 0, flagged: 0 }
435
+ if (decision === 'needs_user') {
436
+ const input = job.input
437
+ if (input.kind === 'registry') {
438
+ const proposal = input.proposal
439
+ inbox(store.wikiDir(this.options.env.root), 'proposed', `Review ${proposal.kind}: ${proposal.name}`, reason, [], null, { kind: proposal.kind, proposal, curationJobId: id })
440
+ } else if (input.kind === 'plugin') parkPluginProposal(store.wikiDir(this.options.env.root), `Curated request ${id}`, pluginProposalSchema.parse(input.proposal))
441
+ else inbox(store.wikiDir(this.options.env.root), 'unresolved', 'Maintenance needs your decision', reason, [])
442
+ }
443
+ this.options.bus.emit({ type: 'wiki.changed' })
444
+ return saveCuration(this.options.env.root, job)
445
+ }
446
+
447
+ applyJob(context: CallerContext, id: string, replacement?: unknown, reason = '', scope?: attachments.AttachScope): CurationJob {
448
+ if (!reason.trim()) throw invalid('Explain why this change should be applied.')
449
+ const root = this.options.env.root
450
+ const wiki = store.wikiDir(root)
451
+ const job = this.claim(context, id)
452
+ if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
453
+ const committed = readJsonOr<{ id: string } | null>(join(wiki, 'operations', `${job.id}.json`), null)
454
+ if (committed) {
455
+ job.status = 'completed'
456
+ job.receipt = { operationId: committed.id, notes: ['This request was already committed.'], parked: 0, flagged: 0 }
457
+ return saveCuration(root, job)
458
+ }
459
+ const input = job.input
460
+ if (input.kind === 'plugin') return this.decide(context, id, 'needs_user', reason || 'Plugin files require user review before activation; executable code also needs explicit trust.')
461
+ if (input.kind === 'registry') {
462
+ const proposal = registryProposalSchema.parse(input.proposal)
463
+ const itemId = `${proposal.kind}s/${store.slug(proposal.name)}`
464
+ if (!['skill', 'prompt', 'rule'].includes(proposal.kind) || !proposal.name.trim() || !proposal.body.trim() || looksSecret(proposal.body)) throw invalid('Invalid text resource proposal.')
465
+ const existing = registry.list(root).find((item) => item.id === itemId)
466
+ const current = captureHashes(wiki).get(itemId)
467
+ if (existing?.builtin || current !== new Map(job.hashes).get(itemId)) return this.decide(context, id, 'needs_user', 'The resource is built-in or changed since it was proposed. Review the current version.')
468
+ const target = scope ?? { agentId: input.caller.agentId ?? context.agentId }
469
+ if (target.agentId && target.projectId) throw invalid('Choose one attachment scope.')
470
+ if (target.agentId && target.agentId !== context.agentId && target.agentId !== input.caller.agentId && !this.options.env.directory.agentName(target.agentId)) throw invalid('Unknown attachment target.')
471
+ if (target.projectId) findProject(root, target.projectId)
472
+ const before = existing ? store.snapshot(wiki, itemId, readFileSync(existing.path, 'utf8'), 'Curator', `Before ${id}`).id : null
473
+ const beforeChoices = attachments.load(root)
474
+ registry.writeItem(root, proposal.kind, proposal.name, proposal.description, proposal.body, `Curated request ${id}: ${reason || proposal.reason}`, context.agentName ?? 'Curator')
475
+ const saved = registry.list(root).find((item) => item.id === itemId)!
476
+ const after = store.currentRevision(wiki, itemId)!
477
+ const choices = attachments.load(root)
478
+ attachments.set(choices, [itemId], target, true, false)
479
+ attachments.save(root, choices)
480
+ writeJson(join(wiki, 'operations', `${id}.json`), { id, createdAt: now(), trigger: `Curated request ${id}`, pages: [{ pageId: itemId, path: saved.path, beforeRevisionId: before, afterRevisionId: after }], undone: false, protected: true, attachmentsBefore: beforeChoices, attachmentsAfter: choices })
481
+ job.receipt = { operationId: id, notes: [`Saved ${itemId}; enabled for ${target.agentId ?? target.projectId ?? 'everyone'}. ${reason || proposal.reason}`], parked: 0, flagged: 0 }
482
+ this.options.bus.emit({ type: 'plugins.changed' })
483
+ } else {
484
+ const prepared = job.prepared
485
+ const proposalInput = input.kind === 'proposal' ? input : null
486
+ const proposals = parseProposalSet(replacement ?? proposalInput?.proposals)
487
+ const origin = proposalInput?.caller.runId ?? null
488
+ const trigger: Trigger = prepared?.trigger ?? { kind: 'agent', runId: origin, actor: context.agentName ?? 'Curator' }
489
+ const evidence: Evidence = input.kind === 'proposal' ? (prepared?.evidence ?? {}) : this.prepare(input).evidence
490
+ const originEvidence: Evidence = prepared?.evidence ?? { runText: origin ? evidenceText(root, origin) : null, blocks: proposals.blocks.map((block) => block.block) }
491
+ {
492
+ if (input.kind === 'proposal') Object.assign(evidence, originEvidence)
493
+ evidence.runs = {}
494
+ evidence.sources = {}
495
+ const refs = [...proposals.pages, ...proposals.memories, ...proposals.blocks].flatMap(proposal => proposal.sourceRefs).slice(0, 20)
496
+ for (const ref of refs) {
497
+ if (ref.kind === 'run' && findRun(root, ref.ref) && readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)?.vaultAccess !== 'curator') {
498
+ const excluded = readJsonOr<{ excludedSourceIds?: string[] }>(join(root, 'config/settings.json'), {}).excludedSourceIds ?? []
499
+ const text = evidenceText(root, ref.ref)
500
+ if (!excluded.includes(ref.ref) && !looksSecret(text)) evidence.runs[ref.ref] = takeChars(text, 60_000)
501
+ }
502
+ if (ref.kind === 'source') {
503
+ const source = store.listSources(wiki).find(source => source.id === ref.ref)
504
+ if (source) {
505
+ const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
506
+ if (!looksSecret(text)) evidence.sources[ref.ref] = takeChars(text, 60_000)
507
+ }
508
+ }
509
+ }
510
+ }
511
+ const outcome = apply(wiki, prepared?.namespace ?? proposalInput?.namespace ?? store.LIBRARY_NAMESPACE, trigger, proposals, new Map(job.hashes), evidence, id, context.agentName ?? 'Curator')
512
+ if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
513
+ job.receipt = { operationId: outcome.operation?.id ?? null, notes: outcome.notes, parked: outcome.parked, flagged: outcome.flagged }
514
+ }
515
+ job.status = job.receipt.parked ? 'needs_user' : 'completed'
516
+ job.reason = reason
517
+ reindexQuietly(root)
518
+ this.options.bus.emit({ type: 'wiki.changed' })
519
+ return saveCuration(root, job)
520
+ }
521
+
522
+ finishRun(runId: string, failed: boolean): void {
523
+ for (const job of this.jobs().filter((job) => job.runId === runId && job.status === 'running')) {
524
+ job.status = failed ? 'failed' : 'completed'
525
+ job.reason = failed ? 'The run stopped before completing this request. Retry it explicitly.' : 'The review completed without applying a change.'
526
+ job.receipt ??= { operationId: null, notes: [job.reason], parked: 0, flagged: 0 }
527
+ saveCuration(this.options.env.root, job)
528
+ }
529
+ }
530
+
531
+ retry(id: string): CurationJob {
532
+ const job = curationJob(this.options.env.root, id)
533
+ if (job.status !== 'failed') throw invalid('Only a failed request can be retried.')
534
+ return saveCuration(this.options.env.root, { ...job, status: 'queued', runId: null, reason: '', receipt: null })
535
+ }
536
+
537
+ prepare(job: Job): { request: MaintenanceRequest; evidence: Evidence; captured: Map<string, string>; namespace: string; trigger: Trigger } {
538
+ const { env } = this.options
204
539
  const root = env.root
205
540
  const wiki = store.wikiDir(root)
206
541
  const evidence: Evidence = {}
@@ -220,9 +555,7 @@ export class Maintenance {
220
555
  text = null
221
556
  }
222
557
  if (text === null) {
223
- inbox(wiki, 'unresolved', `${sourceName} was imported but not summarized`, 'Only text sources can be synthesized automatically right now.', [])
224
- bus.emit({ type: 'wiki.changed' })
225
- return
558
+ throw invalid('Only text sources can be synthesized automatically right now.')
226
559
  }
227
560
  shown = takeChars(text, 60_000)
228
561
  evidence.sourceText = text
@@ -234,8 +567,7 @@ export class Maintenance {
234
567
  try {
235
568
  found = runEvidence(root, job.runId)
236
569
  } catch (error) {
237
- log(`maintenance skipped run ${job.runId}: ${error instanceof Error ? error.message : String(error)}`)
238
- return
570
+ throw error
239
571
  }
240
572
  evidence.runText = evidenceText(root, job.runId)
241
573
  attached = runBlocks(env, job.runId)
@@ -244,7 +576,7 @@ export class Maintenance {
244
576
  trigger = { kind: 'run', runId: job.runId }
245
577
  request = { kind: 'run', runId: job.runId, transcript: shown }
246
578
  } else {
247
- attached = job.blockIds.flatMap((id) => {
579
+ attached = (job.kind === 'review' ? blocks.loadAll(wiki).filter((block) => !block.front.readOnly).sort((a, b) => charCount(b.body) / b.front.limit - charCount(a.body) / a.front.limit).slice(0, 30).map((block) => block.id) : job.blockIds).flatMap((id) => {
248
580
  try {
249
581
  const block = blocks.find(wiki, id)
250
582
  return block.front.readOnly ? [] : [block]
@@ -252,7 +584,7 @@ export class Maintenance {
252
584
  return []
253
585
  }
254
586
  })
255
- if (!attached.length) return
587
+ if (!attached.length && job.kind !== 'review') throw invalid('No writable blocks remain in this job.')
256
588
  namespace = store.LIBRARY_NAMESPACE
257
589
  trigger = { kind: 'consolidate' }
258
590
  request = { kind: 'consolidate' }
@@ -266,12 +598,29 @@ export class Maintenance {
266
598
  } catch {
267
599
  index = ''
268
600
  }
601
+ return { request: { jobId: '', namespace, trigger: request, pages: context.pages, memories: context.memories, blocks: blockValues(attached), namespaces: namespaces(root), index }, evidence, captured, namespace, trigger }
602
+ }
603
+
604
+ private async process(job: Job): Promise<void> {
605
+ const { env, bus } = this.options
606
+ const root = env.root
607
+ const wiki = store.wikiDir(root)
608
+ let prepared: ReturnType<Maintenance['prepare']>
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
612
+ const message = error instanceof Error ? error.message : String(error)
613
+ if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
614
+ else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
615
+ return
616
+ }
617
+ const { namespace, trigger, evidence, captured } = prepared
269
618
  const described = describeTrigger(trigger)
270
619
  try {
271
620
  // The job runs on the default account.
272
621
  const account = this.options.accounts.context(this.options.accounts.resolve())
273
622
  const proposed = await runMaintenance(
274
- { jobId: newId(), namespace, trigger: request, pages: context.pages, memories: context.memories, blocks: blockValues(attached), namespaces: namespaces(root), index },
623
+ { ...prepared.request, jobId: newId() },
275
624
  { cwd: wiki, product: env.productName, account, runtime: () => this.options.accounts.runtime(account) },
276
625
  this.options.factory ?? piMaintainerFactory
277
626
  )
@@ -8,6 +8,8 @@ import { invalid, notFound } from '../core/errors'
8
8
  import { atomicWrite, newId, now, readJson, safeId, writeJson } from '../core/fs'
9
9
  import { compareText } from '../core/text'
10
10
  import * as store from './store'
11
+ import * as attachments from '../plugins/attachments'
12
+ import { dirname } from 'node:path'
11
13
 
12
14
  export interface OperationPage {
13
15
  pageId: string
@@ -23,6 +25,9 @@ export interface Operation {
23
25
  trigger: string
24
26
  pages: OperationPage[]
25
27
  undone: boolean
28
+ protected?: boolean
29
+ attachmentsBefore?: attachments.Attachments
30
+ attachmentsAfter?: attachments.Attachments
26
31
  }
27
32
 
28
33
  export function operationsDir(wiki: string): string {
@@ -107,7 +112,14 @@ export function undo(wiki: string, operationId: string): Operation {
107
112
  const operation = readJson<Operation>(path)
108
113
  if (!operation) throw notFound('Maintenance operation')
109
114
  if (operation.undone) throw invalid('This operation was already undone.')
115
+ if (operation.protected) {
116
+ for (const page of operation.pages) {
117
+ if (!existsSync(page.path) || readFileSync(page.path, 'utf8') !== store.revisionText(wiki, page.pageId, page.afterRevisionId)) throw invalid('An entry changed after this operation; review it before undoing.')
118
+ }
119
+ if (operation.attachmentsAfter && JSON.stringify(attachments.load(dirname(wiki))) !== JSON.stringify(operation.attachmentsAfter)) throw invalid('Attachment settings changed after this operation; review them before undoing.')
120
+ }
110
121
  const short = operation.id.slice(0, 8)
122
+ if (operation.attachmentsBefore) attachments.save(dirname(wiki), operation.attachmentsBefore)
111
123
  for (const page of operation.pages) {
112
124
  let current = ''
113
125
  try {
@@ -95,6 +95,8 @@ export interface Evidence {
95
95
  sourceText?: string | null
96
96
  /** Ids of the blocks attached to the run. */
97
97
  blocks?: string[]
98
+ runs?: Record<string, string>
99
+ sources?: Record<string, string>
98
100
  }
99
101
 
100
102
  const MIN_EXCERPT = 12
@@ -175,6 +177,7 @@ export function captureHashes(wiki: string): Map<string, string> {
175
177
  for (const page of store.loadPages(wiki)) add(page.frontmatter.id, page.path)
176
178
  for (const memory of facts.loadMemories(wiki)) add(memory.front.id, memory.path)
177
179
  for (const block of blocks.loadAll(wiki)) add(block.id, block.path)
180
+ for (const item of registry.list(dirname(wiki))) add(item.id, item.path)
178
181
  return hashes
179
182
  }
180
183
 
@@ -223,7 +226,11 @@ interface Write {
223
226
  }
224
227
 
225
228
  /** Validates proposals and applies the valid ones as one operation. Pure file logic. */
226
- export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence): ApplyOutcome {
229
+ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence, operationKey?: string, appliedBy?: string): ApplyOutcome {
230
+ if (operationKey) {
231
+ const committed = readJson<Operation>(join(operationsDir(wiki), `${safeId(operationKey)}.json`))
232
+ if (committed) return { operation: committed, parked: 0, flagged: 0, notes: ['This request was already applied.'] }
233
+ }
227
234
  const existing = store.loadPages(wiki)
228
235
  const existingMemories = facts.loadMemories(wiki)
229
236
  const root = dirname(wiki)
@@ -240,7 +247,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
240
247
  ])
241
248
  const outcome: ApplyOutcome = { operation: null, parked: 0, flagged: 0, notes: [] }
242
249
  const writes: Write[] = []
243
- const actor = triggerActor(trigger)
250
+ const actor = appliedBy ?? triggerActor(trigger)
244
251
  const described = describeTrigger(trigger)
245
252
  const fromUser = trigger.kind === 'user'
246
253
 
@@ -248,7 +255,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
248
255
  const park = (kind: string, title: string, label: string, reason: string, body: string, entryId: string, payload: unknown): void => {
249
256
  outcome.parked += 1
250
257
  outcome.notes.push(`${label} went to the Inbox: ${reason}`)
251
- inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, payload)
258
+ inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, operationKey && payload && typeof payload === 'object' ? { ...payload, curationJobId: operationKey } : payload)
252
259
  }
253
260
 
254
261
  // Evidence: a source in the Wiki, the triggering run, a URL, or the user's acceptance. A run
@@ -259,11 +266,12 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
259
266
  for (const reference of refs) {
260
267
  let id: string | null = null
261
268
  if (reference.kind === 'source') {
262
- if (sourceIds.has(reference.ref) && (evidence.sourceText == null || quotedIn(evidence.sourceText, reference.excerpt))) id = reference.ref
269
+ const source = evidence.sources?.[reference.ref] ?? (trigger.kind === 'ingest' && trigger.sourceId === reference.ref ? evidence.sourceText : null)
270
+ if (sourceIds.has(reference.ref) && (operationKey ? quotedIn(source, reference.excerpt) : (evidence.sourceText == null || quotedIn(evidence.sourceText, reference.excerpt)))) id = reference.ref
263
271
  } else if (reference.kind === 'run') {
264
- if (triggerRunId(trigger) === reference.ref && quotedIn(evidence.runText, reference.excerpt)) id = `run:${reference.ref}`
272
+ if (quotedIn(evidence.runs?.[reference.ref] ?? (triggerRunId(trigger) === reference.ref ? evidence.runText : null), reference.excerpt)) id = `run:${reference.ref}`
265
273
  } else if (reference.kind === 'url') {
266
- if (reference.ref.startsWith('https://') || reference.ref.startsWith('http://')) id = `url:${reference.ref}`
274
+ if (!operationKey && (reference.ref.startsWith('https://') || reference.ref.startsWith('http://'))) id = `url:${reference.ref}`
267
275
  } else if (reference.kind === 'user') {
268
276
  if (fromUser) id = 'user:accepted'
269
277
  }
@@ -277,7 +285,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
277
285
  .filter((link) => knownIds.has(link.toId) || proposedIds.has(link.toId))
278
286
  .filter((link) => link.toId !== ownId)
279
287
  .map((link) => ({ fromId: ownId, toId: link.toId, type: link.type }))
280
- const changedSince = (id: string, path: string): boolean => captured.has(id) && captured.get(id) !== fileHash(path)
288
+ const changedSince = (id: string, path: string): boolean => (captured.has(id) || Boolean(operationKey)) && captured.get(id) !== fileHash(path)
281
289
 
282
290
  for (const proposal of proposals.pages) {
283
291
  const pageId = store.slug(proposal.id ?? proposal.title)
@@ -464,7 +472,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
464
472
  }
465
473
  })
466
474
 
467
- const operationId = newId()
475
+ const operationId = operationKey ?? newId()
468
476
  const short = operationId.slice(0, 8)
469
477
  const pages: OperationPage[] = writes.map((write) => ({
470
478
  pageId: write.entryId,
@@ -87,6 +87,17 @@ test('an agent recalls its own chats by default and everything on request', () =
87
87
  expect(searchTool(env, { ...agent('agent-a'), runId: 'chat-1' }, { query: 'pnpm' })).not.toContain('run chat-1')
88
88
  })
89
89
 
90
+ test('a caller cannot recall the conversations of agents its host excludes', () => {
91
+ const env = setup()
92
+ sync(env.root)
93
+ const caller = { ...agent('agent-a'), recallExcludeAgents: ['agent-b'] }
94
+ const all = searchTool(env, caller, { query: 'pnpm', scope: 'all' })
95
+ expect(all).toContain('chat-1')
96
+ expect(all).not.toContain('chat-2')
97
+ expect(() => readTool(env, caller, { run: 'chat-2' })).toThrow()
98
+ expect(readTool(env, agent('agent-a'), { run: 'chat-2' })).toContain('Plan the launch')
99
+ })
100
+
90
101
  test('a session recalls only session runs of its project', () => {
91
102
  const env = setup()
92
103
  sync(env.root)