@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,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
6
6
  import { Bus } from '../core/bus'
7
7
  import { Harness } from '../harness'
8
8
  import { memorySecretStore } from '../accounts/secrets'
9
- import * as registry from '../skills/registry'
9
+ import * as registry from '../tree/components'
10
10
  import { Hub } from './hub'
11
11
  import { hubCatalogSchema, hubEntrySchema, type HubEntry } from './schema'
12
12
  import { runHubMenu } from '../terminal/hubMenu'
@@ -47,7 +47,7 @@ describe('Hub installation', () => {
47
47
  const { hub, root } = fixture([entry('registry', skill())])
48
48
  const receipt = await hub.install('example/review', '1.0.0')
49
49
  expect(receipt.resourceIds).toEqual(['skills/review'])
50
- expect(registry.enabledFor(root, null)).toEqual([])
50
+ expect(registry.list(root).map((item) => item.id)).toContain('skills/review')
51
51
  const content = readFileSync(join(root, 'registry', 'skills', 'review', 'SKILL.md'), 'utf8')
52
52
  const collision = { ...entry('registry', skill()), id: 'other/review' }
53
53
  const another = new Hub(({ dataRoot: root, env: { root }, stateDir: join(root, 'state'), bus: new Bus() }) as Harness, { catalog: { schemaVersion: 1, entries: [collision] } })
@@ -67,7 +67,7 @@ describe('Hub installation', () => {
67
67
  const { hub, root } = fixture([item])
68
68
  await hub.install(item.id, item.version)
69
69
  expect(readFileSync(join(root, 'registry', kind, 'review.md'), 'utf8')).toContain('Read evidence')
70
- expect(registry.enabledFor(root, null)).toEqual([])
70
+ expect(registry.list(root).map((entry) => entry.id)).toContain(`${kind}/review`)
71
71
  }
72
72
  })
73
73
  it('does not commit a download with the wrong digest', async () => {
@@ -140,12 +140,11 @@ describe('Hub installation', () => {
140
140
  it('can reinstall deleted resources and clears stale attachment grants', async () => {
141
141
  const { hub, root } = fixture([entry('registry', skill())])
142
142
  await hub.install('example/review', '1.0.0')
143
- registry.setEnabled(root, null, ['skills/review'])
144
143
  rmSync(join(root, 'registry', 'skills', 'review'), { recursive: true })
145
144
  expect((await hub.list()).installed[0]).toMatchObject({ state: 'incomplete' })
146
145
  await hub.uninstall('example/review', '1.0.0')
147
146
  await hub.install('example/review', '1.0.0')
148
- expect(registry.enabledFor(root, null)).toEqual([])
147
+ expect(registry.list(root).map((entry) => entry.id)).toEqual(['skills/review'])
149
148
  expect((await hub.list()).installed).toHaveLength(1)
150
149
  })
151
150
  it('preserves corrupt installation records rather than overwriting them', async () => {
package/src/hub/hub.ts CHANGED
@@ -8,7 +8,7 @@ import { atomicWrite, now, readJson, readJsonOr, writeJson } from '../core/fs'
8
8
  import type { Harness } from '../harness'
9
9
  import * as pluginApi from '../plugins/api'
10
10
  import * as packages from '../plugins/install'
11
- import * as registry from '../skills/registry'
11
+ import * as registry from '../tree/components'
12
12
  import * as attachments from '../plugins/attachments'
13
13
  import { version as harnessVersion } from '../../package.json'
14
14
  import bundled from './catalog.json'
@@ -244,7 +244,7 @@ export class Hub {
244
244
  const payload = JSON.parse(text)
245
245
  const previous = new Set(registry.list(root).map((item) => item.id))
246
246
  const imported = registry.importItems(root, delivery.registryKind, payload.items)
247
- receipt.resourceIds = imported.items.filter((item) => !previous.has(item.id)).map((item) => item.id)
247
+ receipt.resourceIds = imported.filter((item) => !previous.has(item.id)).map((item) => item.id)
248
248
  const choices = structuredClone(choicesBefore)
249
249
  const ids = new Set(receipt.resourceIds)
250
250
  for (const scope of [choices.global, ...Object.values(choices.agents), ...Object.values(choices.projects)]) {
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
- // @xingular/harness: a Pi agent harness. Multi-account Pi sessions, a compiled system prompt
2
- // with stable-prefix delivery, a memory lifecycle (Wiki, blocks, facts, recall, sleep-time
3
- // consolidation), tools with a gate and standing approvals, connected apps over MCP, and
4
- // skills (a person's own and built-in).
1
+ // @xingular/harness: a Pi agent harness built on recursive harnessing (Harness², Apache-2.0,
2
+ // Google LLC). Each agent runs on a versioned harness tree of eight components (system prompt,
3
+ // guardrails, memory, tool guidance, subagents, skills, scripts, plugins); refinement improves a
4
+ // tree from evidence through Propose–Probe–Compose, blind selection and an experience library.
5
+ // Around it: multi-account Pi sessions, a compiled prompt with stable-prefix delivery, project
6
+ // notes (the Wiki), history, tools with a gate and standing approvals, and connected apps.
5
7
  //
6
8
  // An application embeds it with `createHarness` and extends it through prompt sections, tool
7
- // providers, a tool policy, a secret store, a directory of its own names, and a description of
8
- // its own parts as plugins. Everything else is reachable by path, e.g. `@xingular/harness/memory/wiki`.
9
+ // providers, a tool policy, a secret store, a directory of its own names, a description of its
10
+ // own parts as plugins, and a refinement host. Everything else is reachable by path.
9
11
  export { createHarness, Harness, type HarnessOptions, type Recovery } from './harness'
10
12
  export { hubCatalogSchema, hubEntrySchema, hubOverviewSchema, hubReceiptSchema, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './hub/schema'
11
13
 
@@ -20,8 +22,8 @@ export type { AccountLoginSnapshot, ModelOption } from './accounts/accounts'
20
22
  export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
21
23
  export type { RunCapabilitySnapshot } from './capabilities/runSnapshot'
22
24
 
23
- export type { CallerContext } from './memory/caller'
24
- export { dispatch as memoryCall } from './memory/dispatch'
25
+ export type { CallerContext } from './core/caller'
26
+ export { dispatch as harnessCall } from './tools/dispatch'
25
27
 
26
28
  export {
27
29
  compile,
@@ -30,7 +32,6 @@ export {
30
32
  CONTEXT_MESSAGE_TYPE,
31
33
  CONTEXT_SNAPSHOT_KEY,
32
34
  type ContextPack,
33
- type ContextProfile,
34
35
  type HostSection,
35
36
  type HostToolGroup,
36
37
  type Launch,
@@ -45,19 +46,32 @@ export type { ActiveRun, ApprovalDecision, ApprovalInput, QuestionAnswer, Questi
45
46
  export type { RunFields, SessionRunInput } from './sessions/sessions'
46
47
  export type { Effort } from './sessions/runner'
47
48
 
48
- export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
49
+ export { describeTool, HARNESS_TOOLS, DEFAULT_TOOL_NAMES, WIKI_WRITE, type HarnessCall, type HarnessToolDefinition } from './tools/definitions'
49
50
  export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
50
51
  export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from './tools/spawnSubagent'
51
- export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
52
- export type { CurationJob } from './memory/curation'
53
52
  export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
54
- export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, type RegistryToolOptions } from './tools/registryWrite'
55
53
  export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
56
54
 
57
55
  export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
58
56
  export type { Connection, ConnectionInput, AppOAuthClients } from './mcp/types'
59
57
 
60
- export { configureBuiltins, type RegistryItem, type RegistryState, type SkillBundleInput } from './skills/registry'
58
+ export { configureBuiltins, type ComponentItem, type SkillBundleInput } from './tree/components'
59
+
60
+ // The harness tree and refinement.
61
+ export { PI_SURFACE, componentOf, type ComponentGroup, type ComponentId, type ComponentInfo, type SurfaceSpec, type Tree } from './tree/surface'
62
+ export { validate as validateTree, autotrigger, diffSummary, TreeError, type TreeCensus, type TreeDiff } from './tree/tree'
63
+ export { fingerprint, type Head, type VersionRecord, type VersionSource } from './tree/store'
64
+ export { materialise, type HarnessChoice, type Materialised } from './tree/materialise'
65
+ export { seedTree, type SeedInput } from './tree/seed'
66
+ export type { TrustedPlugin } from './tree/trust'
67
+ export { DEFAULT_REFINE_HOST, defaultDeliverable, type RefineHost, type RolloutSession, type RolloutWorkspace } from './refine/host'
68
+ export { PROBE_TRIGGER, IMPROVER_TRIGGER, type ImproverSettings } from './refine/pi'
69
+ export type { CandidateView, StartInput as RefineStartInput } from './refine/service'
70
+ export type { RefineEvent, RefineStatus, RefineSummary } from './refine/runner'
71
+ export type { Executor, Improver, RecursionMode, RefineTask, Rollout } from './refine/types'
72
+ export type { WorkPrompts } from './refine/prompts'
73
+ export type { Experience } from './refine/library'
74
+ export type { Note, NoteSummary } from './wiki/notes'
61
75
 
62
76
  export type { TerminalAssets, TerminalLaunch, TerminalPrepareInput } from './terminal/terminals'
63
77
 
@@ -8,7 +8,7 @@ import type { Bus } from '../core/bus'
8
8
  import { HarnessError, invalid, notFound } from '../core/errors'
9
9
  import { newId, readJson, safeId, writeJson } from '../core/fs'
10
10
  import { findBinary, pathVariableName, searchPath } from '../core/path'
11
- import { isDir } from '../memory/store'
11
+ import { isDir } from '../core/fs'
12
12
  import type { RunEvent } from '../sessions/contracts'
13
13
  import * as attachments from '../plugins/attachments'
14
14
  import { connectionScopeMigrationComplete } from './scopeMigration'
@@ -11,7 +11,7 @@ import { HarnessError, invalid, notFound } from '../core/errors'
11
11
  import { newId, now, readJson, writeJson } from '../core/fs'
12
12
  import { charCount, takeChars } from '../core/text'
13
13
  import type { CapabilitySet } from '../capabilities/types'
14
- import type { CallerContext } from '../memory/caller'
14
+ import type { CallerContext } from '../core/caller'
15
15
  import type { ToolPolicy } from '../sessions/policy'
16
16
  import type { ActiveRun, RunRegistry } from '../sessions/registry'
17
17
  import { findRun, runDir } from '../sessions/runStore'
@@ -0,0 +1,68 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { createServer } from 'node:http'
3
+ import { promisify } from 'node:util'
4
+ import { afterEach, expect, it } from 'vitest'
5
+
6
+ const execFileAsync = promisify(execFile)
7
+ const servers: ReturnType<typeof createServer>[] = []
8
+ afterEach(async () => {
9
+ for (const server of servers.splice(0)) await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
10
+ })
11
+
12
+ it('routes Pi HTTP requests through the process proxy environment', async () => {
13
+ const requests: Array<{ url: string | undefined; host: string | undefined; tunnel: boolean }> = []
14
+ const proxy = createServer((request, response) => {
15
+ requests.push({ url: request.url, host: request.headers.host, tunnel: false })
16
+ response.writeHead(200, { 'content-type': 'text/plain' })
17
+ response.end('served by local proxy')
18
+ })
19
+ proxy.on('connect', (request, socket, head) => {
20
+ requests.push({ url: request.url, host: request.headers.host, tunnel: true })
21
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
22
+ let received = head.toString('utf8')
23
+ const respond = (): void => {
24
+ if (!received.includes('\r\n\r\n')) return
25
+ const body = 'served by local proxy'
26
+ socket.end(`HTTP/1.1 200 OK\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`)
27
+ }
28
+ socket.on('data', chunk => {
29
+ received += chunk.toString('utf8')
30
+ respond()
31
+ })
32
+ respond()
33
+ })
34
+ servers.push(proxy)
35
+ await new Promise<void>((resolve, reject) => {
36
+ proxy.once('error', reject)
37
+ proxy.listen(0, '127.0.0.1', resolve)
38
+ })
39
+
40
+ const address = proxy.address()
41
+ if (!address || typeof address === 'string') throw new Error('The local proxy did not bind to a TCP port.')
42
+ const proxyUrl = `http://127.0.0.1:${address.port}`
43
+ const script = `
44
+ import { join } from 'node:path'
45
+ import { pathToFileURL } from 'node:url'
46
+
47
+ const pi = await import('@earendil-works/pi-coding-agent')
48
+ const dispatcherUrl = pathToFileURL(join(pi.getPackageDir(), 'dist/bundle/chunks/chunk-TJXZSVTD.js')).href
49
+ const { configureHttpDispatcher } = await import(dispatcherUrl)
50
+ configureHttpDispatcher()
51
+ const response = await fetch('http://127.0.0.2/dispatcher-check')
52
+ if (response.status !== 200 || await response.text() !== 'served by local proxy') throw new Error('Pi did not receive the local proxy response')
53
+ `
54
+ const env: NodeJS.ProcessEnv = {
55
+ ...(process.env.PATH ? { PATH: process.env.PATH } : {}),
56
+ HTTP_PROXY: proxyUrl,
57
+ HTTPS_PROXY: proxyUrl,
58
+ NO_PROXY: 'localhost,127.0.0.1'
59
+ }
60
+
61
+ await execFileAsync(process.execPath, ['--input-type=module', '-e', script], {
62
+ cwd: process.cwd(),
63
+ env,
64
+ timeout: 10_000
65
+ })
66
+
67
+ expect(requests).toEqual([{ url: '127.0.0.2:80', host: '127.0.0.2', tunnel: true }])
68
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createPiLoader } from './runtime'
4
+
5
+ describe('Pi runtime initialization', () => {
6
+ it('configures Pi networking before returning the module and shares concurrent initialization', async () => {
7
+ const events: string[] = []
8
+ const pi = { VERSION: '1.2.0', getPackageDir: () => '/pinned/pi' }
9
+ const load = createPiLoader(
10
+ async () => {
11
+ events.push('import Pi')
12
+ return pi
13
+ },
14
+ async (packageDir) => {
15
+ events.push(`load dispatcher from ${packageDir}`)
16
+ return { configureHttpDispatcher: () => events.push('configure dispatcher') }
17
+ }
18
+ )
19
+
20
+ const [first, second] = await Promise.all([load(), load()])
21
+
22
+ expect(first).toBe(pi)
23
+ expect(second).toBe(pi)
24
+ expect(events).toEqual(['import Pi', 'load dispatcher from /pinned/pi', 'configure dispatcher'])
25
+ })
26
+
27
+ it('rejects a Pi version change before loading its private dispatcher', async () => {
28
+ const loadDispatcher = vi.fn(async () => ({ configureHttpDispatcher: vi.fn() }))
29
+ const load = createPiLoader(async () => ({ VERSION: '1.3.0', getPackageDir: () => '/other/pi' }), loadDispatcher)
30
+
31
+ await expect(load()).rejects.toThrow('requires Pi 1.2.0')
32
+ expect(loadDispatcher).not.toHaveBeenCalled()
33
+ })
34
+
35
+ it('reports when Pi no longer exposes its private dispatcher hook', async () => {
36
+ const load = createPiLoader(
37
+ async () => ({ VERSION: '1.2.0', getPackageDir: () => '/pinned/pi' }),
38
+ async () => ({})
39
+ )
40
+
41
+ await expect(load()).rejects.toThrow('pinned Pi 1.2.0 HTTP dispatcher hook')
42
+ })
43
+
44
+ it('can retry initialization after a transient dispatcher load failure', async () => {
45
+ const configure = vi.fn()
46
+ let attempts = 0
47
+ const load = createPiLoader(
48
+ async () => ({ VERSION: '1.2.0', getPackageDir: () => '/pinned/pi' }),
49
+ async () => {
50
+ attempts++
51
+ if (attempts === 1) throw new Error('temporary import failure')
52
+ return { configureHttpDispatcher: configure }
53
+ }
54
+ )
55
+
56
+ await expect(load()).rejects.toThrow('temporary import failure')
57
+ await expect(load()).resolves.toMatchObject({ VERSION: '1.2.0' })
58
+ expect(attempts).toBe(2)
59
+ expect(configure).toHaveBeenCalledOnce()
60
+ })
61
+ })
@@ -0,0 +1,50 @@
1
+ import { join } from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+
4
+ export const PINNED_PI_VERSION = '1.2.0'
5
+ const HTTP_DISPATCHER_CHUNK = 'dist/bundle/chunks/chunk-TJXZSVTD.js'
6
+
7
+ type PiRuntimeModule = {
8
+ VERSION: string
9
+ getPackageDir(): string
10
+ }
11
+
12
+ type PiDispatcherModule = {
13
+ configureHttpDispatcher?: unknown
14
+ }
15
+
16
+ export function assertPiRuntimeVersion(version: string): void {
17
+ if (version !== PINNED_PI_VERSION) throw new Error(`The Harness Pi runtime requires Pi ${PINNED_PI_VERSION}; found ${version}. Install the Harness package's pinned dependencies.`)
18
+ }
19
+
20
+ /** Builds a cached Pi loader so initialization order and compatibility behavior can be verified. */
21
+ export function createPiLoader<T extends PiRuntimeModule>(
22
+ importPi: () => Promise<T>,
23
+ importDispatcher: (packageDir: string) => Promise<PiDispatcherModule>
24
+ ): () => Promise<T> {
25
+ let loading: Promise<T> | undefined
26
+
27
+ return () => {
28
+ if (!loading) {
29
+ loading = (async () => {
30
+ const pi = await importPi()
31
+ assertPiRuntimeVersion(pi.VERSION)
32
+ const dispatcher = await importDispatcher(pi.getPackageDir())
33
+ if (typeof dispatcher.configureHttpDispatcher !== 'function') {
34
+ throw new Error(`The Harness requires the pinned Pi ${PINNED_PI_VERSION} HTTP dispatcher hook; install the Harness package's pinned dependencies.`)
35
+ }
36
+ dispatcher.configureHttpDispatcher()
37
+ return pi
38
+ })().catch((error: unknown) => {
39
+ loading = undefined
40
+ throw error
41
+ })
42
+ }
43
+ return loading
44
+ }
45
+ }
46
+
47
+ export const loadPi = createPiLoader(
48
+ () => import('@earendil-works/pi-coding-agent'),
49
+ async (packageDir) => await import(pathToFileURL(join(packageDir, HTTP_DISPATCHER_CHUNK)).href) as PiDispatcherModule
50
+ )
@@ -6,10 +6,10 @@ import { ensureSharedDir, sharedDir } from '../accounts/env'
6
6
  import { invalid, notFound } from '../core/errors'
7
7
  import { findProject } from '../core/projects'
8
8
  import type { Harness } from '../harness'
9
- import type { CallerContext } from '../memory/caller'
9
+ import type { CallerContext } from '../core/caller'
10
10
  import type { Launch } from '../prompt/compile'
11
+ import { materialise, type HarnessChoice } from '../tree/materialise'
11
12
  import * as attachments from './attachments'
12
- import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
13
13
  import { catalogue } from './catalogue'
14
14
  import { inventory } from './inventory'
15
15
  import type { Loading } from './plugins'
@@ -67,6 +67,8 @@ export interface InventoryParams extends PluginScope {
67
67
  launch?: Launch
68
68
  tools?: string[]
69
69
  connections?: boolean
70
+ /** The harness tree the caller runs, so what it adds is listed too. */
71
+ harness?: HarnessChoice | null
70
72
  }
71
73
 
72
74
  export async function inventoryFor(harness: Harness, params: InventoryParams): Promise<Inventory> {
@@ -79,7 +81,8 @@ export async function inventoryFor(harness: Harness, params: InventoryParams): P
79
81
  // What is switched off for the caller is not in what reaches it.
80
82
  plugins: await list(harness, { ...params, agentId: params.agentId ?? params.context?.agentId ?? null, projectId: params.projectId ?? params.context?.projectId ?? null }),
81
83
  descriptors: harness.descriptors,
82
- saved: harness.apps.connections.list()
84
+ saved: harness.apps.connections.list(),
85
+ tree: params.harness ? materialise(harness.env.root, params.harness, { trust: harness.trust }) : null
83
86
  })
84
87
  }
85
88
 
@@ -129,13 +132,12 @@ export async function attach(harness: Harness, params: AttachParams, on: boolean
129
132
  if (contribution?.kind === 'connection') {
130
133
  harness.apps.attach(contribution.id.slice(`${plugin.id}/connection/`.length), scope, on)
131
134
  } else {
132
- const registryItem = plugin.id === YOURS_PLUGIN.id || plugin.id === INCLUDED_PLUGIN.id
133
- // A registry item's choice is stored under its own id; anything found, under its id and then its plugin's.
134
- const ids = registryItem ? [contribution!.id.slice(plugin.id.length + 1)] : contribution ? [contribution.id, plugin.id] : [plugin.id]
135
- // What it is until a person says otherwise: a built-in registry item is on, a person's own is
136
- // off, a part that ships switched off is off, and anything found on disk is on.
135
+ // A choice is stored under the contribution's id and then its plugin's.
136
+ const ids = contribution ? [contribution.id, plugin.id] : [plugin.id]
137
+ // What it is until a person says otherwise: a part that ships switched off is off, and
138
+ // anything found on disk is on.
137
139
  const shipped = harness.descriptors.find((descriptor) => descriptor.id === plugin.id)?.switchable
138
- const fallback = registryItem ? plugin.id === INCLUDED_PLUGIN.id : shipped ? shipped.on : true
140
+ const fallback = shipped ? shipped.on : true
139
141
  const choices = attachments.load(root)
140
142
  attachments.set(choices, ids, scope, on, fallback)
141
143
  attachments.save(root, choices)
@@ -3,12 +3,11 @@
3
3
  import { CONNECTION_TOOLS, NATIVE_GROUP } from '../prompt/compile'
4
4
  import { ASK_USER } from '../tools/askUser'
5
5
  import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from '../sessions/subagents'
6
- import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
7
- import { MEMORY_TOOLS, RECALL_TOOLS, TRAJECTORY_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
6
+ import { RECALL_TOOLS, SKILL_TOOLS, TRAJECTORY_TOOLS, WIKI_READ_TOOLS, WIKI_WRITE_TOOLS, type HarnessToolDefinition } from '../tools/definitions'
8
7
  import { FIRST_PARTY_PLUGINS } from './firstParty'
9
8
  import type { PluginDescriptor } from './types'
10
9
 
11
- const tools = (definitions: readonly WikiToolDefinition[]): PluginDescriptor['tools'] => definitions.map((tool) => ({ name: tool.name, description: tool.label }))
10
+ const tools = (definitions: readonly HarnessToolDefinition[]): PluginDescriptor['tools'] => definitions.map((tool) => ({ name: tool.name, description: tool.label }))
12
11
 
13
12
  /** Pi itself. Its command's built-in extensions are added to it as they are found. */
14
13
  export const PI_PLUGIN: PluginDescriptor = {
@@ -20,55 +19,39 @@ export const PI_PLUGIN: PluginDescriptor = {
20
19
  toolGroups: ['Native']
21
20
  }
22
21
 
23
- /** A person's own registry. It owns the rules and skills sections even while it is empty. */
24
- export const YOURS_PLUGIN: PluginDescriptor = {
25
- id: 'yours',
26
- title: 'Yours',
27
- description: 'Rules, skills and prompts you wrote, kept as files in the registry folder.',
28
- manage: 'registry',
22
+ /** The harness tree: the eight components that shape what an agent is told and can do. */
23
+ export const HARNESS_TREE_PLUGIN: PluginDescriptor = {
24
+ id: 'harness',
25
+ title: 'Harness',
26
+ description: 'The agent\'s own harness: system prompt, guardrails, memory and tool guidance in every prompt; skills, scripts and subagents on demand; plugins in the tool loop. Versioned, and refined from evidence.',
27
+ required: 'Every agent runs on its harness.',
29
28
  sections: [
30
- { id: 'rules', name: 'Rules', description: 'The full text of every rule that is on.' },
31
- { id: 'skills', name: 'Skills list', description: 'One line for each skill that is on.' }
29
+ { id: 'system', name: 'System prompt', description: 'c1: role and procedure.' },
30
+ { id: 'guardrails', name: 'Guardrails', description: 'c2: standing rules.' },
31
+ { id: 'memory', name: 'Memory', description: 'c3: durable facts about this kind of work.' },
32
+ { id: 'subagents', name: 'Subagents', description: 'c5: the list of this harness\'s subagents.' },
33
+ { id: 'scripts', name: 'Scripts', description: 'c7: where this harness\'s scripts are.' }
32
34
  ],
33
- tools: WIKI_TOOLS.filter((tool) => tool.name === 'skill_load').map((tool) => ({ name: tool.name, description: tool.label }))
35
+ tools: tools(SKILL_TOOLS)
34
36
  }
35
37
 
36
- export const INCLUDED_PLUGIN: PluginDescriptor = { id: 'included', title: 'Included', description: 'Rules, skills and prompts that ship with the app.', manage: 'registry' }
37
-
38
38
  export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
39
39
  PI_PLUGIN,
40
40
  { id: 'subagents', title: 'Subagents', description: 'Isolated child runs of the same caller, bounded by parent capabilities and nesting depth.', required: 'Availability is selected by the session host; child runs retain the parent permission boundary.', tools: [{ name: SPAWN_SUBAGENT, description: 'Start a subagent' }, { name: WAIT_SUBAGENTS, description: 'Wait for subagents' }] },
41
- {
42
- id: 'memory',
43
- title: 'Memory',
44
- description: 'Blocks of text that are always in context and that the agent edits as it works.',
45
- required: 'Every agent keeps its own memory, and the background upkeep depends on it.',
46
- // Facts are memory too: what the agent wrote down, recalled on request.
47
- tools: tools([...MEMORY_TOOLS, ...WIKI_TOOLS.filter((tool) => tool.name === 'memory_facts')]),
48
- sections: [{ id: 'blocks', name: 'Memory blocks', description: 'Every block attached to the caller, in full.' }],
49
- toolGroups: ['Memory']
50
- },
41
+ HARNESS_TREE_PLUGIN,
51
42
  {
52
43
  id: 'wiki',
53
44
  title: 'Wiki',
54
- description: 'Pages and facts the agent searches, reads, and proposes changes to.',
55
- required: 'What was learned is recorded and checked here.',
56
- // `WIKI_TOOLS` is every memory tool; the Wiki's own are the ones that are not Memory's or History's.
57
- tools: tools(WIKI_TOOLS.filter((tool) => tool.name.startsWith('wiki_'))),
45
+ description: 'Notes about projects, linked to each other, that agents search and read.',
46
+ required: 'Project notes are reached through it.',
47
+ tools: tools([...WIKI_READ_TOOLS, ...WIKI_WRITE_TOOLS]),
58
48
  sections: [
59
- { id: 'knowledge', name: 'Knowledge map', description: 'Project pages, the Library count, and facts that match the request.' },
60
- { id: 'references', name: 'Selected references', description: 'Pages and skills pinned to one request.' }
49
+ { id: 'wiki', name: 'Wiki', description: 'The current project\'s notes.' },
50
+ { id: 'references', name: 'Selected references', description: 'Notes pinned to one request.' }
61
51
  ],
62
- toolGroups: ['Knowledge']
63
- },
64
- { id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]) },
65
- {
66
- id: 'plugin-proposals',
67
- title: 'Plugin proposals',
68
- description: 'Lets an agent propose a plugin. You accept it in the Inbox, switch it on, and trust its code; an agent can do none of those.',
69
- required: 'It only proposes. Nothing is saved or run until you decide.',
70
- tools: [{ name: PLUGIN_PROPOSE, description: 'Propose a plugin' }]
52
+ toolGroups: ['Wiki']
71
53
  },
54
+ { id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]), toolGroups: ['History'] },
72
55
  { id: 'ask-user', title: 'Ask the user', description: 'Lets the agent stop and ask a question with choices.', required: 'An agent must be able to stop and ask you.', tools: [{ name: ASK_USER }] },
73
56
  {
74
57
  id: 'connected-apps',
@@ -79,6 +62,5 @@ export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
79
62
  tools: CONNECTION_TOOLS.map((name) => ({ name })),
80
63
  toolGroups: ['Connections']
81
64
  },
82
- YOURS_PLUGIN,
83
65
  ...FIRST_PARTY_PLUGINS
84
66
  ]
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
6
6
  import { memorySecretStore } from '../accounts/secrets'
7
7
  import { createHarness, type Harness } from '../harness'
8
8
  import { methods } from '../server/methods'
9
- import { WIKI_TOOLS } from '../tools/definitions'
9
+ import { HARNESS_TOOLS } from '../tools/definitions'
10
10
  import { WEB_PLUGIN } from '../web/tools'
11
11
  import { rootWithProject, writeFile } from '../../tests/helpers'
12
12
  import { HARNESS_PLUGINS } from './builtin'
@@ -68,12 +68,11 @@ describe('the plugin list', () => {
68
68
  ['project', 'project', ['code:local.ts:untrusted', 'instruction:AGENTS.md:on']],
69
69
  ['package:review-kit', 'package', ['code:guard.ts:on', 'command:guard:on', 'skill:audit:on']],
70
70
  ['home', 'home', ['skill:deploy:on']],
71
- ['yours', 'yours', ['instruction:Rules:on', 'instruction:Skills list:on', 'tool:skill_load:on', 'command:review:on', 'instruction:Run tests:on', 'skill:Release notes:on', 'skill:unused:off']],
72
71
  ['app-identity', 'application', ['instruction:Identity line:on']],
73
72
  ['app-tasks', 'application', ['tool:start_task:on']],
74
73
  ['web', 'application', ['tool:web_search:on', 'tool:web_fetch:on']]
75
74
  ])
76
- expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'subagents', 'wiki'])
75
+ expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'harness', 'history', 'output-trim', 'subagents', 'wiki'])
77
76
 
78
77
  const kit = plugins.find((plugin) => plugin.id === 'package:review-kit')!
79
78
  // It was there when trust was first asked per package, so it was taken over as trusted.
@@ -81,8 +80,6 @@ describe('the plugin list', () => {
81
80
  expect(kit.contributions[0].description).toBe('Adds commands /guard; hooks tool_call.')
82
81
  const project = plugins.find((plugin) => plugin.id === 'project')!
83
82
  expect(project).toMatchObject({ runsCode: true, trust: 'untrusted', code: { files: ['.pi/extensions/local.ts'] } })
84
- const yours = plugins.find((plugin) => plugin.id === 'yours')!
85
- expect(yours.contributions.map((entry) => entry.scope)).toEqual([null, null, null, 'App', 'Everywhere', 'App', null])
86
83
  const pi = plugins.find((plugin) => plugin.id === 'pi')!
87
84
  expect(pi.required).toBe('Pi runs every session.')
88
85
  expect(pi.contributions.filter((entry) => entry.kind === 'code').map((entry) => [entry.name, entry.state])).toEqual([['mcp', 'on'], ['llama.cpp', 'off']])
@@ -94,7 +91,7 @@ describe('the plugin list', () => {
94
91
  expect(names.filter((name, index) => names.indexOf(name) !== index)).toEqual([])
95
92
  }
96
93
  const declared = HARNESS_PLUGINS.flatMap((plugin) => (plugin.tools ?? []).map((tool) => tool.name))
97
- expect(WIKI_TOOLS.map((tool) => tool.name).filter((name) => !declared.includes(name))).toEqual([])
94
+ expect(HARNESS_TOOLS.map((tool) => tool.name).filter((name) => !declared.includes(name))).toEqual([])
98
95
  })
99
96
 
100
97
  it('lists nothing a folder brings when no folder is named, and what every account shares even before the first account exists', async () => {
@@ -133,20 +130,21 @@ describe('what reaches one caller', () => {
133
130
  }
134
131
 
135
132
  it('attributes every prompt section, tool group and tool to a plugin', async () => {
136
- const { call, cwd } = setup()
137
- const result = await call<Inventory>('plugins.inventory', { cwd, context: { agentId: 'a1', projectId: 'p1' }, launch, tools: ['read', 'bash', 'start_task', 'web_search'] })
133
+ const { call, cwd, harness } = setup()
134
+ const { commit } = await import('../tree/store')
135
+ commit(harness.env.root, 'a1', { 'systemprompt.md': 'You review changes.\n', 'guardrails.md': '- When writing release notes, load the `release` skill.\n', 'memory.md': '', 'tool_guidance.md': 'Prefer grep.\n', 'skills/release/SKILL.md': '---\nname: release\ndescription: writing release notes\n---\nSteps.\n' }, { kind: 'seed' })
136
+ const result = await call<Inventory>('plugins.inventory', { cwd, context: { agentId: 'a1', projectId: 'p1' }, launch, tools: ['read', 'bash', 'start_task', 'web_search'], harness: { scope: 'a1' } })
138
137
  expect(result.unattributed).toEqual([])
139
138
  const of = (plugin: string) => result.items.filter((item) => item.pluginId === plugin).map((item) => `${item.kind}:${item.name}:${item.delivery}`)
140
139
  expect(of('app-identity')).toEqual(['instruction:Identity line:system'])
141
140
  expect(of('app-tasks')).toEqual(['instruction:Tool guidance: Tasks:system', 'tool:start_task:tool'])
142
- expect(of('yours')).toEqual(['instruction:Run tests:system', 'instruction:Skills list:system', 'command:review:on-demand', 'skill:Release notes:on-demand', 'tool:skill_load:tool'])
143
- // Facts are memory: recalling them is a memory tool, though it is defined beside the Wiki's.
144
- expect(of('memory')).toEqual(['instruction:Memory blocks:context', 'instruction:Tool guidance: Memory:system', 'tool:memory_append:tool', 'tool:memory_replace:tool', 'tool:memory_rethink:tool', 'tool:memory_facts:tool'])
141
+ expect(of('harness')).toEqual(['instruction:System prompt:system', 'instruction:Guardrails:system', 'skill:release:on-demand', 'tool:skill_load:tool'])
142
+ expect(of('wiki')).toEqual(['instruction:Wiki:context', 'instruction:Tool guidance: Wiki:system', 'tool:wiki_search:tool', 'tool:wiki_read:tool', 'tool:wiki_list:tool'])
143
+ expect(of('history')).toEqual(['instruction:Tool guidance: History:system', 'tool:history_search:tool', 'tool:history_read:tool', 'tool:trajectory_search:tool', 'tool:trajectory_read:tool'])
145
144
  expect(of('pi')).toEqual(['instruction:Tool guidance: Native:system', 'tool:read:tool', 'tool:bash:tool', 'instruction:Base prompt:system'])
146
145
  // The working folder's code is not trusted, so only its instruction file reaches the caller.
147
146
  expect(of('project')).toEqual(['instruction:AGENTS.md:system'])
148
147
  expect(of('package:review-kit')).toEqual(['code:guard.ts:code', 'skill:audit:on-demand'])
149
- expect(result.items.find((item) => item.name === 'Run tests')).toMatchObject({ contributionId: 'yours/rules/tests', cut: false })
150
148
  expect(result.items.find((item) => item.name === 'AGENTS.md')?.chars).toBe(10)
151
149
  expect(result.systemChars).toBeGreaterThan(0)
152
150
  expect(result.contextChars).toBeGreaterThan(0)
@@ -177,31 +175,6 @@ describe('what reaches one caller', () => {
177
175
  })
178
176
 
179
177
  describe('switching a plugin on and off', () => {
180
- const state = (plugins: Plugin[], id: string) => plugins.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === id)?.state
181
-
182
- it('switches a registry item for one agent, and the agent then gets it in its prompt', async () => {
183
- const { call } = setup()
184
- const compiled = async (agentId: string) => (await call<{ text: string }>('context.compile', { context: { agentId, actor: 'Chat' }, render: true })).text
185
- expect(await compiled('a1')).not.toContain('Release notes')
186
- const after = await call<Plugin[]>('plugins.attach', { id: 'yours/skills/release', scope: { agentId: 'a1' } })
187
- const release = after.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === 'yours/skills/release')!
188
- expect(release.attachment).toEqual({ global: false, on: { agentIds: ['a1'], projectIds: ['p1'] }, off: { agentIds: [], projectIds: [] } })
189
- expect(release.scope).toBe('App, 1 agent')
190
- expect(await compiled('a1')).toContain('Release notes')
191
- expect(await compiled('a2')).not.toContain('Release notes')
192
- // The earlier calls read the same choices.
193
- expect((await call<any>('skills.list')).enabled).toEqual({ global: ['rules/tests'], p1: ['skills/release', 'prompts/review'] })
194
-
195
- // A rule that is on everywhere is switched off for one agent only.
196
- await call('plugins.detach', { id: 'yours/rules/tests', scope: { agentId: 'a1' } })
197
- expect(await compiled('a1')).not.toContain('Always run the test suite')
198
- expect(await compiled('a2')).toContain('Always run the test suite')
199
- expect(state(await call<Plugin[]>('plugins.list', { agentId: 'a1' }), 'yours/rules/tests')).toBe('off')
200
- expect(state(await call<Plugin[]>('plugins.list', { agentId: 'a2' }), 'yours/rules/tests')).toBe('on')
201
-
202
- await call('plugins.forgetAgent', { agentId: 'a1' })
203
- expect(await compiled('a1')).toContain('Always run the test suite')
204
- })
205
178
 
206
179
  it('stops a found plugin, or one item of it, from loading for the caller it was switched off for', async () => {
207
180
  const { call, harness, cwd, base } = setup()
@@ -230,13 +203,12 @@ describe('switching a plugin on and off', () => {
230
203
 
231
204
  it('refuses a required part, an application part, and a file Pi reads itself, and says why', async () => {
232
205
  const { call, cwd } = setup()
233
- await expect(call('plugins.detach', { id: 'memory' })).rejects.toThrow('Memory cannot be switched off. Every agent keeps its own memory')
234
- await expect(call('plugins.detach', { id: 'memory/tool/memory_append' })).rejects.toThrow('cannot be switched off')
206
+ await expect(call('plugins.detach', { id: 'harness' })).rejects.toThrow('Harness cannot be switched off. Every agent runs on its harness')
207
+ await expect(call('plugins.detach', { id: 'wiki/tool/wiki_read' })).rejects.toThrow('cannot be switched off')
235
208
  await expect(call('plugins.detach', { id: 'app-tasks' })).rejects.toThrow('switched by the application')
236
- await expect(call('plugins.detach', { id: 'yours' })).rejects.toThrow('one item at a time')
237
209
  await expect(call('plugins.detach', { cwd, id: 'project/instruction/AGENTS.md' })).rejects.toThrow('cannot be switched on its own')
238
210
  await expect(call('plugins.detach', { id: 'no-such-plugin' })).rejects.toThrow()
239
- await expect(call('plugins.attach', { id: 'yours/skills/release', scope: { projectId: 'nope' } })).rejects.toThrow()
211
+ await expect(call('plugins.attach', { id: 'package:review-kit', scope: { projectId: 'nope' } })).rejects.toThrow()
240
212
  })
241
213
 
242
214
  it('switches a connected app for one agent through its own scope', async () => {