@xingular/harness 2.1.2 → 3.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 (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /package/src/{memory → core}/secrets.ts +0 -0
@@ -6,10 +6,10 @@ import type { CreateAgentSessionOptions, ModelRuntime, SettingsManager } from '@
6
6
  import { classifyMessage } from '../accounts/accounts'
7
7
  import type { AccountContext } from '../accounts/env'
8
8
  import { HarnessError } from '../core/errors'
9
+ import { loadPi } from '../pi/runtime'
9
10
  import type { InlineExtension } from '../plugins/firstParty'
10
11
  import type { Loading, Registered } from '../plugins/plugins'
11
12
  import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY, type ContextUpdate } from '../prompt/compile'
12
- import type { RegistryResources } from '../skills/registry'
13
13
  import type { ToolSpec } from '../tools/types'
14
14
  import { toolAttachmentRefsSchema, type UsageRequest } from './contracts'
15
15
  import { completedReasoningSummary, REASONING_SUMMARY_APIS, requestConciseReasoningSummary } from './reasoningSummaries'
@@ -40,7 +40,10 @@ export interface TurnParams {
40
40
  /** Unattended: no project extensions, no context files, settings held in memory. */
41
41
  automatic?: boolean
42
42
  account: AccountContext
43
- resources?: RegistryResources
43
+ /** Skills and prompt templates to load natively: the harness tree's skills folder. */
44
+ resources?: { ids: string[]; skillPaths: string[]; promptPaths: string[] }
45
+ /** Pi extensions from the harness tree (c8) that may load: trusted code, or a probe the person allowed. */
46
+ treeExtensions?: string[]
44
47
  /**
45
48
  * What the harness found for this session to load, and where it reports what loaded. Pi's own
46
49
  * discovery is off whenever this is set, so only these paths and the registry's reach the session.
@@ -482,13 +485,15 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
482
485
  * Loads what a session may use besides its tools: Pi's context files, the registry's skills and
483
486
  * prompts, and what the harness found for it. `gate` is loaded after every extension on disk.
484
487
  */
485
- export async function loadResources(pi: typeof import('@earendil-works/pi-coding-agent'), params: Pick<TurnParams, 'cwd' | 'account' | 'automatic' | 'resources' | 'plugins' | 'system' | 'inline'>, gate: unknown, automaticSettings?: SettingsManager) {
488
+ export async function loadResources(pi: typeof import('@earendil-works/pi-coding-agent'), params: Pick<TurnParams, 'cwd' | 'account' | 'automatic' | 'resources' | 'treeExtensions' | 'plugins' | 'system' | 'inline'>, gate: unknown, automaticSettings?: SettingsManager) {
486
489
  const loading = params.automatic ? null : ((await params.plugins?.loading()) ?? null)
487
490
  const resourceLoader = new pi.DefaultResourceLoader({
488
491
  ...(automaticSettings ? { settingsManager: automaticSettings, noExtensions: true, noContextFiles: true } : {}),
489
492
  // The loader gets empty settings and no discovery of its own: it loads the paths below and
490
493
  // nothing else. The session still reads the account's real settings for everything but resources.
491
- ...(loading ? { settingsManager: pi.SettingsManager.inMemory({}), noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, additionalExtensionPaths: loading.extensionPaths } : {}),
494
+ ...(loading ? { settingsManager: pi.SettingsManager.inMemory({}), noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true } : {}),
495
+ // The harness tree's plugins load in every kind of run, unattended ones included.
496
+ additionalExtensionPaths: [...(loading?.extensionPaths ?? []), ...(params.treeExtensions ?? [])],
492
497
  cwd: params.cwd,
493
498
  agentDir: params.account.configDir,
494
499
  // Inline extensions load after the ones on disk, and the gate after all of them, so it sees
@@ -520,7 +525,7 @@ export function registeredBy(loader: { getExtensions(): { extensions: readonly u
520
525
 
521
526
  /** Opens a Pi session for one turn: the account's runtime, the compiled prompt, and the gated tools. */
522
527
  export const piSessionFactory: SessionFactory = async ({ params, allow, gate, tools, runtime: loadRuntime, extensions: host }) => {
523
- const pi = await import('@earendil-works/pi-coding-agent')
528
+ const pi = await loadPi()
524
529
  const { Type } = await import('typebox')
525
530
  const runtime = await loadRuntime()
526
531
 
@@ -194,7 +194,7 @@ describe('session turns on Pi', () => {
194
194
  await tick()
195
195
  const snapshot = events.find((event) => event.type === 'context.snapshot')!.payload as { systemPrompt: string; contextUpdate: string; tools: unknown[] }
196
196
  expect(snapshot.systemPrompt).toContain('## Context updates')
197
- expect(snapshot.contextUpdate).toContain('<block label="persona"')
197
+ expect(snapshot.contextUpdate).toContain('[App context update')
198
198
  expect(snapshot.tools).toEqual([{ name: 'read', description: 'Read a file', parameters: { type: 'object', properties: { path: { type: 'string' } } } }])
199
199
  })
200
200
 
@@ -443,7 +443,7 @@ describe('session turns on Pi', () => {
443
443
  expect(loadRun(harness.env.root, 'run-3').status).toBe('interrupted')
444
444
  })
445
445
 
446
- it('gives every session the memory, recall and Wiki tools, a question tool, and the application\'s tools in order', async () => {
446
+ it('gives every session the history, Wiki and skill tools, a question tool, and the application\'s tools in order', async () => {
447
447
  let received: Parameters<SessionFactory>[0] | null = null
448
448
  const { run, harness } = setup(async (input) => {
449
449
  received = input
@@ -452,13 +452,52 @@ describe('session turns on Pi', () => {
452
452
  harness.sessions.provide(() => [{ name: 'react', label: 'React', description: 'React', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
453
453
  await run('run-5', { tools: { allow: ['react'] } })
454
454
  await tick()
455
- expect(received!.allow.slice(0, 3)).toEqual(['react', 'ask_user', 'plugin_propose'])
456
- expect(received!.allow).toEqual(expect.arrayContaining(['memory_append', 'history_search', 'wiki_search', 'wiki_read', 'wiki_propose']))
457
- expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'trajectory_search', 'trajectory_read', 'ask_user', 'plugin_propose', 'spawn_subagent', 'wait_subagents', 'react'])
458
- expect(await received!.tools[0].execute({ block: 'notes', text: 'Remember this.' })).toContain('Updated the notes block')
455
+ expect(received!.allow.slice(0, 2)).toEqual(['react', 'ask_user'])
456
+ expect(received!.allow).toEqual(expect.arrayContaining(['history_search', 'wiki_search', 'wiki_read', 'skill_load', 'trajectory_read']))
457
+ expect(received!.allow).not.toContain('wiki_write')
458
+ expect(received!.tools.map((tool) => tool.name)).toEqual(['history_search', 'history_read', 'trajectory_search', 'trajectory_read', 'wiki_search', 'wiki_read', 'wiki_list', 'wiki_write', 'skill_load', 'ask_user', 'spawn_subagent', 'wait_subagents', 'react'])
459
+ expect(await received!.tools.find((tool) => tool.name === 'skill_load')!.execute({})).toBe('You have no skills.')
459
460
  expect(received!.params.system).toContain('# Xingular Harness context')
460
461
  })
461
462
 
463
+ it('refuses, never asks, when a refinement run reaches a call that needs a person', async () => {
464
+ let check: ((name: string, input: unknown) => Promise<string | null>) | null = null
465
+ const { run, events } = setup(async (input) => {
466
+ check = input.gate.check
467
+ return fakeSession(async () => undefined, input.gate)
468
+ })
469
+ await run('probe-1', { run: { id: 'probe-1', sessionId: 'agent-1', trigger: 'probe' }, tools: { allow: ['read', 'bash'], gated: ['bash'] }, automatic: true })
470
+ await tick()
471
+ expect(await check!('bash', { command: 'rm -rf /' })).toMatch(/^Refused in a refinement run/)
472
+ expect(events.some((event) => event.type === 'approval.requested')).toBe(false)
473
+ })
474
+
475
+ it('runs on a pinned harness tree: its sections, its skills, and its version on the run', async () => {
476
+ let received: Parameters<SessionFactory>[0] | null = null
477
+ const { run, harness } = setup(async (input) => {
478
+ received = input
479
+ return fakeSession(async () => undefined, input.gate)
480
+ })
481
+ const { commit, head } = await import('../tree/store')
482
+ commit(harness.env.root, 'agent-1', {
483
+ 'systemprompt.md': 'You review pull requests.\n',
484
+ 'guardrails.md': '- When a diff is large, load the `triage` skill.\n',
485
+ 'memory.md': '',
486
+ 'tool_guidance.md': '',
487
+ 'skills/triage/SKILL.md': '---\nname: triage\ndescription: a diff is large\n---\nSplit it by folder.\n',
488
+ 'plugins/guard.ts': 'export default function (pi) {}\n'
489
+ }, { kind: 'seed' })
490
+ await run('run-tree', { harness: { scope: 'agent-1' } })
491
+ await tick()
492
+ const record = loadRun(harness.env.root, 'run-tree')
493
+ expect(record).toMatchObject({ harnessScope: 'agent-1', harnessVersion: head(harness.env.root, 'agent-1')!.fingerprint })
494
+ expect(received!.params.system).toContain('You review pull requests.')
495
+ expect(received!.params.resources!.skillPaths[0]).toMatch(/versions\/[0-9a-f]{16}\/tree\/skills$/)
496
+ // Plugin code waits for trust.
497
+ expect(received!.params.treeExtensions).toEqual([])
498
+ expect(await received!.tools.find((tool) => tool.name === 'skill_load')!.execute({ name: 'triage' })).toContain('Split it by folder.')
499
+ })
500
+
462
501
  it('refuses missing continuation history before creating a run or opening a session', async () => {
463
502
  let called = false
464
503
  const { run, events, harness } = setup(async ({ gate }) => {
@@ -12,18 +12,18 @@ import { capabilitySet as parseCapabilitySet, freezeRun } from '../capabilities/
12
12
  import type { CapabilitySet } from '../capabilities/types'
13
13
  import { now, writeJson } from '../core/fs'
14
14
  import { takeChars } from '../core/text'
15
- import type { CallerContext } from '../memory/caller'
16
- import type { DispatchEnv } from '../memory/dispatch'
15
+ import type { CallerContext } from '../core/caller'
16
+ import type { DispatchEnv } from '../tools/dispatch'
17
17
  import type { Plugins } from '../plugins/plugins'
18
18
  import { sessionLaunch, type Launch } from '../prompt/compile'
19
19
  import * as attachments from '../plugins/attachments'
20
20
  import { FIRST_PARTY_PLUGINS, firstPartyExtensions, type InlineExtension } from '../plugins/firstParty'
21
- import * as registry from '../skills/registry'
21
+ import { materialise, type HarnessChoice, type Materialised } from '../tree/materialise'
22
+ import type { TreeTrust } from '../tree/trust'
22
23
  import { ASK_USER, askUserTool } from '../tools/askUser'
23
- import { PLUGIN_PROPOSE, pluginProposeTool } from '../tools/pluginPropose'
24
24
  import { subagentTools } from '../tools/spawnSubagent'
25
- import { WIKI_TOOL_NAMES } from '../tools/definitions'
26
- import { memoryTools } from '../tools/memoryTools'
25
+ import { DEFAULT_TOOL_NAMES } from '../tools/definitions'
26
+ import { harnessTools } from '../tools/harnessTools'
27
27
  import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
28
28
  import type { RunRecord } from './contracts'
29
29
  import { SPAWN_SUBAGENT, WAIT_SUBAGENTS, subagentDepth, subagentHistory, subagentNesting, type SubagentInput, type SubagentOptions } from './subagents'
@@ -58,8 +58,13 @@ export interface SessionRunInput {
58
58
  preamble?: string | null
59
59
  /** Unattended: no project extensions, no context files, nothing may ask a person. */
60
60
  automatic?: boolean
61
- /** Who the session works as, for memory and the prompt. */
61
+ /** Who the session works as, for history, the Wiki and the prompt. */
62
62
  caller: CallerContext
63
+ /**
64
+ * The harness tree the session runs: a scope's head (or a version of it), or a tree on disk.
65
+ * Its version is pinned for the whole run and recorded on it.
66
+ */
67
+ harness?: HarnessChoice | null
63
68
  /** What the application adds to the prompt, and which tools it describes. */
64
69
  launch?: Launch
65
70
  /** The tools switched on, in the order the model sees them, and those that ask before each call. */
@@ -74,6 +79,8 @@ export interface SessionRunInput {
74
79
  subagents?: SubagentOptions | null
75
80
  /** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
76
81
  providers?: boolean
82
+ /** Give the session the harness's own tools: History, the Wiki, skills, and asking the user (the default). */
83
+ harnessTools?: boolean
77
84
  /**
78
85
  * Plan mode: the run may only read, search, recall and ask, and is told to end with a
79
86
  * `<proposed_plan>` block. Other calls are refused at the gate; the tool list is unchanged.
@@ -99,6 +106,8 @@ export interface SessionsOptions {
99
106
  /** Resolves the caller's capability set from the harness's stores. */
100
107
  resolveCapabilities?: (caller: CallerContext, tools: readonly string[], cwd: string) => Promise<CapabilitySet>
101
108
  factory?: SessionFactory
109
+ /** Which plugin code in harness trees a person trusted. */
110
+ trust?: TreeTrust | null
102
111
  }
103
112
 
104
113
  /** The harness's own plugins a person switched on for this caller, with the numbers they set. */
@@ -142,6 +151,8 @@ export class Sessions {
142
151
  return error instanceof Error ? error.message : 'Tool unavailable in the app settings.'
143
152
  }
144
153
  if (scope.allowed && !scope.humanOnly && (scope.automatic || !gated.has(tool))) return null
154
+ // Refinement runs have nobody to ask: what would need a person is refused, never queued.
155
+ if (call.run.trigger === 'probe' || call.run.trigger === 'improver') return scope.reason ? `Refused in a refinement run: ${scope.reason}` : 'Refused in a refinement run: this call needs a person to approve it.'
145
156
  // A call outside the scope is approved one at a time; a gated tool may be remembered, unless only a person may decide it.
146
157
  const decision = await run.requestApproval({
147
158
  target: tool,
@@ -178,15 +189,13 @@ export class Sessions {
178
189
  const account = this.options.accounts.resolve(input.accountId, input.poolId)
179
190
  const context = this.options.accounts.context(account)
180
191
  const runId = input.run.id
181
- const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
192
+ const caller: CallerContext = { ...input.caller, runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
182
193
  const broker = input.connections ? this.options.broker : null
183
194
  const depth = input.depth ?? 0
184
195
  const canSpawn = input.subagents !== null && (!input.automatic || input.subagents !== undefined) && subagentNesting(root, input.run) < subagentDepth(input.subagents ?? undefined)
185
196
  const requestedTools = [...new Set([
186
197
  ...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT && name !== WAIT_SUBAGENTS),
187
- ASK_USER,
188
- ...(input.automatic ? [] : [PLUGIN_PROPOSE]),
189
- ...WIKI_TOOL_NAMES,
198
+ ...(input.harnessTools === false ? [] : [ASK_USER, ...DEFAULT_TOOL_NAMES]),
190
199
  ...(broker?.toolNames ?? []),
191
200
  ...(canSpawn ? [SPAWN_SUBAGENT, WAIT_SUBAGENTS] : [])
192
201
  ])]
@@ -202,6 +211,16 @@ export class Sessions {
202
211
  }
203
212
  freezeRun(root, runId, caller, capabilities)
204
213
 
214
+ // The harness is resolved once and pinned: a child run or a later turn of this run uses the
215
+ // same files even if the head moves meanwhile.
216
+ let tree: Materialised | null = null
217
+ if (input.harness) {
218
+ const scope = input.harness.scope ?? null
219
+ const trust = this.options.trust ?? null
220
+ tree = materialise(root, input.harness, { trust, probeCode: input.run.trigger === 'probe' && Boolean(scope && trust?.probeCode(scope)), subagents: capabilities.tools.includes(SPAWN_SUBAGENT) })
221
+ input.harness = { dir: tree.dir, scope: tree.scope }
222
+ }
223
+
205
224
  saveRun(root, {
206
225
  resumedFromRunId: input.run.resumedFromRunId ?? null,
207
226
  parentRunId: input.run.parentRunId ?? null,
@@ -225,7 +244,9 @@ export class Sessions {
225
244
  handoffId: input.run.handoffId ?? null,
226
245
  accountId: account.id,
227
246
  agentId: input.run.agentId ?? caller.agentId ?? null,
228
- trigger: input.run.trigger ?? null
247
+ trigger: input.run.trigger ?? null,
248
+ harnessScope: tree?.scope ?? null,
249
+ harnessVersion: tree?.fingerprint ?? null
229
250
  })
230
251
 
231
252
  // What the session is, kept with the run so later consolidation knows which memory it had.
@@ -254,7 +275,7 @@ export class Sessions {
254
275
  connectionTools: (launch.connectionTools ?? []).filter((name) => capabilities.tools.includes(name)),
255
276
  toolGroups: launch.toolGroups?.map((group) => ({ ...group, tools: group.tools.filter((name) => capabilities.tools.includes(name)) })).filter((group) => group.tools.length > 0)
256
277
  }
257
- ;({ system, update } = sessionLaunch(this.env, caller, effectiveLaunch, historyFile, capabilities.contributions))
278
+ ;({ system, update } = sessionLaunch(this.env, caller, effectiveLaunch, historyFile, tree))
258
279
  } catch (error) {
259
280
  fail(error)
260
281
  throw error
@@ -262,6 +283,9 @@ export class Sessions {
262
283
 
263
284
  const run = this.options.runs.start(input.run.sessionId, runId)
264
285
  if (run.finished) return { runId, nativeSessionId: null }
286
+ if (tree?.untrustedPlugins.length) {
287
+ run.emit('extension.notice', { kind: 'notify', level: 'warning', text: `Harness plugins not loaded until their code is trusted: ${tree.untrustedPlugins.join(', ')}.` })
288
+ }
265
289
  // What every session has, after the tools the caller switched on and in a fixed order.
266
290
  const allow = capabilities.tools
267
291
  const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
@@ -271,9 +295,7 @@ export class Sessions {
271
295
  try {
272
296
  const tools: ToolSpec[] = [
273
297
  ...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
274
- ...memoryTools(this.options.env, caller, capabilities.contributions),
275
- askUserTool(run),
276
- pluginProposeTool(this.options.env, caller),
298
+ ...(input.harnessTools === false ? [] : [...harnessTools(this.options.env, caller, { treeDir: tree?.dir ?? null }), askUserTool(run)]),
277
299
  ...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
278
300
  ]
279
301
  if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
@@ -294,7 +316,8 @@ export class Sessions {
294
316
  preamble: input.preamble ?? null,
295
317
  automatic: input.automatic,
296
318
  account: context,
297
- resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
319
+ resources: { ids: [], skillPaths: tree?.skillPaths ?? [], promptPaths: [] },
320
+ treeExtensions: tree?.extensionPaths ?? [],
298
321
  inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId), this.options.subagents.extension(runId)],
299
322
  ...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: context.configDir, install: true, caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null }, contributions: capabilities.contributions }), loaded: (extensions) => plugins.loaded(extensions) } } : {}),
300
323
  system,
@@ -14,8 +14,10 @@ afterEach(async () => { for (const harness of open.splice(0)) await harness.clos
14
14
 
15
15
  function setup() {
16
16
  const finish = new Map<string, (text: string) => void>()
17
+ const systems = new Map<string, string>()
17
18
  const root = mkdtempSync(join(tmpdir(), 'native-subagents-'))
18
19
  const harness = createHarness({ dataRoot: join(root, 'data'), stateDir: join(root, 'state'), secrets: memorySecretStore(), sessionFactory: async ({ params }) => {
20
+ systems.set(params.runId, params.system)
19
21
  let emit: (event: any) => void = () => {}
20
22
  let done: () => void = () => {}
21
23
  return { sessionId: 'native-history', subscribe: (listener) => { emit = listener; return () => {} }, sendCustomMessage: async () => {},
@@ -29,10 +31,29 @@ function setup() {
29
31
  run: { id, sessionId: 'opaque-parent' }, caller: { agentId: 'caller', actor: 'Chat' }, cwd: root,
30
32
  sessionDir: join(root, 'history'), text: 'Parent', tools: { allow: ['read'] }, ...extra
31
33
  })
32
- return { harness, finish, start }
34
+ return { harness, finish, start, systems }
33
35
  }
34
36
  const empty = () => ({ owners: [], receipts: [] })
35
37
 
38
+ describe('subagents a harness defines (c5)', () => {
39
+ it('starts one by name with its own instructions instead of the parent\'s, and refuses a name the harness lacks', async () => {
40
+ const { harness, start, systems } = setup()
41
+ const { commit } = await import('../tree/store')
42
+ commit(harness.env.root, 'caller', { 'systemprompt.md': 'You coordinate releases.\n', 'guardrails.md': '', 'memory.md': '', 'tool_guidance.md': '', 'sub_agents/reader/AGENT.md': '---\ndescription: reads long logs and answers briefly\n---\nRead the log and answer in three lines.\n' }, { kind: 'seed' })
43
+ await start('parent', { harness: { scope: 'caller' }, tools: { allow: ['read', 'spawn_subagent', 'wait_subagents'] } })
44
+ expect(systems.get('parent')).toContain('You coordinate releases.')
45
+ expect(systems.get('parent')).toContain('- reader — reads long logs and answers briefly')
46
+ const child = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Summarise build.log', agent: 'reader' })
47
+ expect(systems.get(child.runId)).toContain('Read the log and answer in three lines.')
48
+ expect(systems.get(child.runId)).not.toContain('You coordinate releases.')
49
+ // A plain child keeps the parent's harness, pinned to the same version.
50
+ const copy = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Check the tests' })
51
+ expect(systems.get(copy.runId)).toContain('You coordinate releases.')
52
+ expect(loadRun(harness.env.root, copy.runId).harnessVersion).toBe(loadRun(harness.env.root, 'parent').harnessVersion)
53
+ await expect(harness.subagents.spawn({ parentRunId: 'parent', brief: 'x', agent: 'writer' })).rejects.toThrow('There is no subagent named writer.')
54
+ })
55
+ })
56
+
36
57
  describe('native subagent ownership and delivery', () => {
37
58
  it('spawns asynchronously, joins selected children, and rejects foreign handles', async () => {
38
59
  const { harness, finish, start } = setup()
@@ -13,6 +13,7 @@ import type { RunRegistry } from './registry'
13
13
  import type { Effort } from './runner'
14
14
  import type { SessionRunInput } from './sessions'
15
15
  import { isActiveStatus } from './contracts'
16
+ import { subagentOf } from '../tree/materialise'
16
17
  import { findRun, loadRun, readEvents, recordedSubagents, recordSubagent, saveRun } from './runStore'
17
18
  import { SPAWN_SUBAGENT, SUBAGENT_RELATION, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentInput, type SubagentReceipt, type SubagentResult, type SubagentSelection } from './subagents'
18
19
 
@@ -104,6 +105,17 @@ export class Subagents {
104
105
  if (outside.length) throw invalid(`A subagent cannot have tools you do not have: ${outside.join(', ')}.`)
105
106
  const tools = capabilities.tools.filter((name) => !request.tools || request.tools.includes(name))
106
107
  await until(parent.ready, parent.stopped.signal)
108
+ let harness = input.harness ?? null
109
+ let launch = input.launch
110
+ if (request.agent !== undefined) {
111
+ if (typeof request.agent !== 'string' || !request.agent.trim()) throw invalid('agent must be the name of one of your subagents.')
112
+ const dir = harness && 'dir' in harness ? harness.dir : null
113
+ const definition = dir ? subagentOf(dir, request.agent.trim()) : null
114
+ if (!definition) throw invalid(`There is no subagent named ${request.agent.trim()}.`)
115
+ // A named subagent works from its own definition, not from the parent's instructions.
116
+ harness = null
117
+ launch = { ...(input.launch ?? {}), sections: [...(input.launch?.sections ?? []).filter((section) => section.id !== 'subagent'), { id: 'subagent', title: `Your role: ${definition.name}`, body: definition.instructions || definition.description, budget: 16_000, order: 22 }] }
118
+ }
107
119
  const runId = randomUUID()
108
120
  const sessionId = randomUUID()
109
121
  recordSubagent(this.env.root, request.parentRunId, runId)
@@ -114,7 +126,8 @@ export class Subagents {
114
126
  text: brief, images: undefined, requiresImageInput: false, preamble: null,
115
127
  caller: { ...input.caller, runId: undefined, sessionId },
116
128
  tools: { allow: tools, gated: (input.tools.gated ?? []).filter((name) => tools.includes(name)) },
117
- capabilities: { ...capabilities, tools }, depth: (input.depth ?? 0) + 1
129
+ capabilities: { ...capabilities, tools }, depth: (input.depth ?? 0) + 1,
130
+ harness, launch
118
131
  }
119
132
  const started = this.launch(child).catch((error) => {
120
133
  if (!findRun(this.env.root, runId)) {
@@ -24,6 +24,8 @@ export interface SubagentInput {
24
24
  brief: string
25
25
  tools?: string[]
26
26
  wait?: boolean
27
+ /** One of the parent harness's subagents (c5) by name; its instructions frame the child. */
28
+ agent?: string
27
29
  }
28
30
 
29
31
  export interface SubagentSelection {
@@ -1,6 +1,6 @@
1
- // `xagent-wiki` and `xagent-mcp`: the harness's memory, Wiki, and connected apps as commands,
2
- // for a `pi` terminal session's shell. Each command maps onto one call to the session socket.
3
- import type { WikiCall } from '../tools/definitions'
1
+ // `xagent-wiki` and `xagent-mcp`: the harness's notes, skills, history and connected apps as
2
+ // commands, for a `pi` terminal session's shell. Each command maps onto one call to the session socket.
3
+ import type { HarnessCall } from '../tools/definitions'
4
4
  import { connectionFromEnv, socketCall } from './client'
5
5
 
6
6
  export const WIKI_COMMAND = 'xagent-wiki'
@@ -8,22 +8,14 @@ export const APPS_COMMAND = 'xagent-mcp'
8
8
 
9
9
  export const USAGE = `Usage: ${WIKI_COMMAND} <command> [arguments]
10
10
 
11
- entrypoint Your context pack: memory blocks, rules, skills, project map
12
- search <query> [--kind K] [--namespace NS]
13
- Search every namespace (K: page, fact, skill, rule, prompt)
14
- read <id or path> Read one page, fact, skill, rule, or prompt
15
- list [namespace] [--kind K] List namespaces and pages, or all entries of a kind
16
- skill [name] Load a skill, or list skills
17
- facts [query] [--namespace NS] Recall facts (also: memory)
11
+ search <query> [--project SLUG] Search the Wiki's project notes
12
+ read <path> Read one note, e.g. projects/<slug>/decisions
13
+ list [project slug | library] List notes
14
+ write <path> <text | -> Save a note (when this session may write the Wiki)
15
+ skill [name] Load one of your skills, or list them
18
16
  history <query> [--scope all] [--since YYYY-MM-DD]
19
17
  Search earlier conversations and runs (also: recall)
20
18
  history-read <run id> Read one earlier run (also: recall-read)
21
- block append <label> <text> Add to a memory block (user, project, or a shared block)
22
- block replace <label> --old <text> --new <text>
23
- Replace one exact passage in a memory block
24
- block rethink <label> <text> Rewrite a whole memory block
25
- propose <json | -> Record knowledge, e.g. {"kind":"fact","title":"…","body":"…",
26
- "type":"preference","sourceRefs":[{"kind":"run","ref":"current"}]}
27
19
  `
28
20
 
29
21
  export const APPS_USAGE = `Usage: ${APPS_COMMAND} <command>
@@ -33,15 +25,15 @@ export const APPS_USAGE = `Usage: ${APPS_COMMAND} <command>
33
25
  Use one of them
34
26
  `
35
27
 
36
- export type CliCommand = { kind: 'call'; call: WikiCall; params: Record<string, unknown> } | { kind: 'help' } | { kind: 'stdin-propose' }
28
+ export type CliCommand = { kind: 'call'; call: HarnessCall; params: Record<string, unknown> } | { kind: 'help' } | { kind: 'stdin-write'; path: string }
37
29
 
38
30
  function options(args: string[]): { positional: string[]; flags: Record<string, string> } {
39
31
  const positional: string[] = []
40
32
  const flags: Record<string, string> = {}
41
33
  for (let index = 0; index < args.length; index += 1) {
42
- const arg = args[index]
34
+ const arg = args[index]!
43
35
  if (arg.startsWith('--') && index + 1 < args.length) {
44
- flags[arg.slice(2)] = args[index + 1]
36
+ flags[arg.slice(2)] = args[index + 1]!
45
37
  index += 1
46
38
  } else {
47
39
  positional.push(arg)
@@ -55,21 +47,23 @@ export function parseCli(argv: string[]): CliCommand {
55
47
  const { positional, flags } = options(rest)
56
48
  const joined = positional.join(' ').trim()
57
49
  switch (command) {
58
- case 'entrypoint':
59
- return { kind: 'call', call: 'wiki.entrypoint', params: {} }
60
50
  case 'search':
61
51
  if (!joined) return { kind: 'help' }
62
- return { kind: 'call', call: 'wiki.search', params: { query: joined, ...(flags.kind ? { kinds: [flags.kind] } : {}), ...(flags.namespace ? { namespace: flags.namespace } : {}) } }
52
+ return { kind: 'call', call: 'wiki.search', params: { query: joined, ...(flags.project ? { project: flags.project } : {}) } }
63
53
  case 'read':
64
54
  if (!joined) return { kind: 'help' }
65
- return { kind: 'call', call: 'wiki.read', params: { id: joined } }
55
+ return { kind: 'call', call: 'wiki.read', params: { path: joined } }
66
56
  case 'list':
67
- return { kind: 'call', call: 'wiki.list', params: { ...(joined ? { namespace: joined } : {}), ...(flags.kind ? { kind: flags.kind } : {}) } }
57
+ return { kind: 'call', call: 'wiki.list', params: joined ? { project: joined } : {} }
58
+ case 'write': {
59
+ const [path, ...words] = positional
60
+ if (!path) return { kind: 'help' }
61
+ if (words.join(' ').trim() === '-') return { kind: 'stdin-write', path }
62
+ if (!words.length) return { kind: 'help' }
63
+ return { kind: 'call', call: 'wiki.write', params: { path, text: words.join(' ') } }
64
+ }
68
65
  case 'skill':
69
- return { kind: 'call', call: 'wiki.skill', params: joined ? { name: joined } : {} }
70
- case 'facts':
71
- case 'memory':
72
- return { kind: 'call', call: 'wiki.memory', params: { ...(joined ? { query: joined } : {}), ...(flags.namespace ? { namespace: flags.namespace } : {}) } }
66
+ return { kind: 'call', call: 'skill.load', params: joined ? { name: joined } : {} }
73
67
  case 'history':
74
68
  case 'recall':
75
69
  if (!joined) return { kind: 'help' }
@@ -78,24 +72,6 @@ export function parseCli(argv: string[]): CliCommand {
78
72
  case 'recall-read':
79
73
  if (!joined) return { kind: 'help' }
80
74
  return { kind: 'call', call: 'recall.read', params: { run: joined } }
81
- case 'block': {
82
- const [action, label, ...words] = positional
83
- const body = words.join(' ').trim()
84
- if (action === 'append' && label && body) return { kind: 'call', call: 'memory.append', params: { block: label, text: body } }
85
- if (action === 'rethink' && label && body) return { kind: 'call', call: 'memory.rethink', params: { block: label, body } }
86
- if (action === 'replace' && label && flags.old !== undefined && flags.new !== undefined) return { kind: 'call', call: 'memory.replace', params: { block: label, old: flags.old, new: flags.new } }
87
- return { kind: 'help' }
88
- }
89
- case 'propose': {
90
- if (joined === '-') return { kind: 'stdin-propose' }
91
- try {
92
- const params = JSON.parse(joined) as unknown
93
- if (params && typeof params === 'object' && !Array.isArray(params)) return { kind: 'call', call: 'wiki.propose', params: params as Record<string, unknown> }
94
- } catch {
95
- // Falls through to help.
96
- }
97
- return { kind: 'help' }
98
- }
99
75
  default:
100
76
  return { kind: 'help' }
101
77
  }
@@ -119,8 +95,8 @@ export async function runWikiCli(argv: string[]): Promise<number> {
119
95
  try {
120
96
  const connection = connectionFromEnv()
121
97
  const result =
122
- command.kind === 'stdin-propose'
123
- ? await socketCall<string>(connection, 'wiki.propose', JSON.parse(await readStdin()) as Record<string, unknown>)
98
+ command.kind === 'stdin-write'
99
+ ? await socketCall<string>(connection, 'wiki.write', { path: command.path, text: await readStdin() })
124
100
  : await socketCall<string>(connection, command.call, command.params)
125
101
  process.stdout.write(`${typeof result === 'string' ? result : JSON.stringify(result)}\n`)
126
102
  return 0
@@ -80,21 +80,20 @@ describe('plugin control from a terminal session', () => {
80
80
  const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
81
81
  const call = <T>(method: string, params?: Record<string, unknown>) => socketCall<T>(connection, method, params ?? {}, 5_000, key)
82
82
  const rows = menuRows(await call<Plugin[]>('plugins.list'), 'p1')
83
- expect(rows.map((row) => row.label)).toEqual(['● review-kit · code not trusted', '○ Run tests · instruction in Yours', '○ Budget guard · runs code', '○ Output trim · runs code'])
83
+ expect(rows.map((row) => row.label)).toEqual(['● review-kit · code not trusted', '○ Budget guard · runs code', '○ Output trim · runs code'])
84
84
  expect(rowActions(rows[0], 'p1')).toEqual(['Switch off for this project', 'Switch off everywhere', 'Trust its code', 'Back'])
85
85
  expect(rowActions(rows[1], null)).toEqual(['Switch on everywhere', 'Back'])
86
86
  // A part that ships with the harness needs no trust: it is the harness's own code.
87
- expect(rowActions(rows[2], 'p1')).toEqual(['Switch on for this project', 'Switch on everywhere', 'Back'])
87
+ expect(rowActions(rows[1], 'p1')).toEqual(['Switch on for this project', 'Switch on everywhere', 'Back'])
88
88
 
89
- // Declining the trust question leaves the code untrusted; then they trust it, switch a rule on, and leave.
90
- const { ui, shown, said } = person(['● review-kit · code not trusted', 'Trust its code', false, '● review-kit · code not trusted', 'Trust its code', true, '○ Run tests · instruction in Yours', 'Switch on for this project', 'Done'])
89
+ // Declining the trust question leaves the code untrusted; then they trust it and leave.
90
+ const { ui, shown, said } = person(['● review-kit · code not trusted', 'Trust its code', false, '● review-kit · code not trusted', 'Trust its code', true, 'Done'])
91
91
  await runPluginsMenu(ui, call, 'p1')
92
92
  expect(shown.find((line) => line.startsWith('Trust this code?'))).toContain('extensions/guard.ts')
93
93
  expect(shown.find((line) => line.startsWith('Trust this code?'))).toContain('can do anything this app can')
94
94
  expect(said).toEqual(['Saved. It applies when this session restarts.'])
95
95
  const after = await call<Plugin[]>('plugins.list')
96
96
  expect(after.find((plugin) => plugin.id === 'package:review-kit')).toMatchObject({ trust: 'trusted' })
97
- expect(after.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === 'yours/rules/tests')).toMatchObject({ state: 'on', scope: 'App' })
98
97
  expect(harness.plugins.trust.list()).toHaveLength(1)
99
98
 
100
99
  // Leaving at once changes nothing and says nothing.
@@ -113,7 +112,7 @@ describe('the plugins command', () => {
113
112
  const last = (): string => lines.at(-1) ?? ''
114
113
 
115
114
  expect(await run(['list'])).toBe(0)
116
- expect(last()).toMatch(/^memory\s+required\s+Memory$/m)
115
+ expect(last()).toMatch(/^harness\s+required\s+Harness$/m)
117
116
  expect(await run(['install', join(base, 'kit')])).toBe(0)
118
117
  expect(last()).toContain('Installed, switched off.')
119
118
  expect(last()).toContain('trust its code with "plugins trust <id>"')
@@ -134,8 +133,8 @@ describe('the plugins command', () => {
134
133
  expect(last()).toContain('Code (trusted)')
135
134
  expect(last()).toContain('● skill audit (package:review-kit/skill/audit)')
136
135
 
137
- expect(await run(['detach', 'memory'])).toBe(1)
138
- expect(last()).toContain('Memory cannot be switched off.')
136
+ expect(await run(['detach', 'harness'])).toBe(1)
137
+ expect(last()).toContain('Harness cannot be switched off.')
139
138
  expect(await run(['untrust', 'package:review-kit'])).toBe(0)
140
139
  expect(await run(['remove', 'package:review-kit'])).toBe(0)
141
140
  expect(await run(['show', 'package:review-kit'])).toBe(1)
@@ -13,7 +13,7 @@ import { log } from '../core/bus'
13
13
  import { HarnessError, toErrorShape } from '../core/errors'
14
14
  import { newId } from '../core/fs'
15
15
  import type { BrokerSession } from '../mcp/service'
16
- import type { CallerContext } from '../memory/caller'
16
+ import type { CallerContext } from '../core/caller'
17
17
 
18
18
  /** What a token stands for. */
19
19
  export interface SocketGrant {
@@ -27,7 +27,7 @@ export interface SocketGrant {
27
27
  }
28
28
 
29
29
  export interface SocketHandlers {
30
- /** `wiki.*`, `memory.*`, `recall.*`. */
30
+ /** `wiki.*`, `skill.*`, `recall.*`, `trajectory.*`. */
31
31
  memory(caller: CallerContext, call: string, params: Record<string, unknown>): string
32
32
  /** `mcp.list`, `mcp.call`, `mcp.revision`, `tools.list`, `tools.call`. */
33
33
  apps(session: BrokerSession, call: string, params: Record<string, unknown>): Promise<unknown>
@@ -147,7 +147,7 @@ export class SessionSocket {
147
147
  }
148
148
  const call = typeof request.call === 'string' ? request.call : ''
149
149
  const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>
150
- if (['wiki.', 'memory.', 'recall.'].some((prefix) => call.startsWith(prefix))) return { result: this.handlers.memory(grant.caller(), call, params) }
150
+ if (['wiki.', 'skill.', 'recall.', 'trajectory.'].some((prefix) => call.startsWith(prefix))) return { result: this.handlers.memory(grant.caller(), call, params) }
151
151
  if (call === 'tools.list' && !grant.broker) return { result: { tools: [] } }
152
152
  if (['mcp.', 'tools.'].some((prefix) => call.startsWith(prefix))) {
153
153
  if (!grant.broker) throw new HarnessError('mcp_unauthorized', 'Connected apps are not available to this session.')