@xingular/harness 0.0.0-stage → 0.1.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 (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,106 @@
1
+ // Installing, updating and removing a package. Pi's own package manager does the work, against
2
+ // the settings every account shares, so the `pi` command and the harness agree on what is
3
+ // installed. Fetching runs none of a package's code, and what was installed is switched off and
4
+ // untrusted until a person has looked at it.
5
+ import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { basename, isAbsolute, join, resolve } from 'node:path'
8
+
9
+ import { ensureSharedDir } from '../accounts/env'
10
+ import { invalid, notFound } from '../core/errors'
11
+ import { now } from '../core/fs'
12
+ import type { Harness } from '../harness'
13
+ import { ensureAdopted, list, type PluginScope } from './api'
14
+ import * as attachments from './attachments'
15
+ import { withoutInstallScripts } from './discover'
16
+ import type { Plugin } from './types'
17
+
18
+ /** A folder on this machine, or a package as Pi's settings name it. A bare name is an npm package. */
19
+ export function normalizeSource(input: string): string {
20
+ const source = input.trim()
21
+ if (!source) throw invalid('Name a package: a folder, an npm name, or a git address.')
22
+ if (source.startsWith('~/')) return join(process.env.HOME || homedir(), source.slice(2))
23
+ if (isAbsolute(source)) return resolve(source)
24
+ if (source.startsWith('./') || source.startsWith('../')) throw invalid('Give the whole path of the folder.')
25
+ return /^(npm:|git:|https?:\/\/|ssh:\/\/|git@)/.test(source) ? source : `npm:${source}`
26
+ }
27
+
28
+ async function manager(harness: Harness) {
29
+ const pi = await import('@earendil-works/pi-coding-agent')
30
+ // The shared folder is where `settings.json` really is; an account's own is a link to it.
31
+ const agentDir = ensureSharedDir(harness.accounts.dir)
32
+ const settings = pi.SettingsManager.create(harness.stateDir, agentDir)
33
+ return { settings, packages: new pi.DefaultPackageManager({ cwd: harness.stateDir, agentDir, settingsManager: settings }) }
34
+ }
35
+
36
+ const changed = (harness: Harness): void => harness.bus.emit({ type: 'plugins.changed' })
37
+
38
+ /**
39
+ * Installs a package for every account. It arrives switched off, and if it brings code, not
40
+ * trusted: nothing of it reaches a session until a person switches it on and trusts it.
41
+ */
42
+ export async function install(harness: Harness, params: PluginScope & { source: string }): Promise<Plugin[]> {
43
+ const source = normalizeSource(params.source)
44
+ if (isAbsolute(source) && !(existsSync(source) && statSync(source).isDirectory())) throw invalid('That folder does not exist.')
45
+ // What was already there is taken over as trusted before anything new arrives, never after.
46
+ await ensureAdopted(harness)
47
+ const before = new Set((await list(harness, params)).map((plugin) => plugin.id))
48
+ const { settings, packages } = await manager(harness)
49
+ if (isAbsolute(source)) {
50
+ // Pi would save a folder as a path relative to the settings file, and each account reads
51
+ // that file through a link in its own folder, where a relative path points somewhere else.
52
+ const named = settings.getGlobalSettings().packages ?? []
53
+ if (!named.some((entry) => (typeof entry === 'string' ? entry : entry.source) === source)) settings.setPackages([...named, source])
54
+ await settings.flush()
55
+ } else {
56
+ await withoutInstallScripts(() => packages.installAndPersist(source))
57
+ }
58
+ const after = await list(harness, params)
59
+ const added = after.filter((plugin) => plugin.install && !before.has(plugin.id))
60
+ if (added.length) {
61
+ const choices = attachments.load(harness.env.root)
62
+ for (const plugin of added) attachments.set(choices, [plugin.id], {}, false, true)
63
+ attachments.save(harness.env.root, choices)
64
+ }
65
+ changed(harness)
66
+ return added.length ? list(harness, params) : after
67
+ }
68
+
69
+ function installed(plugins: readonly Plugin[], pluginId: string): Plugin & { install: NonNullable<Plugin['install']> } {
70
+ const plugin = plugins.find((candidate) => candidate.id === pluginId)
71
+ if (!plugin) throw notFound('Plugin')
72
+ if (!plugin.install) throw invalid(`${plugin.title} was not installed as a package, so it cannot be removed or updated here.`)
73
+ return plugin as Plugin & { install: NonNullable<Plugin['install']> }
74
+ }
75
+
76
+ /** Removes an installed package. One kept as a folder beside a person's data is moved to the trash there. */
77
+ export async function remove(harness: Harness, params: PluginScope & { pluginId: string }): Promise<Plugin[]> {
78
+ const plugin = installed(await list(harness, params), params.pluginId)
79
+ if (plugin.install.kind === 'folder') {
80
+ const trash = join(harness.env.root, 'trash', 'plugins')
81
+ mkdirSync(trash, { recursive: true })
82
+ renameSync(plugin.install.source, join(trash, `${basename(plugin.install.source)}-${now().replace(/[:.]/g, '-')}`))
83
+ } else {
84
+ await (await manager(harness)).packages.removeAndPersist(plugin.install.source)
85
+ }
86
+ const choices = attachments.load(harness.env.root)
87
+ for (const scope of [choices.global, ...Object.values(choices.agents), ...Object.values(choices.projects)]) {
88
+ scope.on = scope.on.filter((id) => id !== plugin.id && !id.startsWith(`${plugin.id}/`))
89
+ scope.off = scope.off.filter((id) => id !== plugin.id && !id.startsWith(`${plugin.id}/`))
90
+ }
91
+ delete choices.settings[plugin.id]
92
+ attachments.save(harness.env.root, choices)
93
+ if (plugin.code) harness.plugins.trust.revoke(plugin.code.hash)
94
+ changed(harness)
95
+ return list(harness, params)
96
+ }
97
+
98
+ /** Fetches a newer version of one installed package, or of all of them. Changed code waits for trust again. */
99
+ export async function update(harness: Harness, params: PluginScope & { pluginId?: string | null }): Promise<Plugin[]> {
100
+ const plugin = params.pluginId ? installed(await list(harness, params), params.pluginId) : null
101
+ if (plugin?.install.kind === 'folder') throw invalid(`${plugin.title} is a folder on this machine. Change its files to update it.`)
102
+ const { packages } = await manager(harness)
103
+ await withoutInstallScripts(() => packages.update(plugin?.install.source))
104
+ changed(harness)
105
+ return list(harness, params)
106
+ }
@@ -0,0 +1,123 @@
1
+ // What reaches one caller: every prompt section, tool, skill, connection and extension, each
2
+ // attributed to the plugin it comes from. Anything no plugin accounts for is reported, so a new
3
+ // source of model-visible text cannot appear without showing up here.
4
+ import { readFileSync } from 'node:fs'
5
+
6
+ import type { HarnessEnv } from '../core/env'
7
+ import { charCount } from '../core/text'
8
+ import { allowed } from '../mcp/connections'
9
+ import type { Connection } from '../mcp/types'
10
+ import type { CallerContext } from '../memory/caller'
11
+ import { BUILT_IN_DYNAMIC, compile, toolGroups, type Launch } from '../prompt/compile'
12
+ import * as registry from '../skills/registry'
13
+ import { ASK_USER } from '../tools/askUser'
14
+ import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
15
+ import { WIKI_TOOL_NAMES } from '../tools/definitions'
16
+ import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
17
+ import type { Contribution, Inventory, InventoryItem, Plugin, PluginDescriptor } from './types'
18
+
19
+ export interface InventoryInput {
20
+ env: HarnessEnv
21
+ context: CallerContext
22
+ /** What the application adds to the prompt for this caller, as it would for a session. */
23
+ launch: Launch
24
+ /** The tools the caller has switched on, as a session's `allow` list. */
25
+ tools: readonly string[]
26
+ /** Whether the caller gets the connected-apps tools. */
27
+ connections: boolean
28
+ plugins: readonly Plugin[]
29
+ descriptors: readonly PluginDescriptor[]
30
+ saved: readonly Connection[]
31
+ }
32
+
33
+ const size = (path: string | null): number | null => {
34
+ if (!path) return null
35
+ try {
36
+ return charCount(readFileSync(path, 'utf8'))
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ export function inventory(input: InventoryInput): Inventory {
43
+ const { env, context, launch, descriptors } = input
44
+ const items: InventoryItem[] = []
45
+ const unattributed: Inventory['unattributed'] = []
46
+ const item = (pluginId: string, kind: InventoryItem['kind'], name: string, delivery: InventoryItem['delivery'], chars: number | null, rest: Partial<InventoryItem> = {}): void => {
47
+ items.push({ pluginId, contributionId: null, kind, name, delivery, chars, cut: false, ...rest })
48
+ }
49
+
50
+ const pack = compile(env, context, launch)
51
+ const dynamic = new Set([...BUILT_IN_DYNAMIC.map((entry) => entry.id), ...(launch.sections ?? []).filter((section) => section.dynamic).map((section) => section.id)])
52
+ const active = registry.enabledFor(env.root, context.projectId ?? null, context.agentId ?? null)
53
+ const owner = (entry: registry.RegistryItem): string => (entry.builtin ? INCLUDED_PLUGIN.id : YOURS_PLUGIN.id)
54
+ let systemChars = 0
55
+ let contextChars = 0
56
+
57
+ for (const section of pack.sections) {
58
+ const delivery = dynamic.has(section.id) ? 'context' : 'system'
59
+ if (delivery === 'context') contextChars += section.size
60
+ else systemChars += section.size
61
+ if (section.id === 'tools') {
62
+ // One paragraph per tool group; each belongs to the plugin that provides the group.
63
+ for (const group of toolGroups(context, launch)) {
64
+ if (!group.guidance) continue
65
+ const plugin = descriptors.find((descriptor) => descriptor.toolGroups?.includes(group.group))
66
+ if (!plugin) unattributed.push({ kind: 'toolGroup', id: group.group })
67
+ item(plugin?.id ?? '', 'instruction', `Tool guidance: ${group.group}`, delivery, charCount(group.guidance), { cut: section.cut })
68
+ }
69
+ continue
70
+ }
71
+ if (section.id === 'rules') {
72
+ for (const rule of active.filter((entry) => entry.kind === 'rules')) {
73
+ item(owner(rule), 'instruction', rule.name, delivery, charCount(`### ${rule.name} (${rule.id})\n\n${registry.itemBody(rule).trim()}`), { contributionId: `${owner(rule)}/${rule.id}`, cut: section.cut })
74
+ }
75
+ continue
76
+ }
77
+ const plugin = descriptors.find((descriptor) => descriptor.sections?.some((declared) => declared.id === section.id))
78
+ if (!plugin) unattributed.push({ kind: 'section', id: section.id })
79
+ const name = plugin?.sections?.find((declared) => declared.id === section.id)?.name ?? section.title
80
+ item(plugin?.id ?? '', 'instruction', name, delivery, section.size, { contributionId: plugin ? `${plugin.id}/instruction/${name}` : null, cut: section.cut })
81
+ }
82
+
83
+ // A skill's or a prompt's own text is loaded only when it is used.
84
+ for (const entry of active.filter((candidate) => candidate.kind === 'skills' || candidate.kind === 'prompts')) {
85
+ item(owner(entry), entry.kind === 'skills' ? 'skill' : 'command', entry.name, 'on-demand', size(entry.path), { contributionId: `${owner(entry)}/${entry.id}` })
86
+ }
87
+
88
+ const names = [...input.tools]
89
+ const extensionToolNames = new Set(input.plugins.filter((plugin) => ['project', 'package', 'shared', 'home'].includes(plugin.source)).flatMap((plugin) => plugin.contributions.filter((entry) => entry.kind === 'tool' && entry.state === 'on').map((entry) => entry.name)))
90
+ for (const name of [ASK_USER, PLUGIN_PROPOSE, ...WIKI_TOOL_NAMES, ...(input.connections ? ['mcp_list_tools', 'mcp_call_tool'] : [])]) if (!names.includes(name)) names.push(name)
91
+ for (const name of names) {
92
+ if (extensionToolNames.has(name)) continue
93
+ const plugin = descriptors.find((descriptor) => descriptor.tools?.some((tool) => tool.name === name))
94
+ if (!plugin) unattributed.push({ kind: 'tool', id: name })
95
+ item(plugin?.id ?? '', 'tool', name, 'tool', null, { contributionId: plugin ? `${plugin.id}/tool/${name}` : null })
96
+ }
97
+
98
+ if (input.connections) {
99
+ for (const connection of input.saved.filter((candidate) => allowed(candidate, { agentId: context.agentId ?? null, projectId: context.projectId ?? null }))) {
100
+ item('connected-apps', 'connection', connection.name || connection.id, 'tool', null, { contributionId: `connected-apps/connection/${connection.id}` })
101
+ }
102
+ }
103
+
104
+ // What the harness found on disk and lets load. Pi's command's own extensions exist only in a terminal.
105
+ const delivered: Record<Contribution['kind'], InventoryItem['delivery']> = { code: 'code', skill: 'on-demand', command: 'on-demand', instruction: 'system', theme: 'code', tool: 'tool', connection: 'tool' }
106
+ const terminal = context.surface === 'cli'
107
+ for (const plugin of input.plugins) {
108
+ if (!['project', 'package', 'shared', 'home', 'pi'].includes(plugin.source)) continue
109
+ for (const found of plugin.contributions) {
110
+ if (found.state !== 'on' || (plugin.source === 'pi' && (found.kind !== 'code' || !terminal)) || (found.kind === 'theme' && !terminal)) continue
111
+ item(plugin.id, found.kind, found.name, found.background ? 'background' : delivered[found.kind], ['instruction', 'skill', 'command'].includes(found.kind) ? size(found.path) : null, { contributionId: found.id })
112
+ }
113
+ }
114
+ // The harness's own switched-on extensions.
115
+ for (const plugin of input.plugins) {
116
+ if (plugin.source !== 'builtin') continue
117
+ for (const own of plugin.contributions) if (own.kind === 'code' && own.state === 'on') item(plugin.id, 'code', own.name, 'code', null, { contributionId: own.id })
118
+ }
119
+ item('pi', 'instruction', 'Base prompt', 'system', null)
120
+
121
+ const used = new Set(items.map((entry) => entry.pluginId))
122
+ return { plugins: input.plugins.filter((plugin) => used.has(plugin.id)), others: input.plugins.filter((plugin) => !used.has(plugin.id)), items, unattributed, systemChars, contextChars }
123
+ }
@@ -0,0 +1,44 @@
1
+ // What a package says about itself in its `package.json`: Pi's own fields, and under `harness`
2
+ // which part of the harness it extends and the file of hooks the harness runs for it.
3
+ import { basename, join } from 'node:path'
4
+
5
+ import { readJsonOr, safeRelative } from '../core/fs'
6
+
7
+ export interface PackageManifest {
8
+ name: string
9
+ description: string
10
+ version: string | null
11
+ /** The parts it adds to, by id: `memory`, `wiki`, `history`. */
12
+ extends: string[]
13
+ /** The hooks file, inside the package, or `null` when it has none. */
14
+ hooks: string | null
15
+ }
16
+
17
+ interface Raw {
18
+ name?: unknown
19
+ description?: unknown
20
+ version?: unknown
21
+ harness?: unknown
22
+ }
23
+
24
+ /** A path inside the package, or `null` for one that is missing, absolute, or climbs out of it. */
25
+ function inside(path: unknown): string | null {
26
+ if (typeof path !== 'string' || !path.trim()) return null
27
+ try {
28
+ return safeRelative(path.replace(/^\.\//, ''))
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ export function packageManifest(packageRoot: string): PackageManifest {
35
+ const manifest = readJsonOr<Raw>(join(packageRoot, 'package.json'), {})
36
+ const own = (manifest.harness && typeof manifest.harness === 'object' && !Array.isArray(manifest.harness) ? manifest.harness : {}) as { extends?: unknown; hooks?: unknown }
37
+ return {
38
+ name: typeof manifest.name === 'string' && manifest.name ? manifest.name : basename(packageRoot),
39
+ description: typeof manifest.description === 'string' ? manifest.description : '',
40
+ version: typeof manifest.version === 'string' ? manifest.version : null,
41
+ extends: (Array.isArray(own.extends) ? own.extends : typeof own.extends === 'string' ? [own.extends] : []).filter((id): id is string => typeof id === 'string' && !!id),
42
+ hooks: inside(own.hooks)
43
+ }
44
+ }
@@ -0,0 +1,205 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { Bus } from '../core/bus'
7
+ import type { Attachments } from './attachments'
8
+ import { loadResources, type TurnParams } from '../sessions/runner'
9
+ import { piResolver, type Found } from './discover'
10
+ import { Plugins } from './plugins'
11
+ import { projectCode } from './trust'
12
+
13
+ const temp = (prefix: string): string => mkdtempSync(join(tmpdir(), prefix))
14
+
15
+ function write(path: string, text: string): void {
16
+ mkdirSync(join(path, '..'), { recursive: true })
17
+ writeFileSync(path, text)
18
+ }
19
+
20
+ /** An extension that leaves a file behind when Pi loads it, and registers a command and a hook. */
21
+ const extension = (marker: string, command: string): string =>
22
+ `import { writeFileSync } from 'node:fs'\nwriteFileSync(${JSON.stringify(marker)}, 'ran')\nexport default function (pi) {\n pi.registerCommand('${command}', { description: 'A test command', handler: async () => {} })\n pi.on('tool_call', async () => undefined)\n}\n`
23
+
24
+ function workspace() {
25
+ const base = temp('harness-plugins-')
26
+ const agentDir = join(base, 'agent')
27
+ const cwd = join(base, 'project')
28
+ const markers = { shared: join(base, 'ran-shared'), project: join(base, 'ran-project'), packaged: join(base, 'ran-packaged') }
29
+ write(join(agentDir, 'extensions/shared.ts'), extension(markers.shared, 'shared'))
30
+ write(join(agentDir, 'skills/alpha/SKILL.md'), '---\nname: alpha\ndescription: Alpha skill\n---\nAlpha.\n')
31
+ write(join(agentDir, 'prompts/review.md'), 'Review this.\n')
32
+ write(join(cwd, '.pi/extensions/project.ts'), extension(markers.project, 'project'))
33
+ write(join(cwd, '.pi/skills/beta/SKILL.md'), '---\nname: beta\ndescription: Beta skill\n---\nBeta.\n')
34
+ write(join(base, 'package/package.json'), JSON.stringify({ name: 'local-package', description: 'A local package', version: '0.1.0', keywords: ['pi-package'] }))
35
+ write(join(base, 'package/extensions/packaged.ts'), extension(markers.packaged, 'packaged'))
36
+ write(join(base, 'package/skills/gamma/SKILL.md'), '---\nname: gamma\ndescription: Gamma skill\n---\nGamma.\n')
37
+ write(join(agentDir, 'settings.json'), JSON.stringify({ packages: [join(base, 'package')], extensions: ['-builtin:llama.cpp'] }))
38
+ const stateDir = join(base, 'state')
39
+ mkdirSync(stateDir)
40
+ const notices: string[] = []
41
+ const bus = new Bus()
42
+ bus.on((notice) => void notices.push(notice.type))
43
+ return { base, agentDir, cwd, markers, notices, plugins: new Plugins({ stateDir, bus }) }
44
+ }
45
+
46
+ const ran = (markers: Record<string, string>): Record<string, boolean> => Object.fromEntries(Object.entries(markers).map(([name, path]) => [name, existsSync(path)]))
47
+
48
+ describe('what Pi would load', () => {
49
+ it('is found without running any of it, with where each item comes from', async () => {
50
+ const { agentDir, cwd, markers, base } = workspace()
51
+ const found = await piResolver({ cwd, agentDir })
52
+ const of = (kind: Found['kind']) => found.filter((item) => item.kind === kind).map((item) => [item.origin, item.path.replace(`${base}/`, ''), item.enabled])
53
+ expect(of('extension')).toEqual([
54
+ ['project', 'project/.pi/extensions/project.ts', true],
55
+ ['shared', 'agent/extensions/shared.ts', true],
56
+ ['package', 'package/extensions/packaged.ts', true],
57
+ ['builtin', 'builtin:llama.cpp', false],
58
+ ['builtin', 'builtin:codemode', true],
59
+ ['builtin', 'builtin:tool-search', true],
60
+ ['builtin', 'builtin:mcp', true]
61
+ ])
62
+ expect(of('skill')).toEqual([
63
+ ['project', 'project/.pi/skills/beta/SKILL.md', true],
64
+ ['shared', 'agent/skills/alpha/SKILL.md', true],
65
+ ['package', 'package/skills/gamma/SKILL.md', true]
66
+ ])
67
+ expect(found.find((item) => item.origin === 'package')).toMatchObject({ packageRoot: join(base, 'package'), source: join(base, 'package'), project: false })
68
+ expect(ran(markers)).toEqual({ shared: false, project: false, packaged: false })
69
+ })
70
+
71
+ it('names a package nobody installed, and installs nothing when only listing', async () => {
72
+ const { agentDir, cwd } = workspace()
73
+ writeFileSync(join(agentDir, 'settings.json'), JSON.stringify({ packages: ['npm:@no-such-scope/never-installed'] }))
74
+ const found = await piResolver({ cwd, agentDir })
75
+ expect(found.some((item) => item.origin === 'package')).toBe(false)
76
+ expect(existsSync(join(agentDir, 'npm'))).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('what a session loads', () => {
81
+ it("leaves a working folder's code out until a person trusts it", async () => {
82
+ const { agentDir, cwd, plugins, base, notices } = workspace()
83
+ const before = await plugins.loading({ cwd, agentDir })
84
+ const short = (paths: string[]) => paths.map((path) => path.replace(`${base}/`, ''))
85
+ expect(short(before.extensionPaths)).toEqual(['agent/extensions/shared.ts', 'package/extensions/packaged.ts'])
86
+ expect(short(before.skillPaths)).toEqual(['project/.pi/skills/beta/SKILL.md', 'agent/skills/alpha/SKILL.md', 'package/skills/gamma/SKILL.md'])
87
+ expect(before.builtins).toEqual(['builtin:codemode', 'builtin:tool-search', 'builtin:mcp'])
88
+ expect(before.projectCode).toMatchObject({ trusted: false, files: ['.pi/extensions/project.ts'] })
89
+
90
+ plugins.trust.grant(before.projectCode!, cwd)
91
+ expect(notices).toContain('plugins.changed')
92
+ const trusted = await plugins.loading({ cwd, agentDir })
93
+ expect(short(trusted.extensionPaths)).toEqual(['project/.pi/extensions/project.ts', 'agent/extensions/shared.ts', 'package/extensions/packaged.ts'])
94
+
95
+ // A changed file is different code: it asks again.
96
+ writeFileSync(join(cwd, '.pi/extensions/project.ts'), 'export default function () {}\n')
97
+ const changed = await plugins.loading({ cwd, agentDir })
98
+ expect(changed.projectCode?.trusted).toBe(false)
99
+ expect(changed.projectCode?.hash).not.toBe(before.projectCode?.hash)
100
+ expect(short(changed.extensionPaths)).toEqual(['agent/extensions/shared.ts', 'package/extensions/packaged.ts'])
101
+
102
+ plugins.trust.revoke(before.projectCode!.hash)
103
+ // What is left is the package that was there when trust was first asked per package.
104
+ expect(plugins.trust.list().map((record) => record.folder)).toEqual([join(base, 'package')])
105
+ })
106
+
107
+ it('takes over the packages present once, then makes a new or changed package wait', async () => {
108
+ const { agentDir, cwd, plugins, base } = workspace()
109
+ const packaged = join(base, 'package/extensions/packaged.ts')
110
+ const first = await plugins.loading({ cwd, agentDir })
111
+ expect(first.packageCode[join(base, 'package')]).toMatchObject({ trusted: true, files: ['extensions/packaged.ts', 'package.json', 'skills/gamma/SKILL.md'] })
112
+ expect(first.extensionPaths).toContain(packaged)
113
+
114
+ // An update changes the code: it does not load until a person trusts it again.
115
+ writeFileSync(packaged, 'export default function () {}\n')
116
+ const changed = await plugins.loading({ cwd, agentDir })
117
+ expect(changed.packageCode[join(base, 'package')].trusted).toBe(false)
118
+ expect(changed.extensionPaths).not.toContain(packaged)
119
+ // Its text still loads: a skill runs nothing by itself.
120
+ expect(changed.skillPaths).toContain(join(base, 'package/skills/gamma/SKILL.md'))
121
+ plugins.trust.grant(changed.packageCode[join(base, 'package')], join(base, 'package'))
122
+ expect((await plugins.loading({ cwd, agentDir })).extensionPaths).toContain(packaged)
123
+
124
+ // A package added after the first start was never taken over.
125
+ write(join(base, 'second/package.json'), JSON.stringify({ name: 'second', version: '1.0.0' }))
126
+ write(join(base, 'second/extensions/later.ts'), 'export default function () {}\n')
127
+ write(join(agentDir, 'settings.json'), JSON.stringify({ packages: [join(base, 'package'), join(base, 'second')] }))
128
+ const later = await plugins.loading({ cwd, agentDir })
129
+ expect(later.packageCode[join(base, 'second')].trusted).toBe(false)
130
+ expect(later.extensionPaths).not.toContain(join(base, 'second/extensions/later.ts'))
131
+ })
132
+
133
+ it('trusts the same code in another folder, and names files from the folder they are in', () => {
134
+ const one = temp('harness-code-')
135
+ const two = temp('harness-code-')
136
+ for (const cwd of [one, two]) write(join(cwd, '.pi/extensions/tool/index.ts'), 'export default function () {}\n')
137
+ const item = (cwd: string): Found => ({ kind: 'extension', path: join(cwd, '.pi/extensions/tool/index.ts'), origin: 'project', project: true, packageRoot: null, source: null, enabled: true })
138
+ write(join(one, '.pi/extensions/tool/node_modules/dep/index.js'), 'ignored')
139
+ expect(projectCode([item(one)], one)).toEqual(projectCode([item(two)], two))
140
+ expect(projectCode([item(one)], one)?.files).toEqual(['.pi/extensions/tool/index.ts'])
141
+ // A helper beside `index.ts` is part of the same extension.
142
+ write(join(two, '.pi/extensions/tool/helper.ts'), 'export const x = 1\n')
143
+ expect(projectCode([item(two)], two)?.hash).not.toBe(projectCode([item(one)], one)?.hash)
144
+ expect(projectCode([{ ...item(one), project: false }], one)).toBeNull()
145
+ })
146
+
147
+ it('removes the skill links an earlier version kept in the shared folder', async () => {
148
+ const { agentDir, cwd, plugins, base } = workspace()
149
+ symlinkSync(join(agentDir, 'skills/alpha'), join(agentDir, 'skills/agentx-old'))
150
+ const loading = await plugins.loading({ cwd, agentDir })
151
+ expect(existsSync(join(agentDir, 'skills/agentx-old'))).toBe(false)
152
+ expect(loading.skillPaths.filter((path) => path.startsWith(join(base, 'agent')))).toEqual([join(agentDir, 'skills/alpha/SKILL.md')])
153
+ })
154
+ })
155
+
156
+ describe('a Pi session loader', () => {
157
+ const params = (cwd: string, agentDir: string, plugins: Plugins, extra: Partial<TurnParams> = {}) =>
158
+ ({
159
+ cwd,
160
+ account: { configDir: agentDir } as TurnParams['account'],
161
+ system: '',
162
+ plugins: { loading: () => plugins.loading({ cwd, agentDir }), loaded: (extensions) => plugins.loaded(extensions) },
163
+ ...extra
164
+ }) satisfies Parameters<typeof loadResources>[1]
165
+
166
+ it('runs only what the harness chose, and records what each extension registered', async () => {
167
+ const { agentDir, cwd, plugins, markers } = workspace()
168
+ const pi = await import('@earendil-works/pi-coding-agent')
169
+ const loader = await loadResources(pi, params(cwd, agentDir, plugins), () => undefined)
170
+ expect(ran(markers)).toEqual({ shared: true, project: false, packaged: true })
171
+ expect(loader.getSkills().skills.map((skill) => skill.name).sort()).toEqual(['alpha', 'beta', 'gamma'])
172
+ expect(loader.getPrompts().prompts.map((prompt) => prompt.name)).toEqual(['review'])
173
+ expect(Object.values(plugins.registered()).map((entry) => [entry.commands, entry.hooks])).toEqual([
174
+ [['shared'], ['tool_call']],
175
+ [['packaged'], ['tool_call']]
176
+ ])
177
+
178
+ const code = (await plugins.loading({ cwd, agentDir })).projectCode!
179
+ plugins.trust.grant(code, cwd)
180
+ await loadResources(pi, params(cwd, agentDir, plugins), () => undefined)
181
+ expect(ran(markers).project).toBe(true)
182
+ })
183
+
184
+ it('does not run an extension a person switched off for the caller', async () => {
185
+ const { agentDir, cwd, markers, base } = workspace()
186
+ const choices: Attachments = { version: 1, global: { on: [], off: [] }, agents: { a1: { on: [], off: ['shared/code/shared.ts'] } }, projects: {}, settings: {} }
187
+ const plugins = new Plugins({ stateDir: join(base, 'state'), bus: new Bus(), attachments: () => choices })
188
+ const pi = await import('@earendil-works/pi-coding-agent')
189
+ const forAgent = (agentId: string) => ({ ...params(cwd, agentDir, plugins), plugins: { loading: () => plugins.loading({ cwd, agentDir, caller: { agentId } }), loaded: () => undefined } })
190
+ const loader = await loadResources(pi, forAgent('a1'), () => undefined)
191
+ expect(ran(markers)).toEqual({ shared: false, project: false, packaged: true })
192
+ // Its skills are a different contribution and still load.
193
+ expect(loader.getSkills().skills.map((skill) => skill.name).sort()).toEqual(['alpha', 'beta', 'gamma'])
194
+ await loadResources(pi, forAgent('a2'), () => undefined)
195
+ expect(ran(markers).shared).toBe(true)
196
+ })
197
+
198
+ it('loads no extension at all for an unattended run', async () => {
199
+ const { agentDir, cwd, plugins, markers } = workspace()
200
+ const pi = await import('@earendil-works/pi-coding-agent')
201
+ const settings = pi.SettingsManager.inMemory({})
202
+ await loadResources(pi, params(cwd, agentDir, plugins, { automatic: true }), () => undefined, settings)
203
+ expect(ran(markers)).toEqual({ shared: false, project: false, packaged: false })
204
+ })
205
+ })