@xingular/harness 0.0.0-stage → 0.1.1

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 (135) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11510 -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.test.ts +95 -0
  8. package/src/accounts/env.ts +338 -0
  9. package/src/accounts/secrets.ts +50 -0
  10. package/src/accounts/store.ts +338 -0
  11. package/src/capabilities/api.ts +75 -0
  12. package/src/capabilities/runSnapshot.ts +59 -0
  13. package/src/capabilities/types.ts +34 -0
  14. package/src/cli.ts +110 -0
  15. package/src/core/bus.ts +34 -0
  16. package/src/core/changes.ts +33 -0
  17. package/src/core/env.ts +27 -0
  18. package/src/core/errors.ts +32 -0
  19. package/src/core/frontmatter.ts +47 -0
  20. package/src/core/fs.test.ts +38 -0
  21. package/src/core/fs.ts +92 -0
  22. package/src/core/index.ts +193 -0
  23. package/src/core/path.ts +45 -0
  24. package/src/core/projects.ts +90 -0
  25. package/src/core/text.ts +39 -0
  26. package/src/harness.ts +299 -0
  27. package/src/index.ts +70 -0
  28. package/src/mcp/connections.ts +614 -0
  29. package/src/mcp/manager.ts +130 -0
  30. package/src/mcp/oauth.ts +195 -0
  31. package/src/mcp/scopeMigration.ts +204 -0
  32. package/src/mcp/service.test.ts +376 -0
  33. package/src/mcp/service.ts +549 -0
  34. package/src/mcp/types.ts +108 -0
  35. package/src/memory/api.test.ts +82 -0
  36. package/src/memory/api.ts +289 -0
  37. package/src/memory/blocks.test.ts +78 -0
  38. package/src/memory/blocks.ts +406 -0
  39. package/src/memory/caller.ts +57 -0
  40. package/src/memory/dispatch.ts +59 -0
  41. package/src/memory/facts.test.ts +41 -0
  42. package/src/memory/facts.ts +248 -0
  43. package/src/memory/graph.ts +103 -0
  44. package/src/memory/health.test.ts +130 -0
  45. package/src/memory/health.ts +74 -0
  46. package/src/memory/maintainer.ts +272 -0
  47. package/src/memory/maintenance.test.ts +176 -0
  48. package/src/memory/maintenance.ts +330 -0
  49. package/src/memory/migrate.ts +79 -0
  50. package/src/memory/operations.ts +138 -0
  51. package/src/memory/proposals.test.ts +168 -0
  52. package/src/memory/proposals.ts +576 -0
  53. package/src/memory/recall.test.ts +104 -0
  54. package/src/memory/recall.ts +271 -0
  55. package/src/memory/references.ts +77 -0
  56. package/src/memory/runContext.ts +93 -0
  57. package/src/memory/secrets.ts +6 -0
  58. package/src/memory/store.test.ts +66 -0
  59. package/src/memory/store.ts +385 -0
  60. package/src/memory/wiki.test.ts +47 -0
  61. package/src/memory/wiki.ts +422 -0
  62. package/src/memory/wikiIndex.test.ts +88 -0
  63. package/src/memory/wikiIndex.ts +116 -0
  64. package/src/plugins/api.ts +181 -0
  65. package/src/plugins/attachments.test.ts +84 -0
  66. package/src/plugins/attachments.ts +141 -0
  67. package/src/plugins/builtin.ts +82 -0
  68. package/src/plugins/catalogue.test.ts +254 -0
  69. package/src/plugins/catalogue.ts +251 -0
  70. package/src/plugins/cli.ts +184 -0
  71. package/src/plugins/discover.ts +151 -0
  72. package/src/plugins/firstParty.test.ts +143 -0
  73. package/src/plugins/firstParty.ts +129 -0
  74. package/src/plugins/hooks.test.ts +250 -0
  75. package/src/plugins/hooks.ts +239 -0
  76. package/src/plugins/identity.ts +37 -0
  77. package/src/plugins/install.test.ts +107 -0
  78. package/src/plugins/install.ts +106 -0
  79. package/src/plugins/inventory.ts +123 -0
  80. package/src/plugins/manifest.ts +76 -0
  81. package/src/plugins/plugins.test.ts +205 -0
  82. package/src/plugins/plugins.ts +189 -0
  83. package/src/plugins/proposal.ts +121 -0
  84. package/src/plugins/runtime.test.ts +40 -0
  85. package/src/plugins/runtime.ts +82 -0
  86. package/src/plugins/trust.ts +142 -0
  87. package/src/plugins/types.ts +149 -0
  88. package/src/prompt/compile.test.ts +261 -0
  89. package/src/prompt/compile.ts +732 -0
  90. package/src/server/methods.ts +314 -0
  91. package/src/server/remote.ts +77 -0
  92. package/src/server/rpc.ts +116 -0
  93. package/src/server/serve.ts +28 -0
  94. package/src/server/server.test.ts +174 -0
  95. package/src/sessions/approvals.ts +118 -0
  96. package/src/sessions/contracts.ts +299 -0
  97. package/src/sessions/conversation.test.ts +150 -0
  98. package/src/sessions/extensionSession.test.ts +74 -0
  99. package/src/sessions/extensionUi.test.ts +65 -0
  100. package/src/sessions/extensionUi.ts +102 -0
  101. package/src/sessions/history.ts +51 -0
  102. package/src/sessions/lineageJoin.ts +75 -0
  103. package/src/sessions/policy.ts +32 -0
  104. package/src/sessions/pricing.ts +60 -0
  105. package/src/sessions/reasoningSummaries.ts +59 -0
  106. package/src/sessions/registry.ts +294 -0
  107. package/src/sessions/runIndex.ts +99 -0
  108. package/src/sessions/runStore.test.ts +152 -0
  109. package/src/sessions/runStore.ts +332 -0
  110. package/src/sessions/runner.ts +556 -0
  111. package/src/sessions/runtimeResources.test.ts +61 -0
  112. package/src/sessions/sessions.test.ts +434 -0
  113. package/src/sessions/sessions.ts +295 -0
  114. package/src/skills/registry.test.ts +104 -0
  115. package/src/skills/registry.ts +407 -0
  116. package/src/terminal/cli.ts +149 -0
  117. package/src/terminal/client.ts +74 -0
  118. package/src/terminal/controller.ts +227 -0
  119. package/src/terminal/piExtension.ts +208 -0
  120. package/src/terminal/pluginControl.test.ts +146 -0
  121. package/src/terminal/pluginMenu.ts +112 -0
  122. package/src/terminal/socket.ts +182 -0
  123. package/src/terminal/standalone.test.ts +115 -0
  124. package/src/terminal/standalone.ts +126 -0
  125. package/src/terminal/terminal.test.ts +302 -0
  126. package/src/terminal/terminals.ts +429 -0
  127. package/src/tools/askUser.ts +45 -0
  128. package/src/tools/definitions.ts +187 -0
  129. package/src/tools/memoryTools.ts +22 -0
  130. package/src/tools/pluginPropose.ts +41 -0
  131. package/src/tools/types.ts +36 -0
  132. package/src/web/safeRequest.ts +171 -0
  133. package/src/web/text.ts +53 -0
  134. package/src/web/tools.ts +134 -0
  135. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,250 @@
1
+ import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { memorySecretStore } from '../accounts/secrets'
7
+ import { Bus } from '../core/bus'
8
+ import { createHarness, type Harness } from '../harness'
9
+ import { listInbox } from '../memory/operations'
10
+ import { wikiDir } from '../memory/store'
11
+ import { methods } from '../server/methods'
12
+ import { pluginProposeTool } from '../tools/pluginPropose'
13
+ import { rootWithProject, writeFile } from '../../tests/helpers'
14
+ import { entryEvent, Hooks, type HookEvent } from './hooks'
15
+ import { checkPluginProposal } from './proposal'
16
+ import type { Plugin } from './types'
17
+
18
+ const open: Harness[] = []
19
+ afterEach(async () => {
20
+ for (const harness of open.splice(0)) await harness.close()
21
+ })
22
+
23
+ const temp = (): string => mkdtempSync(join(tmpdir(), 'hooks-'))
24
+ // The test runner owns `import()` here; in a real process the harness uses Node's own.
25
+ const importFile = (address: string): Promise<unknown> => import(/* @vite-ignore */ address)
26
+
27
+ /** A hooks file that appends what it was told to a file, so a test can read it back. */
28
+ const recorder = (out: string, events: string[], extra = ''): string =>
29
+ `import { appendFileSync } from 'node:fs'\nexport default function (harness) {\n${events.map((event) => ` harness.on('${event}', async (event, ctx) => { appendFileSync(${JSON.stringify(out)}, JSON.stringify({ type: event.type, id: event.id ?? event.runId, title: event.title, body: event.body, actor: event.actor }) + '\\n')${extra} })`).join('\n')}\n}\n`
30
+
31
+ const told = (out: string): Record<string, unknown>[] => (existsSync(out) ? readFileSync(out, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)) : [])
32
+
33
+ function kit(folder: string, name: string, hooks: string): void {
34
+ writeFile(join(folder, 'package.json'), JSON.stringify({ name, version: '1.0.0', description: `${name} mirrors memory.`, harness: { extends: ['memory', 'nonsense'], hooks: './hooks.mjs' } }))
35
+ writeFile(join(folder, 'hooks.mjs'), hooks)
36
+ }
37
+
38
+ function setup() {
39
+ const root = rootWithProject()
40
+ const base = temp()
41
+ const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore(), hookImporter: importFile })
42
+ open.push(harness)
43
+ const table = methods(harness)
44
+ const call = <T>(method: string, params: Record<string, unknown> = {}) => table[method](params) as Promise<T>
45
+ const saveFact = (title: string, body: string) => call('facts.save', { input: { title, kind: 'fact', body, namespace: '/' } })
46
+ return { harness, root, base, call, saveFact }
47
+ }
48
+
49
+ describe('a written file as an event', () => {
50
+ it('is a fact, a memory block, or a page by where it is in the Wiki', () => {
51
+ const wiki = '/data/wiki'
52
+ const change = (path: string, text: string) => entryEvent({ wiki, id: 'x', path: join(wiki, path), text, actor: 'user', reason: 'Edited' })
53
+ expect(change('facts/pnpm.md', '---\ntitle: Use pnpm\nkind: preference\n---\n\nAlways pnpm.\n')).toMatchObject({ type: 'memory.saved', title: 'Use pnpm', body: 'Always pnpm.', actor: 'user' })
54
+ expect(change('blocks/agents/a1/notes.md', '---\nlabel: notes\n---\n\nLikes tabs.\n')).toMatchObject({ type: 'memory.block.changed', title: 'notes', body: 'Likes tabs.' })
55
+ expect(change('library/arch.md', '---\ntitle: Architecture\n---\n\nLayers.\n')).toMatchObject({ type: 'wiki.page.saved', title: 'Architecture' })
56
+ expect(change('home.md', 'No front matter.')).toMatchObject({ type: 'wiki.page.saved', title: 'x', body: 'No front matter.' })
57
+ })
58
+ })
59
+
60
+ describe('hooks a plugin gives the harness', () => {
61
+ it('mirrors saved facts through a real disposable MCP subprocess and stops on detach, changed code or revoked permission', async () => {
62
+ const { harness, base, call, saveFact } = setup()
63
+ const output = join(base, 'mirrored.jsonl')
64
+ await harness.apps.save({ id: 'mirror', name: 'Disposable mirror', kind: 'local', command: process.execPath, args: [join(process.cwd(), 'tests/mcp-hook-fixture.mjs'), output], enabled: true, global: true, readTools: ['mirror_fact'] })
65
+ const paths = ['one', 'two'].map((name) => join(base, name))
66
+ for (const [index, path] of paths.entries()) {
67
+ const name = ['mirror-one', 'mirror-two'][index]
68
+ kit(path, name, `export default function(harness) { harness.on('memory.saved', async (event, ctx) => { await ctx.call('Disposable mirror', 'mirror_fact', { plugin: '${name}', id: event.id, title: event.title, body: event.body }) }) }\n`)
69
+ const plugins = await call<Plugin[]>('plugins.install', { source: path })
70
+ const plugin = plugins.find((entry) => entry.id === `package:${name}`)!
71
+ await call('plugins.attach', { id: plugin.id })
72
+ await call('plugins.trust', { hash: plugin.code!.hash })
73
+ }
74
+ await harness.hooks.reload()
75
+ await saveFact('Synthetic one', 'Generated probe body.')
76
+ await harness.hooks.settled()
77
+ expect(told(output).map((row) => row.plugin)).toEqual(['mirror-one', 'mirror-two'])
78
+ expect(told(output)[0]).toMatchObject({ title: 'Synthetic one', body: 'Generated probe body.' })
79
+ await call('plugins.detach', { id: 'package:mirror-one' })
80
+ await saveFact('Synthetic two', 'Second body.')
81
+ await harness.hooks.settled()
82
+ expect(told(output).map((row) => row.plugin)).toEqual(['mirror-one', 'mirror-two', 'mirror-two'])
83
+ writeFile(join(paths[1], 'hooks.mjs'), '// Changed since trust\nexport default function() {}\n')
84
+ await saveFact('Synthetic three', 'Untrusted code hears nothing.')
85
+ await harness.hooks.settled()
86
+ expect(told(output)).toHaveLength(3)
87
+ await call('plugins.attach', { id: 'package:mirror-one' })
88
+ await harness.apps.save({ id: 'mirror', name: 'Disposable mirror', kind: 'local', command: process.execPath, args: [join(process.cwd(), 'tests/mcp-hook-fixture.mjs'), output], enabled: true, global: true, readTools: [] })
89
+ await harness.hooks.reload()
90
+ await saveFact('Synthetic four', 'Write succeeds with permission revoked.')
91
+ await harness.hooks.settled()
92
+ expect(told(output)).toHaveLength(3)
93
+ expect(harness.hooks.status()[join(paths[0], 'hooks.mjs')].lastError).toContain('asks before running')
94
+ })
95
+
96
+ it('run for every plugin that listens, only once the plugin is switched on for everyone and trusted', async () => {
97
+ const { harness, base, call, saveFact } = setup()
98
+ const one = join(base, 'one.log')
99
+ const two = join(base, 'two.log')
100
+ kit(join(base, 'docs'), 'memory-to-docs', recorder(one, ['memory.saved']))
101
+ kit(join(base, 'sheet'), 'memory-to-sheet', recorder(two, ['memory.saved', 'history.run.ended']))
102
+ await call('plugins.install', { source: join(base, 'docs') })
103
+ const listed = await call<Plugin[]>('plugins.install', { source: join(base, 'sheet') })
104
+ const docs = listed.find((plugin) => plugin.id === 'package:memory-to-docs')!
105
+ const sheet = listed.find((plugin) => plugin.id === 'package:memory-to-sheet')!
106
+ // What it says it extends is kept only for parts that exist.
107
+ expect(docs).toMatchObject({ extends: ['memory'], runsCode: true, trust: 'untrusted', attachment: { global: false } })
108
+ expect(docs.contributions.map((entry) => [entry.kind, entry.name, entry.state])).toEqual([['code', 'hooks.mjs', 'off']])
109
+
110
+ // Installed, switched off, untrusted: a saved fact reaches nobody.
111
+ await saveFact('Use pnpm', 'Always pnpm.')
112
+ await harness.hooks.settled()
113
+ expect([told(one), told(two)]).toEqual([[], []])
114
+
115
+ // Switched on but not trusted is still nothing.
116
+ await call('plugins.attach', { id: docs.id })
117
+ await call('plugins.attach', { id: sheet.id })
118
+ await harness.hooks.reload()
119
+ await saveFact('Tabs', 'Use tabs.')
120
+ await harness.hooks.settled()
121
+ expect([told(one), told(two)]).toEqual([[], []])
122
+
123
+ // Trusted: both hear the next fact, each in its own way.
124
+ await call('plugins.trust', { hash: docs.code!.hash })
125
+ const trusted = await call<Plugin[]>('plugins.trust', { hash: sheet.code!.hash })
126
+ await harness.hooks.reload()
127
+ await saveFact('Line length', 'Wrap at 100.')
128
+ await harness.hooks.settled()
129
+ expect(told(one)).toEqual([{ type: 'memory.saved', id: expect.any(String), title: 'Line length', body: 'Wrap at 100.', actor: 'user' }])
130
+ expect(told(two)).toEqual(told(one))
131
+ expect(trusted.find((plugin) => plugin.id === docs.id)!.contributions[0]).toMatchObject({ state: 'on', problem: null })
132
+ const described = (await call<Plugin[]>('plugins.list')).find((plugin) => plugin.id === sheet.id)!.contributions[0]
133
+ expect(described.description).toBe('Runs in the background after: memory.saved, history.run.ended.')
134
+
135
+ // Switched off for everyone again, it stops hearing; the other keeps going.
136
+ await call('plugins.detach', { id: docs.id })
137
+ await harness.hooks.reload()
138
+ await saveFact('Indent', 'Two spaces.')
139
+ await harness.hooks.settled()
140
+ expect([told(one).length, told(two).length]).toEqual([1, 2])
141
+ })
142
+
143
+ it('never lets a hook that throws or hangs touch the write, and shows what went wrong on its plugin', async () => {
144
+ const bus = new Bus()
145
+ const base = temp()
146
+ const out = join(base, 'ok.log')
147
+ writeFile(join(base, 'bad.mjs'), `export default function (harness) { harness.on('memory.saved', async () => { throw new Error('the document is gone') }) }\n`)
148
+ writeFile(join(base, 'slow.mjs'), `export default function (harness) { harness.on('memory.saved', () => new Promise(() => {})) }\n`)
149
+ writeFile(join(base, 'good.mjs'), recorder(out, ['memory.saved']))
150
+ writeFile(join(base, 'broken.mjs'), `export const nothing = 1\n`)
151
+ writeFile(join(base, 'unknown.mjs'), `export default function (harness) { harness.on('memory.deleted', () => {}) }\n`)
152
+ const hooks = new Hooks({ stateDir: base, bus, timeoutMs: 40, importFile, call: async () => undefined, wanted: async () => ['bad', 'slow', 'good', 'broken', 'unknown'].map((name) => ({ path: join(base, `${name}.mjs`), hash: 'h1' })) })
153
+ await hooks.reload()
154
+ const event: HookEvent = { type: 'memory.saved', id: 'f1', path: '/w/facts/f1.md', title: 'Fact', body: 'Body', text: 'Body', actor: 'user', reason: 'Created', at: '2026-10-13T00:00:00.000Z' }
155
+ hooks.emit(event)
156
+ await hooks.settled()
157
+ // The good hook ran although two before it failed.
158
+ expect(told(out)).toHaveLength(1)
159
+ const status = hooks.status()
160
+ expect(status[join(base, 'bad.mjs')]).toMatchObject({ events: ['memory.saved'], lastError: 'memory.saved: the document is gone' })
161
+ expect(status[join(base, 'slow.mjs')].lastError).toMatch(/took longer than/)
162
+ expect(status[join(base, 'good.mjs')]).toMatchObject({ lastError: null, lastRunAt: expect.any(String) })
163
+ expect(status[join(base, 'broken.mjs')].lastError).toBe('It could not be loaded: It must export a default function.')
164
+ expect(status[join(base, 'unknown.mjs')].lastError).toMatch(/There is no event called "memory.deleted"/)
165
+ hooks.close()
166
+ })
167
+
168
+ it('reads a hooks file again when its code changed, and keeps the one it has when it did not', async () => {
169
+ const base = temp()
170
+ const path = join(base, 'hooks.mjs')
171
+ let hash = 'h1'
172
+ const read: string[] = []
173
+ const heard: string[] = []
174
+ // Stands in for the file on disk: each read gives the code as it is at that moment.
175
+ const importer = async (address: string) => {
176
+ read.push(address.slice(address.indexOf('?')))
177
+ const version = read.length
178
+ return { default: (harness: { on(event: string, handler: () => void): void }) => harness.on('wiki.page.saved', () => void heard.push(`version ${version}`)) }
179
+ }
180
+ const hooks = new Hooks({ stateDir: base, bus: new Bus(), call: async () => undefined, importFile: importer, wanted: async () => [{ path, hash }] })
181
+ const event: HookEvent = { type: 'wiki.page.saved', id: 'p1', path: '/w/library/p1.md', title: 'Page', body: 'One', text: 'One', actor: 'user', reason: 'Edited', at: '2026-10-13T00:00:00.000Z' }
182
+ await hooks.reload()
183
+ await hooks.reload()
184
+ hooks.emit(event)
185
+ await hooks.settled()
186
+ hash = 'h2'
187
+ await hooks.reload()
188
+ hooks.emit(event)
189
+ await hooks.settled()
190
+ // The fingerprint is part of the address, so Node does not hand back the module it already has.
191
+ expect(read).toEqual(['?v=h1', '?v=h2'])
192
+ expect(heard).toEqual(['version 1', 'version 2'])
193
+ hooks.close()
194
+ })
195
+
196
+ it('lets a hook reach a connected app only through a tool marked to run without asking', async () => {
197
+ const { harness } = setup()
198
+ await harness.apps.save({ id: 'docs', name: 'Docs', kind: 'local', command: process.execPath, args: [join(process.cwd(), 'tests/mcp-fixture.mjs')], enabled: true, global: true, readTools: ['read_record'] })
199
+ await harness.apps.save({ id: 'private', name: 'Private', kind: 'local', command: process.execPath, args: [join(process.cwd(), 'tests/mcp-fixture.mjs')], enabled: true, global: false, agentIds: ['a1'], readTools: ['read_record'] })
200
+ expect(await harness.apps.callUnattended('Docs', 'read_record', {})).toMatchObject({ content: [{ type: 'text', text: 'fixture' }] })
201
+ await expect(harness.apps.callUnattended('Docs', 'change_record', {})).rejects.toThrow('asks before running "change_record"')
202
+ // A hook runs for everyone, so an app that is on for one agent only is out of its reach.
203
+ await expect(harness.apps.callUnattended('Private', 'read_record', {})).rejects.toThrow('on for everyone')
204
+ await expect(harness.apps.callUnattended('Nope', 'read_record', {})).rejects.toThrow('no connected app called "Nope"')
205
+ })
206
+ })
207
+
208
+ describe('a plugin an agent proposes', () => {
209
+ const proposal = { name: 'Memory to Docs', description: 'Adds every saved memory to the team document.', extends: ['memory'], reason: 'You asked for a copy in Docs.', files: [{ path: 'hooks.mjs', content: "export default function (harness) { harness.on('memory.saved', async () => {}) }\n" }, { path: 'skills/docs/SKILL.md', content: '---\nname: docs\ndescription: How the copy works.\n---\nSteps.\n' }] }
210
+
211
+ it('waits in the Inbox, and is saved switched off and untrusted only when the person accepts it', async () => {
212
+ const { harness, root, call } = setup()
213
+ const tool = pluginProposeTool(harness.env, { agentId: 'a1', agentName: 'Helper', actor: 'Chat' })
214
+ expect(await tool.execute(proposal)).toBe('The plugin "memory-to-docs" is waiting in the Inbox. It is saved only if the user accepts it, and it does nothing until they switch it on and trust its code.')
215
+ expect(existsSync(join(root, 'plugins'))).toBe(false)
216
+ const [item] = listInbox(wikiDir(root))
217
+ expect(item).toMatchObject({ kind: 'proposed', title: 'Proposed plugin "Memory to Docs"' })
218
+ expect(item.detail).toContain('- hooks.mjs (')
219
+ expect(item.detail).toContain('It brings code.')
220
+ expect(item.detail).toContain('From Helper (Chat).')
221
+
222
+ expect(await call<string>('wiki.inboxAccept', { itemId: item.id })).toBe('Saved the plugin "memory-to-docs", switched off. Its code does not run until you switch it on and trust it.')
223
+ expect(readdirSync(join(root, 'plugins'))).toEqual(['memory-to-docs'])
224
+ expect(JSON.parse(readFileSync(join(root, 'plugins/memory-to-docs/package.json'), 'utf8'))).toMatchObject({ name: 'memory-to-docs', description: proposal.description, harness: { extends: ['memory'], hooks: './hooks.mjs' } })
225
+ const plugin = (await call<Plugin[]>('plugins.list')).find((candidate) => candidate.id === 'package:memory-to-docs')!
226
+ expect(plugin).toMatchObject({ extends: ['memory'], trust: 'untrusted', install: { kind: 'folder' }, attachment: { global: false } })
227
+ expect(plugin.contributions.map((entry) => [entry.kind, entry.name, entry.state])).toEqual([['skill', 'docs', 'off'], ['code', 'hooks.mjs', 'off']])
228
+ await harness.hooks.reload()
229
+ expect(Object.keys(harness.hooks.status())).toEqual([])
230
+ // Accepting the same proposal again would overwrite a folder, so it is refused.
231
+ await tool.execute(proposal)
232
+ await expect(call('wiki.inboxAccept', { itemId: listInbox(wikiDir(root))[0].id })).rejects.toThrow('already a plugin folder')
233
+ })
234
+
235
+ it('refuses what could not be a safe plugin, and says why', () => {
236
+ const bad = (change: Partial<typeof proposal>) => () => checkPluginProposal({ ...proposal, ...change })
237
+ expect(bad({ name: ' ' })).toThrow('short name')
238
+ expect(bad({ description: '' })).toThrow('one line')
239
+ expect(bad({ extends: ['approvals'] })).toThrow('"approvals" is none of them')
240
+ expect(bad({ files: [] })).toThrow('between 1 and 40 files')
241
+ expect(bad({ files: [{ path: '../outside.mjs', content: 'x' }] })).toThrow('not a path inside the plugin')
242
+ expect(bad({ files: [{ path: '/etc/hosts', content: 'x' }] })).toThrow('not a path inside the plugin')
243
+ expect(bad({ files: [{ path: '.env', content: 'x' }] })).toThrow('cannot be part of a plugin')
244
+ expect(bad({ files: [{ path: 'node_modules/x/index.js', content: 'x' }] })).toThrow('cannot be part of a plugin')
245
+ expect(bad({ files: [{ path: 'package.json', content: '{}' }] })).toThrow('Leave package.json out')
246
+ expect(bad({ files: [{ path: 'a.md', content: 'x' }, { path: 'A.md', content: 'y' }] })).toThrow('listed twice')
247
+ expect(bad({ files: [{ path: 'big.md', content: 'x'.repeat(300 * 1024) }] })).toThrow('larger than 256 KiB')
248
+ expect(checkPluginProposal({ ...proposal, files: [{ path: './skills/a/SKILL.md', content: 'x' }] })).toMatchObject({ slug: 'memory-to-docs', hooks: null, runsCode: false })
249
+ })
250
+ })
@@ -0,0 +1,239 @@
1
+ // Hooks: code a plugin gives the harness to run after something changed. A Pi extension lives
2
+ // only while a session is open, and most memory is written outside one (a person accepts an
3
+ // Inbox item, edits a fact, the background upkeep runs), so these are the harness's own events,
4
+ // delivered in its own process.
5
+ //
6
+ // A hook is told after the fact. It cannot stop or change a write, it runs with a time limit,
7
+ // and what it throws is recorded and shown on its plugin. It reaches outside only through
8
+ // `ctx.call`, which the harness limits to connected-app tools a person marked to run without asking.
9
+ import { chmodSync } from 'node:fs'
10
+ import { join, sep } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+
13
+ import type { Bus } from '../core/bus'
14
+ import { log } from '../core/bus'
15
+ import { onChange, type Change } from '../core/changes'
16
+ import * as frontmatter from '../core/frontmatter'
17
+ import { now, readJsonOr, writeJson } from '../core/fs'
18
+
19
+ export const HOOK_EVENTS = ['memory.saved', 'memory.block.changed', 'wiki.page.saved', 'history.run.ended'] as const
20
+ export type HookEventType = (typeof HOOK_EVENTS)[number]
21
+
22
+ /** A fact, a memory block, or a page, as it is after the change. */
23
+ export interface EntryEvent {
24
+ type: 'memory.saved' | 'memory.block.changed' | 'wiki.page.saved'
25
+ id: string
26
+ path: string
27
+ title: string
28
+ /** The entry without its front matter. */
29
+ body: string
30
+ /** The whole file. */
31
+ text: string
32
+ /** Who made the change: `user`, an agent's name, or the background upkeep. */
33
+ actor: string
34
+ reason: string
35
+ at: string
36
+ }
37
+
38
+ export interface RunEndedEvent {
39
+ type: 'history.run.ended'
40
+ runId: string
41
+ sessionId: string
42
+ status: string
43
+ goal: string | null
44
+ agentId: string | null
45
+ projectId: string | null
46
+ startedAt: string
47
+ endedAt: string | null
48
+ at: string
49
+ }
50
+
51
+ export type HookEvent = EntryEvent | RunEndedEvent
52
+
53
+ export interface HookContext {
54
+ /** Calls a tool of a connected app that is on for everyone, if a person marked that tool to run without asking. */
55
+ call(connection: string, tool: string, args?: Record<string, unknown>): Promise<unknown>
56
+ log(message: string): void
57
+ }
58
+
59
+ type Handler = (event: HookEvent, context: HookContext) => unknown
60
+
61
+ /** What a hooks file is given: `export default function (harness) { harness.on('memory.saved', …) }`. */
62
+ export interface HookApi {
63
+ on(event: HookEventType, handler: Handler): void
64
+ }
65
+
66
+ export interface HookTarget {
67
+ /** The hooks file. */
68
+ path: string
69
+ /** A hash of the package's code, so a changed file is loaded again. */
70
+ hash: string
71
+ }
72
+
73
+ export interface HookStatus {
74
+ /** The events it listens for. */
75
+ events: string[]
76
+ lastRunAt: string | null
77
+ /** Why it could not be loaded, or what its last failing run threw. */
78
+ lastError: string | null
79
+ }
80
+
81
+ export interface HooksOptions {
82
+ stateDir: string
83
+ bus: Bus
84
+ /** The hooks files that should run now: attached for everyone, and trusted. */
85
+ wanted: () => Promise<HookTarget[]>
86
+ call: HookContext['call']
87
+ /** How long one handler may take. */
88
+ timeoutMs?: number
89
+ /** Reads a hooks file as a module. Replaced in tests, where the runner owns `import()`. */
90
+ importFile?: (address: string) => Promise<unknown>
91
+ }
92
+
93
+ // Node's own `import()`, out of reach of a bundler or a test runner that would rewrite it: a hooks
94
+ // file is a person's file on disk, loaded as it is, and its address must reach Node unchanged.
95
+ const importFile = new Function('address', 'return import(address)') as (address: string) => Promise<unknown>
96
+
97
+ interface Loaded extends HookTarget {
98
+ handlers: Map<string, Handler[]>
99
+ }
100
+
101
+ /** Which event a written file is, from where it is in the Wiki. */
102
+ export function entryEvent(change: Change): EntryEvent {
103
+ const within = change.path.startsWith(`${change.wiki}${sep}`) ? change.path.slice(change.wiki.length + 1).split(sep) : []
104
+ const type = within[0] === 'facts' ? 'memory.saved' : within[0] === 'blocks' ? 'memory.block.changed' : 'wiki.page.saved'
105
+ const parsed = frontmatter.parse(change.text)
106
+ const front = (parsed?.front ?? {}) as Record<string, unknown>
107
+ const title = typeof front.title === 'string' ? front.title : typeof front.label === 'string' ? front.label : change.id
108
+ return { type, id: change.id, path: change.path, title, body: (parsed?.body ?? change.text).trim(), text: change.text, actor: change.actor, reason: change.reason, at: now() }
109
+ }
110
+
111
+ export class Hooks {
112
+ private loaded: Loaded[] = []
113
+ private readonly queue: HookEvent[] = []
114
+ private working: Promise<void> = Promise.resolve()
115
+ private reloading: Promise<void> = Promise.resolve()
116
+ private readonly statusPath: string
117
+ private wiki: string | null = null
118
+ private readonly stop: () => void
119
+
120
+ constructor(private readonly options: HooksOptions) {
121
+ this.statusPath = join(options.stateDir, 'plugin-hooks.json')
122
+ this.stop = onChange((change) => {
123
+ // Several harnesses can share a process; each hears only about its own Wiki.
124
+ if (change.wiki === this.wiki) this.emit(entryEvent(change))
125
+ })
126
+ }
127
+
128
+ /** The Wiki whose changes these hooks hear about. */
129
+ watch(wiki: string | null): void {
130
+ this.wiki = wiki
131
+ }
132
+
133
+ /** What each hooks file listens for, and how its last run went. */
134
+ status(): Record<string, HookStatus> {
135
+ return readJsonOr<Record<string, HookStatus>>(this.statusPath, {})
136
+ }
137
+
138
+ private record(path: string, change: Partial<HookStatus>): void {
139
+ const all = this.status()
140
+ const before = all[path] ?? { events: [], lastRunAt: null, lastError: null }
141
+ const next = { ...before, ...change }
142
+ if (JSON.stringify(before) === JSON.stringify(next) && all[path]) return
143
+ all[path] = next
144
+ writeJson(this.statusPath, all)
145
+ chmodSync(this.statusPath, 0o600)
146
+ // A new failure, or a failure that cleared, is something a person looking at the plugin should see.
147
+ if (before.lastError !== next.lastError || before.events.join() !== next.events.join()) this.options.bus.emit({ type: 'plugins.changed' })
148
+ }
149
+
150
+ /** Loads the hooks files that should run and drops the ones that should not. Safe to call often. */
151
+ reload(): Promise<void> {
152
+ this.reloading = this.reloading.then(async () => {
153
+ let wanted: HookTarget[]
154
+ try {
155
+ wanted = await this.options.wanted()
156
+ } catch (error) {
157
+ log('plugin hooks', error instanceof Error ? error.message : String(error))
158
+ return
159
+ }
160
+ const next: Loaded[] = []
161
+ for (const target of wanted) {
162
+ const kept = this.loaded.find((hook) => hook.path === target.path && hook.hash === target.hash)
163
+ if (kept) {
164
+ next.push(kept)
165
+ continue
166
+ }
167
+ const handlers = new Map<string, Handler[]>()
168
+ try {
169
+ // The hash is part of the address, so a changed file is read again and not served from the module cache.
170
+ const module = (await (this.options.importFile ?? importFile)(`${pathToFileURL(target.path).href}?v=${target.hash}`)) as { default?: unknown }
171
+ if (typeof module.default !== 'function') throw new Error('It must export a default function.')
172
+ const api: HookApi = {
173
+ on: (event, handler) => {
174
+ if (!(HOOK_EVENTS as readonly string[]).includes(event)) throw new Error(`There is no event called "${String(event)}". The events are ${HOOK_EVENTS.join(', ')}.`)
175
+ if (typeof handler !== 'function') throw new Error('A handler must be a function.')
176
+ handlers.set(event, [...(handlers.get(event) ?? []), handler])
177
+ }
178
+ }
179
+ await (module.default as (api: HookApi) => unknown)(api)
180
+ next.push({ ...target, handlers })
181
+ this.record(target.path, { events: [...handlers.keys()], lastError: null })
182
+ } catch (error) {
183
+ this.record(target.path, { events: [], lastError: `It could not be loaded: ${error instanceof Error ? error.message : String(error)}` })
184
+ }
185
+ }
186
+ this.loaded = next
187
+ })
188
+ return this.reloading
189
+ }
190
+
191
+ /** Takes an event down. It is delivered afterwards, in the order events arrived. */
192
+ emit(event: HookEvent): void {
193
+ this.queue.push(event)
194
+ this.working = this.working.then(() => this.drain())
195
+ }
196
+
197
+ private async drain(): Promise<void> {
198
+ // A file can change on disk without anyone telling the harness. What is loaded is checked
199
+ // against what should run before it is told anything, so code that changed since it was
200
+ // trusted stops here. With nothing loaded there is nothing to check: a new hook arrives
201
+ // with a `plugins.changed` notice.
202
+ if (this.loaded.length) await this.reload()
203
+ else await this.reloading
204
+ for (let event = this.queue.shift(); event; event = this.queue.shift()) {
205
+ for (const hook of this.loaded) {
206
+ for (const handler of hook.handlers.get(event.type) ?? []) await this.run(hook, handler, event)
207
+ }
208
+ }
209
+ }
210
+
211
+ private async run(hook: Loaded, handler: Handler, event: HookEvent): Promise<void> {
212
+ const context: HookContext = { call: this.options.call, log: (message) => log(`plugin hook ${hook.path}`, String(message)) }
213
+ let timer: ReturnType<typeof setTimeout> | undefined
214
+ try {
215
+ await Promise.race([
216
+ Promise.resolve().then(() => handler(event, context)),
217
+ new Promise((_, reject) => {
218
+ timer = setTimeout(() => reject(new Error(`It took longer than ${Math.round((this.options.timeoutMs ?? 30_000) / 1000)} seconds and was left behind.`)), this.options.timeoutMs ?? 30_000)
219
+ })
220
+ ])
221
+ this.record(hook.path, { lastRunAt: now(), lastError: null })
222
+ } catch (error) {
223
+ this.record(hook.path, { lastRunAt: now(), lastError: `${event.type}: ${error instanceof Error ? error.message : String(error)}`.slice(0, 1000) })
224
+ } finally {
225
+ if (timer) clearTimeout(timer)
226
+ }
227
+ }
228
+
229
+ /** Resolves when every event taken down so far was delivered. */
230
+ async settled(): Promise<void> {
231
+ await this.reloading
232
+ await this.working
233
+ }
234
+
235
+ close(): void {
236
+ this.stop()
237
+ this.loaded = []
238
+ }
239
+ }
@@ -0,0 +1,37 @@
1
+ // Which plugin a found file belongs to, and what it is called there. The list a person sees and
2
+ // the choice of what loads both use this, so a switch on the page names exactly what stops loading.
3
+ import { basename, dirname } from 'node:path'
4
+
5
+ import * as registry from '../skills/registry'
6
+ import { BUILTIN_PREFIX, type Found } from './discover'
7
+ import { packageManifest } from './manifest'
8
+ import type { ContributionKind } from './types'
9
+
10
+ export { packageManifest } from './manifest'
11
+
12
+ export const FOUND_KINDS: Record<Found['kind'], ContributionKind> = { extension: 'code', hook: 'code', skill: 'skill', prompt: 'command', theme: 'theme', context: 'instruction' }
13
+
14
+ /** The id of the plugin a found item is listed under. */
15
+ export function pluginIdOf(item: Found): string {
16
+ if (item.origin === 'builtin') return 'pi'
17
+ if (item.packageRoot) return `package:${packageManifest(item.packageRoot).name}`
18
+ return item.origin === 'home' ? 'home' : item.origin === 'project' ? 'project' : 'shared'
19
+ }
20
+
21
+ /** A found item's name and description as a person reads them. */
22
+ export function describeFound(item: Found): { name: string; description: string | null } {
23
+ if (item.path.startsWith(BUILTIN_PREFIX)) return { name: item.path.slice(BUILTIN_PREFIX.length), description: null }
24
+ const file = basename(item.path)
25
+ if (item.kind === 'extension') return { name: file.startsWith('index.') ? basename(dirname(item.path)) : file, description: null }
26
+ if (item.kind === 'context' || item.kind === 'theme' || item.kind === 'hook') return { name: file, description: null }
27
+ const described = registry.describeFile(item.path)
28
+ return { name: described?.name ?? file, description: described?.description ?? '' }
29
+ }
30
+
31
+ export const contributionId = (pluginId: string, kind: ContributionKind, name: string): string => `${pluginId}/${kind}/${name}`
32
+
33
+ /** The ids a choice about a found item is stored under: the item itself, then its plugin. */
34
+ export function foundIds(item: Found): [string, string] {
35
+ const pluginId = pluginIdOf(item)
36
+ return [contributionId(pluginId, FOUND_KINDS[item.kind], describeFound(item).name), pluginId]
37
+ }
@@ -0,0 +1,107 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { sharedDir } from '../accounts/env'
7
+ import { memorySecretStore } from '../accounts/secrets'
8
+ import { createHarness, type Harness } from '../harness'
9
+ import { methods } from '../server/methods'
10
+ import { rootWithProject, writeFile } from '../../tests/helpers'
11
+ import { normalizeSource } from './install'
12
+ import type { Plugin } from './types'
13
+
14
+ const open: Harness[] = []
15
+ afterEach(async () => {
16
+ for (const harness of open.splice(0)) await harness.close()
17
+ })
18
+
19
+ function setup() {
20
+ const root = rootWithProject()
21
+ const base = mkdtempSync(join(tmpdir(), 'install-'))
22
+ const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore() })
23
+ open.push(harness)
24
+ harness.accounts.create('Main', 'apiKey')
25
+ const agentDir = harness.accounts.context(harness.accounts.resolve(null, null)).configDir
26
+ const table = methods(harness)
27
+ const call = <T>(method: string, params: Record<string, unknown> = {}) => table[method](params) as Promise<T>
28
+ const loaded = async () => {
29
+ const loading = await harness.plugins.loading({ cwd: base, agentDir, caller: {} })
30
+ return { extensions: loading.extensionPaths.map((path) => path.replace(`${base}/`, '')), skills: loading.skillPaths.map((path) => path.replace(`${base}/`, '')) }
31
+ }
32
+ return { harness, root, base, call, loaded }
33
+ }
34
+
35
+ function kit(folder: string): void {
36
+ writeFile(join(folder, 'package.json'), JSON.stringify({ name: 'review-kit', description: 'Review helpers.', version: '1.0.0' }))
37
+ writeFile(join(folder, 'extensions/guard.ts'), 'export default function () {}\n')
38
+ writeFile(join(folder, 'skills/audit/SKILL.md'), '---\nname: audit\ndescription: Audit a change.\n---\nSteps.\n')
39
+ }
40
+
41
+ describe('installing a package', () => {
42
+ it('reads a name as an npm package, keeps a folder or an address as it is, and refuses a relative path', () => {
43
+ expect(normalizeSource(' pi-tools ')).toBe('npm:pi-tools')
44
+ expect(normalizeSource('npm:@scope/pi-tools@1.0.0')).toBe('npm:@scope/pi-tools@1.0.0')
45
+ expect(normalizeSource('git:github.com/example/pi-tools@v1')).toBe('git:github.com/example/pi-tools@v1')
46
+ expect(normalizeSource('https://github.com/example/pi-tools')).toBe('https://github.com/example/pi-tools')
47
+ expect(normalizeSource('/tmp/kit/')).toBe('/tmp/kit')
48
+ expect(() => normalizeSource('./kit')).toThrow('whole path')
49
+ expect(() => normalizeSource(' ')).toThrow()
50
+ })
51
+
52
+ it('arrives switched off and untrusted, and loads only once it is both switched on and trusted', async () => {
53
+ const { harness, base, call, loaded } = setup()
54
+ kit(join(base, 'kit'))
55
+ const after = await call<Plugin[]>('plugins.install', { source: join(base, 'kit') })
56
+ const plugin = after.find((candidate) => candidate.id === 'package:review-kit')!
57
+ expect(plugin).toMatchObject({ source: 'package', version: '1.0.0', runsCode: true, trust: 'untrusted', install: { kind: 'settings', source: join(base, 'kit') }, attachment: { global: false } })
58
+ expect(plugin.contributions.map((entry) => `${entry.kind}:${entry.name}:${entry.state}`)).toEqual(['code:guard.ts:off', 'skill:audit:off'])
59
+ // It is named in the settings every account shares, as `pi install` would have done.
60
+ expect(JSON.parse(readFileSync(join(sharedDir(harness.accounts.dir), 'settings.json'), 'utf8')).packages).toEqual([join(base, 'kit')])
61
+ expect(await loaded()).toEqual({ extensions: [], skills: [] })
62
+
63
+ // Switched on, its text loads; its code still waits for trust.
64
+ await call('plugins.attach', { id: plugin.id })
65
+ expect(await loaded()).toEqual({ extensions: [], skills: ['kit/skills/audit/SKILL.md'] })
66
+ const trusted = await call<Plugin[]>('plugins.trust', { hash: plugin.code!.hash })
67
+ expect(trusted.find((candidate) => candidate.id === plugin.id)).toMatchObject({ trust: 'trusted' })
68
+ expect(await loaded()).toEqual({ extensions: ['kit/extensions/guard.ts'], skills: ['kit/skills/audit/SKILL.md'] })
69
+
70
+ // A stale fingerprint is refused, and changed code waits again.
71
+ writeFileSync(join(base, 'kit/extensions/guard.ts'), 'export default function () { /* changed */ }\n')
72
+ await expect(call('plugins.trust', { hash: plugin.code!.hash })).rejects.toThrow('changed since it was shown')
73
+ expect((await loaded()).extensions).toEqual([])
74
+
75
+ const removed = await call<Plugin[]>('plugins.remove', { pluginId: plugin.id })
76
+ expect(removed.some((candidate) => candidate.id === plugin.id)).toBe(false)
77
+ expect(JSON.parse(readFileSync(join(sharedDir(harness.accounts.dir), 'settings.json'), 'utf8')).packages ?? []).toEqual([])
78
+ // A folder that was installed in place is not deleted with its entry.
79
+ expect(existsSync(join(base, 'kit/package.json'))).toBe(true)
80
+ expect(await loaded()).toEqual({ extensions: [], skills: [] })
81
+ })
82
+
83
+ it('refuses a folder that is not there, and a plugin that was not installed', async () => {
84
+ const { base, call } = setup()
85
+ await expect(call('plugins.install', { source: join(base, 'missing') })).rejects.toThrow('does not exist')
86
+ await expect(call('plugins.remove', { pluginId: 'memory' })).rejects.toThrow('was not installed as a package')
87
+ await expect(call('plugins.update', { pluginId: 'nope' })).rejects.toThrow()
88
+ })
89
+
90
+ it('finds a package kept as a folder beside the data, and moves it to the trash when removed', async () => {
91
+ const { root, call, loaded, harness } = setup()
92
+ // Trust has been asked per package before this folder appears, so its code waits.
93
+ harness.plugins.trust.adopt([])
94
+ kit(join(root, 'plugins/review-kit'))
95
+ mkdirSync(join(root, 'plugins/.hidden'), { recursive: true })
96
+ const plugins = await call<Plugin[]>('plugins.list')
97
+ const plugin = plugins.find((candidate) => candidate.id === 'package:review-kit')!
98
+ expect(plugin).toMatchObject({ trust: 'untrusted', install: { kind: 'folder', source: join(root, 'plugins/review-kit') }, attachment: { global: true } })
99
+ expect((await loaded()).skills.some((path) => path.endsWith('plugins/review-kit/skills/audit/SKILL.md'))).toBe(true)
100
+ expect((await loaded()).extensions).toEqual([])
101
+ await expect(call('plugins.update', { pluginId: plugin.id })).rejects.toThrow('is a folder on this machine')
102
+
103
+ await call('plugins.remove', { pluginId: plugin.id })
104
+ expect(existsSync(join(root, 'plugins/review-kit'))).toBe(false)
105
+ expect(readdirSync(join(root, 'trash/plugins')).some((name) => name.startsWith('review-kit-'))).toBe(true)
106
+ })
107
+ })