@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,184 @@
1
+ // The harness decides what a Pi session loads. Pi's own discovery is switched off for sessions
2
+ // and terminals; they receive the paths chosen here, so nothing reaches a model unlisted.
3
+ import { chmodSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ import type { Bus } from '../core/bus'
7
+ import { now, readJsonOr, writeJson } from '../core/fs'
8
+ import { removeLinkedSkills } from '../skills/registry'
9
+ import { attached, type Attachments, type AttachScope } from './attachments'
10
+ import { piResolver, type Found, type ResolveInput, type Resolver } from './discover'
11
+ import { foundIds, pluginIdOf } from './identity'
12
+ import { packageCode, projectCode, Trust, type ProjectCode } from './trust'
13
+
14
+ /** What one session or terminal loads besides the registry's own items. */
15
+ export interface Loading {
16
+ /** Extension files and folders, in the order Pi would have loaded them. */
17
+ extensionPaths: string[]
18
+ skillPaths: string[]
19
+ promptPaths: string[]
20
+ themePaths: string[]
21
+ /** The `pi` command's own extensions left on, as `builtin:<name>`. Only a terminal has them. */
22
+ builtins: string[]
23
+ /** Hooks files the harness runs itself. Pi is never given them. */
24
+ hooks: { path: string; packageRoot: string; hash: string }[]
25
+ /** Everything found, loaded or not. */
26
+ found: Found[]
27
+ /** Paths of found items a person switched off for this caller. */
28
+ detached: string[]
29
+ /** Applies the same attachment policy to resources supplied by a trusted extension. */
30
+ resourceAllowed?: (item: Found) => boolean
31
+ /** Code the working folder brings, and whether a person trusted it. */
32
+ projectCode: (ProjectCode & { trusted: boolean }) | null
33
+ /** Each installed package that brings code, by its folder, and whether a person trusted it. */
34
+ packageCode: Record<string, ProjectCode & { trusted: boolean }>
35
+ }
36
+
37
+ /** What one loaded extension added to a session. */
38
+ export interface Registered {
39
+ path: string
40
+ hooks: string[]
41
+ tools: string[]
42
+ commands: string[]
43
+ flags: string[]
44
+ shortcuts: string[]
45
+ /** Fingerprint at observation time; stale registrations are not presented as current. */
46
+ hash?: string | null
47
+ seenAt?: string
48
+ resources?: RuntimeResource[]
49
+ }
50
+
51
+ export interface RuntimeResource {
52
+ kind: 'skill' | 'command' | 'theme'
53
+ name: string
54
+ path: string
55
+ chars: number | null
56
+ }
57
+
58
+ export interface PluginsOptions {
59
+ stateDir: string
60
+ bus: Bus
61
+ resolver?: Resolver
62
+ /** The choices a person made about what is switched on, or `null` while there is no data root. */
63
+ attachments?: () => Attachments | null
64
+ /** The folder of packages kept with a person's data, or `null` while there is no data root. */
65
+ packagesDir?: () => string | null
66
+ }
67
+
68
+ /** The package folders inside a folder: each direct child that is a folder and not hidden. */
69
+ export function packageFolders(directory: string | null): string[] {
70
+ if (!directory) return []
71
+ try {
72
+ return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')).map((entry) => join(directory, entry.name)).sort()
73
+ } catch {
74
+ return []
75
+ }
76
+ }
77
+
78
+ export interface LoadingInput extends ResolveInput {
79
+ /** Who the session is for. Something switched off for this agent or project does not load. */
80
+ caller?: AttachScope
81
+ }
82
+
83
+ /** Which code a person trusted: the working folder's as a whole, and each installed package's. */
84
+ export interface Trusted {
85
+ project: boolean
86
+ /** Folders of installed packages whose code is not trusted. */
87
+ untrustedPackages: ReadonlySet<string>
88
+ }
89
+
90
+ export const trustedBy = (loading: Pick<Loading, 'projectCode' | 'packageCode'> | null): Trusted => ({
91
+ project: !!loading?.projectCode?.trusted,
92
+ untrustedPackages: new Set(Object.entries(loading?.packageCode ?? {}).filter(([, code]) => !code.trusted).map(([root]) => root))
93
+ })
94
+
95
+ /**
96
+ * Whether a found item loads: it is on in Pi's settings, and if it is code, a person trusted it.
97
+ * A loose file in the shared folder was put there by hand and needs no trust. Text always loads.
98
+ */
99
+ export function loads(item: Found, trusted: Trusted): boolean {
100
+ if (!item.enabled) return false
101
+ if (item.kind !== 'extension' && item.kind !== 'hook') return true
102
+ if (item.project) return trusted.project
103
+ return !(item.packageRoot && trusted.untrustedPackages.has(item.packageRoot))
104
+ }
105
+
106
+ /**
107
+ * Whether a person left a found item switched on for a caller. Pi reads its instruction files
108
+ * itself and its command owns its built-in extensions, so neither can be switched off here.
109
+ */
110
+ export function switchable(item: Found): boolean {
111
+ return item.kind !== 'context' && item.origin !== 'builtin'
112
+ }
113
+
114
+ export class Plugins {
115
+ readonly trust: Trust
116
+ private readonly resolver: Resolver
117
+ private readonly registeredPath: string
118
+
119
+ constructor(private readonly options: PluginsOptions) {
120
+ this.trust = new Trust(options.stateDir, options.bus)
121
+ this.resolver = options.resolver ?? piResolver
122
+ this.registeredPath = join(options.stateDir, 'plugin-registered.json')
123
+ }
124
+
125
+ /** Finds what Pi would load in this folder, and chooses what does. */
126
+ async loading(input: LoadingInput): Promise<Loading> {
127
+ // Links an earlier version put in the shared skills folder would be found as skills again.
128
+ removeLinkedSkills(join(input.agentDir, 'skills'))
129
+ const found = await this.resolver({ ...input, packages: packageFolders(this.options.packagesDir?.() ?? null) })
130
+ const code = projectCode(found, input.cwd)
131
+ const packages = packageCode(found)
132
+ // Packages that loaded before trust was asked per package keep loading: they are taken over once.
133
+ if (!this.trust.adopted) this.trust.adopt([...packages].map(([folder, packaged]) => ({ code: packaged, folder })))
134
+ const trustedHashes = new Set(this.trust.list().map((record) => record.hash))
135
+ const projectCodeState = code ? { ...code, trusted: trustedHashes.has(code.hash) } : null
136
+ const packageCodeState = Object.fromEntries([...packages].map(([folder, packaged]) => [folder, { ...packaged, trusted: trustedHashes.has(packaged.hash) }]))
137
+ const trusted = trustedBy({ projectCode: projectCodeState, packageCode: packageCodeState })
138
+ const choices = this.options.attachments?.() ?? null
139
+ const off = (item: Found): boolean => {
140
+ if (!choices || !switchable(item)) return false
141
+ // A hook runs for everyone or not at all, so only its plugin's switch for everyone counts.
142
+ if (item.kind === 'hook') return !attached(choices, [pluginIdOf(item)], {}, true)
143
+ return !attached(choices, foundIds(item), input.caller ?? {}, true)
144
+ }
145
+ const detached = found.filter(off).map((item) => item.path)
146
+ const chosen = found.filter((item) => loads(item, trusted) && !detached.includes(item.path))
147
+ const paths = (kind: Found['kind'], builtin: boolean): string[] => chosen.filter((item) => item.kind === kind && (item.origin === 'builtin') === builtin).map((item) => item.path)
148
+ return {
149
+ extensionPaths: paths('extension', false),
150
+ skillPaths: paths('skill', false),
151
+ promptPaths: paths('prompt', false),
152
+ themePaths: paths('theme', false),
153
+ builtins: paths('extension', true),
154
+ hooks: chosen.filter((item) => item.kind === 'hook' && item.packageRoot).map((item) => ({ path: item.path, packageRoot: item.packageRoot!, hash: packageCodeState[item.packageRoot!]?.hash ?? '' })),
155
+ found,
156
+ detached,
157
+ resourceAllowed: (item) => !off(item),
158
+ projectCode: projectCodeState,
159
+ packageCode: packageCodeState
160
+ }
161
+ }
162
+
163
+ /** What each extension registered the last time a session loaded it. */
164
+ registered(): Record<string, Registered & { seenAt: string }> {
165
+ return readJsonOr<Record<string, Registered & { seenAt: string }>>(this.registeredPath, {})
166
+ }
167
+
168
+ /** Records what a session's extensions registered, so the list can show it without loading code. */
169
+ loaded(extensions: readonly Registered[]): void {
170
+ if (!extensions.length) return
171
+ const stored = this.registered()
172
+ let changed = false
173
+ for (const extension of extensions) {
174
+ const { seenAt: _seenAt, ...before } = stored[extension.path] ?? ({} as Registered & { seenAt: string })
175
+ if (JSON.stringify(before) === JSON.stringify(extension)) continue
176
+ stored[extension.path] = { ...extension, seenAt: now() }
177
+ changed = true
178
+ }
179
+ if (!changed) return
180
+ writeJson(this.registeredPath, stored)
181
+ chmodSync(this.registeredPath, 0o600)
182
+ this.options.bus.emit({ type: 'plugins.changed' })
183
+ }
184
+ }
@@ -0,0 +1,121 @@
1
+ // A plugin an agent wrote for a person to consider. Nothing here installs anything on its own:
2
+ // the proposal waits in the Inbox; accepting it writes a package beside the person's data,
3
+ // switched off and (when it brings code) untrusted. The person then reads it, switches it on,
4
+ // and trusts it. No agent has a tool for either of those.
5
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { z } from 'zod'
8
+
9
+ import { invalid } from '../core/errors'
10
+ import { now, safeRelative, writeJson } from '../core/fs'
11
+ import { slugify } from '../core/projects'
12
+ import { inbox } from '../memory/operations'
13
+ import { looksSecret } from '../memory/secrets'
14
+ import * as store from '../memory/store'
15
+ import * as attachments from './attachments'
16
+
17
+ /** The parts a plugin can say it extends. */
18
+ export const EXTENDABLE = ['memory', 'wiki', 'history'] as const
19
+
20
+ const MAX_FILES = 40
21
+ const MAX_BYTES = 256 * 1024
22
+ const HOOKS_FILES = ['hooks.mjs', 'hooks.js']
23
+
24
+ export const pluginProposalSchema = z.object({
25
+ name: z.string(),
26
+ description: z.string().default(''),
27
+ extends: z.array(z.string()).default([]),
28
+ reason: z.string().default(''),
29
+ files: z.array(z.object({ path: z.string(), content: z.string() }))
30
+ })
31
+ export type PluginProposal = z.infer<typeof pluginProposalSchema>
32
+
33
+ interface Checked {
34
+ slug: string
35
+ extends: string[]
36
+ hooks: string | null
37
+ runsCode: boolean
38
+ files: { path: string; content: string }[]
39
+ }
40
+
41
+ /** The proposal with its paths made safe, or the reason it cannot be a plugin. */
42
+ export function checkPluginProposal(proposal: PluginProposal): Checked {
43
+ const name = proposal.name.trim()
44
+ const slug = slugify(name)
45
+ if (!name || slug.startsWith('page-') || name.length > 64) throw invalid('Give the plugin a short name of letters and numbers.')
46
+ if (!proposal.description.trim()) throw invalid('Say in one line what the plugin does; that line is what the user sees first.')
47
+ const unknown = proposal.extends.find((part) => !(EXTENDABLE as readonly string[]).includes(part))
48
+ if (unknown !== undefined) throw invalid(`A plugin can extend ${EXTENDABLE.join(', ')}; "${unknown}" is none of them.`)
49
+ if (!proposal.files.length || proposal.files.length > MAX_FILES) throw invalid(`A plugin needs between 1 and ${MAX_FILES} files.`)
50
+ const files: Checked['files'] = []
51
+ let bytes = 0
52
+ for (const file of proposal.files) {
53
+ let path: string
54
+ try {
55
+ path = safeRelative(file.path.replace(/^\.\//, ''))
56
+ } catch {
57
+ throw invalid(`"${file.path}" is not a path inside the plugin.`)
58
+ }
59
+ const parts = path.split('/')
60
+ if (parts.some((part) => part.startsWith('.') || part.includes(':') || part === 'node_modules')) throw invalid(`"${file.path}" cannot be part of a plugin.`)
61
+ if (path === 'package.json') throw invalid('Leave package.json out; it is written from the name and description you gave.')
62
+ if (files.some((existing) => existing.path.toLowerCase() === path.toLowerCase())) throw invalid(`"${file.path}" is listed twice.`)
63
+ bytes += Buffer.byteLength(file.content, 'utf8')
64
+ if (bytes > MAX_BYTES) throw invalid('The plugin is larger than 256 KiB.')
65
+ if (looksSecret(file.content)) throw invalid(`"${file.path}" contains text that looks like a credential. A plugin reaches outside through a connected app, never with a key of its own.`)
66
+ files.push({ path, content: file.content })
67
+ }
68
+ const hooks = files.find((file) => HOOKS_FILES.includes(file.path))?.path ?? null
69
+ return { slug, extends: [...new Set(proposal.extends)], hooks, runsCode: hooks !== null || files.some((file) => file.path.startsWith('extensions/')), files }
70
+ }
71
+
72
+ /** Puts a proposed plugin in the Inbox, for a person to read before anything is written. */
73
+ export function parkPluginProposal(wiki: string, from: string, proposal: PluginProposal): string {
74
+ const checked = checkPluginProposal(proposal)
75
+ const listed = checked.files.map((file) => `- ${file.path} (${file.content.length} characters)`).join('\n')
76
+ const code = checked.runsCode ? '\n\nIt brings code. Accepting only saves the files: the code runs after you also switch the plugin on and trust it.' : ''
77
+ inbox(
78
+ wiki,
79
+ 'proposed',
80
+ `Proposed plugin "${proposal.name.trim()}"`,
81
+ `${proposal.description.trim()}\n\n${proposal.reason.trim() || 'Review it before it is saved.'}${checked.extends.length ? `\n\nExtends: ${checked.extends.join(', ')}.` : ''}\n\nFiles:\n${listed}${code}\n\nFrom ${from}.`,
82
+ [],
83
+ null,
84
+ { kind: 'plugin', proposal: { ...proposal, files: checked.files }, trigger: from }
85
+ )
86
+ store.log(wiki, `- ${now()} · ${from} · proposed plugin \`${checked.slug}\` · waiting in Inbox`)
87
+ return checked.slug
88
+ }
89
+
90
+ /** Writes an accepted proposal as a package beside the person's data, switched off everywhere. */
91
+ export function acceptPluginProposal(root: string, proposal: PluginProposal): string {
92
+ const checked = checkPluginProposal(proposal)
93
+ const directory = join(root, 'plugins')
94
+ const target = join(directory, checked.slug)
95
+ if (existsSync(target)) throw invalid(`There is already a plugin folder called "${checked.slug}". Remove it or rename the proposal.`)
96
+ mkdirSync(directory, { recursive: true })
97
+ // Written beside its place and moved in whole, so a half-written plugin is never found.
98
+ const staging = mkdtempSync(join(directory, '.accepting-'))
99
+ try {
100
+ writeJson(join(staging, 'package.json'), {
101
+ name: checked.slug,
102
+ version: '0.1.0',
103
+ description: proposal.description.trim(),
104
+ keywords: ['pi-package'],
105
+ ...(checked.extends.length || checked.hooks ? { harness: { ...(checked.extends.length ? { extends: checked.extends } : {}), ...(checked.hooks ? { hooks: `./${checked.hooks}` } : {}) } } : {})
106
+ })
107
+ for (const file of checked.files) {
108
+ const path = join(staging, ...file.path.split('/'))
109
+ mkdirSync(dirname(path), { recursive: true })
110
+ writeFileSync(path, file.content, { flag: 'wx' })
111
+ }
112
+ renameSync(staging, target)
113
+ } catch (error) {
114
+ rmSync(staging, { recursive: true, force: true })
115
+ throw error
116
+ }
117
+ const choices = attachments.load(root)
118
+ attachments.set(choices, [`package:${checked.slug}`], {}, false, true)
119
+ attachments.save(root, choices)
120
+ return `Saved the plugin "${checked.slug}", switched off.${checked.runsCode ? ' Its code does not run until you switch it on and trust it.' : ' Switch it on to use it.'}`
121
+ }
@@ -0,0 +1,40 @@
1
+ import { mkdtempSync, symlinkSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { expect, it } from 'vitest'
5
+ import type * as Pi from '@earendil-works/pi-coding-agent'
6
+
7
+ import { writeFile } from '../../tests/helpers'
8
+ import type { Found } from './discover'
9
+ import type { Loading, Registered } from './plugins'
10
+ import { observeResources } from './runtime'
11
+
12
+ it('expands theme directories, filters individual files and replaces each handler observation', async () => {
13
+ const pi = await import('@earendil-works/pi-coding-agent')
14
+ const base = mkdtempSync(join(tmpdir(), 'resource-observation-'))
15
+ const path = join(base, 'extension.ts')
16
+ writeFile(path, 'export default function() {}')
17
+ const first = join(base, 'themes/first.json')
18
+ const second = join(base, 'themes/second.json')
19
+ writeFile(first, '{}')
20
+ writeFile(second, '{}')
21
+ symlinkSync(join(base, 'absent.json'), join(base, 'themes/broken.json'))
22
+ writeFile(join(base, 'review.md'), '---\nname: runtime-review\ndescription: Review.\n---\nReview text.')
23
+ let active = true
24
+ const handlers = new Map([['resources_discover', [async () => active ? { themePaths: ['./themes'] } : undefined, async () => ({ promptPaths: ['./review.md'] })]]])
25
+ const extension = { path, resolvedPath: path, handlers }
26
+ const loader = { getExtensions: () => ({ extensions: [extension] }) } as unknown as Pi.DefaultResourceLoader
27
+ const owner: Found = { kind: 'extension', path, origin: 'shared', project: false, packageRoot: null, source: null, enabled: true }
28
+ const loading: Loading = { extensionPaths: [path], skillPaths: [], promptPaths: [], themePaths: [], builtins: [], hooks: [], found: [owner], detached: [], projectCode: null, packageCode: {}, resourceAllowed: (item) => item.path !== first }
29
+ const records: Registered[] = [{ path, tools: [], commands: [], hooks: ['resources_discover'], flags: [], shortcuts: [] }]
30
+ let published = 0
31
+ observeResources(pi, loader, loading, records, () => { published++ })
32
+ const wrapped = extension.handlers.get('resources_discover')! as unknown as Array<(event: unknown, context: unknown) => Promise<Pi.ResourcesDiscoverResult | undefined>>
33
+ expect(await wrapped[0]({}, {})).toMatchObject({ themePaths: [second] })
34
+ await wrapped[1]({}, {})
35
+ expect(records[0].resources?.map((resource) => resource.name)).toEqual(['first.json', 'second.json', 'runtime-review'])
36
+ active = false
37
+ await wrapped[0]({}, {})
38
+ expect(records[0].resources?.map((resource) => resource.name)).toEqual(['runtime-review'])
39
+ expect(published).toBe(3)
40
+ })
@@ -0,0 +1,82 @@
1
+ // Observe Pi's structured resource discovery without running code while the catalogue is read.
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
4
+ import { dirname, resolve } from 'node:path'
5
+ import type * as Pi from '@earendil-works/pi-coding-agent'
6
+
7
+ import { charCount } from '../core/text'
8
+ import { FOUND_KINDS, describeFound } from './identity'
9
+ import type { Found } from './discover'
10
+ import type { Loading, Registered, RuntimeResource } from './plugins'
11
+
12
+ export function runtimeOwner(loading: Loading, path: string): Found | undefined {
13
+ return loading.found.find((item) => item.kind === 'extension' && (item.path === path || path.startsWith(`${item.path}/`)))
14
+ }
15
+
16
+ export function registrationHash(loading: Loading, path: string): string | null {
17
+ const owner = runtimeOwner(loading, path)
18
+ if (owner?.project) return loading.projectCode?.hash ?? null
19
+ if (owner?.packageRoot) return loading.packageCode[owner.packageRoot]?.hash ?? null
20
+ try { return createHash('sha256').update(readFileSync(path)).digest('hex') } catch { return null }
21
+ }
22
+
23
+ /** A discovered resource has the same identity for its switch, catalogue and load decision. */
24
+ export function resourceOf(owner: Found, kind: 'skill' | 'prompt' | 'theme', path: string): RuntimeResource {
25
+ const found = { ...owner, kind, path }
26
+ const described = describeFound(found)
27
+ let chars: number | null = null
28
+ try { chars = charCount(readFileSync(path, 'utf8')) } catch { /* Missing files remain observable. */ }
29
+ return { kind: FOUND_KINDS[kind] as RuntimeResource['kind'], name: described.name, path, chars }
30
+ }
31
+
32
+ /** Wrap each handler separately so identical extension basenames never lose their owner. */
33
+ export function observeResources(pi: typeof Pi, loader: Pi.DefaultResourceLoader, loading: Loading, records: Registered[], publish: () => void): void {
34
+ for (const extension of loader.getExtensions().extensions) {
35
+ if (extension.hidden) continue
36
+ const path = extension.resolvedPath || extension.path
37
+ const owner = runtimeOwner(loading, path)
38
+ const record = records.find((entry) => entry.path === path)
39
+ if (!owner || !record) continue
40
+ const handlers = extension.handlers.get('resources_discover')
41
+ if (!handlers) continue
42
+ const observations = new Map<number, RuntimeResource[]>()
43
+ extension.handlers.set('resources_discover', handlers.map((handler, index) => async (event, context) => {
44
+ const result = await handler(event, context) as Pi.ResourcesDiscoverResult | undefined
45
+ if (!result) {
46
+ observations.delete(index)
47
+ record.resources = [...observations.values()].flat()
48
+ publish()
49
+ return result
50
+ }
51
+ const collected: RuntimeResource[] = []
52
+ const allowed = (kind: 'skill' | 'prompt' | 'theme', resourcePath: string): boolean => {
53
+ const resource = resourceOf(owner, kind, resourcePath)
54
+ collected.push(resource)
55
+ return loading.resourceAllowed?.({ ...owner, kind, path: resourcePath }) !== false
56
+ }
57
+ const absolute = (entry: string): string => resolve(dirname(path), entry)
58
+ const skills = pi.loadSkills({ cwd: dirname(path), agentDir: dirname(path), includeDefaults: false, skillPaths: (result.skillPaths ?? []).map(absolute) }).skills
59
+ const scanner = new pi.DefaultResourceLoader({ cwd: dirname(path), agentDir: dirname(path), settingsManager: pi.SettingsManager.inMemory({}), noExtensions: true, noSkills: true, noThemes: true, noContextFiles: true, noPromptTemplates: true, additionalPromptTemplatePaths: (result.promptPaths ?? []).map(absolute) })
60
+ await scanner.reload()
61
+ const prompts = scanner.getPrompts().prompts
62
+ const themes = (result.themePaths ?? []).map(absolute).flatMap((theme) => {
63
+ try {
64
+ if (statSync(theme).isDirectory()) return readdirSync(theme).filter((name) => {
65
+ if (!name.endsWith('.json')) return false
66
+ try { return statSync(resolve(theme, name)).isFile() } catch { return false }
67
+ }).map((name) => resolve(theme, name))
68
+ } catch { /* Preserve missing paths for Pi's diagnostics. */ }
69
+ return [theme]
70
+ })
71
+ const filtered = {
72
+ skillPaths: skills.filter((skill) => allowed('skill', skill.filePath)).map((skill) => skill.filePath),
73
+ promptPaths: prompts.filter((prompt) => allowed('prompt', prompt.filePath)).map((prompt) => prompt.filePath),
74
+ themePaths: themes.filter((theme) => allowed('theme', theme))
75
+ }
76
+ observations.set(index, collected)
77
+ record.resources = [...new Map([...observations.values()].flat().map((entry) => [`${entry.kind}:${entry.path}`, entry])).values()]
78
+ publish()
79
+ return filtered
80
+ }))
81
+ }
82
+ }
@@ -0,0 +1,142 @@
1
+ // Code a person did not write into the shared folder by hand loads only after they trusted it:
2
+ // a working folder's own (its `.pi/extensions`, or a package its settings name), and every
3
+ // installed package. Trust belongs to the code, not the folder: it is a hash of the files, so a
4
+ // copy of the same code elsewhere is trusted too and a changed file asks again.
5
+ import { createHash } from 'node:crypto'
6
+ import { chmodSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
7
+ import { basename, dirname, join, relative, sep } from 'node:path'
8
+
9
+ import type { Bus } from '../core/bus'
10
+ import { now, readJsonOr, writeJson } from '../core/fs'
11
+ import { compareText } from '../core/text'
12
+ import type { Found } from './discover'
13
+
14
+ export interface ProjectCode {
15
+ /** SHA-256 over every file's name and contents. */
16
+ hash: string
17
+ /** The files, named from the working folder where they are inside it. */
18
+ files: string[]
19
+ }
20
+
21
+ // Dependencies and version control are left out: the hash says whether the code a person
22
+ // read has changed, and is not a seal on everything that code can import.
23
+ const SKIPPED = new Set(['node_modules', '.git'])
24
+ const MAX_FILES = 5000
25
+
26
+ function walk(path: string, into: string[]): void {
27
+ if (into.length >= MAX_FILES) return
28
+ let meta
29
+ try {
30
+ meta = lstatSync(path)
31
+ } catch {
32
+ return
33
+ }
34
+ if (meta.isSymbolicLink()) return
35
+ if (meta.isFile()) {
36
+ into.push(path)
37
+ return
38
+ }
39
+ if (!meta.isDirectory()) return
40
+ for (const name of readdirSync(path).sort(compareText)) if (!SKIPPED.has(name)) walk(join(path, name), into)
41
+ }
42
+
43
+ /** The folder or file that holds one extension: its package, its own folder, or the file. */
44
+ const unit = (item: Found): string => item.packageRoot ?? (basename(item.path).startsWith('index.') ? dirname(item.path) : item.path)
45
+
46
+ /** A hash over the files of some folders and files, each named from `base` where it is inside it. */
47
+ function hashOf(units: readonly string[], base: string): ProjectCode {
48
+ const paths: string[] = []
49
+ for (const path of [...units].sort(compareText)) walk(path, paths)
50
+ const name = (path: string): string => {
51
+ const within = relative(base, path)
52
+ return within && !within.startsWith('..') ? within.split(sep).join('/') : path
53
+ }
54
+ const hash = createHash('sha256')
55
+ const files: string[] = []
56
+ for (const path of [...new Set(paths)].sort(compareText)) {
57
+ let contents: Buffer
58
+ try {
59
+ contents = readFileSync(path)
60
+ } catch {
61
+ continue
62
+ }
63
+ files.push(name(path))
64
+ hash.update(`${name(path)}\0${contents.length}\0`).update(contents)
65
+ }
66
+ return { hash: hash.digest('hex'), files }
67
+ }
68
+
69
+ /** The code the working folder brings, or `null` when it brings none. */
70
+ export function projectCode(found: readonly Found[], cwd: string): ProjectCode | null {
71
+ const units = [...new Set(found.filter((item) => item.project && item.kind === 'extension' && item.enabled).map(unit))]
72
+ return units.length ? hashOf(units, cwd) : null
73
+ }
74
+
75
+ /**
76
+ * Each package a person installed that brings code, by its folder: the whole package is hashed,
77
+ * so a changed skill or manifest asks again as well. A working folder's packages are part of
78
+ * `projectCode`.
79
+ */
80
+ export function packageCode(found: readonly Found[]): Map<string, ProjectCode> {
81
+ const roots = [...new Set(found.filter((item) => !item.project && (item.kind === 'extension' || item.kind === 'hook') && item.packageRoot).map((item) => item.packageRoot!))]
82
+ return new Map(roots.map((root) => [root, hashOf([root], root)]))
83
+ }
84
+
85
+ export interface TrustRecord {
86
+ hash: string
87
+ /** The working folder it was trusted in. */
88
+ folder: string
89
+ files: string[]
90
+ trustedAt: string
91
+ }
92
+
93
+ export class Trust {
94
+ private readonly path: string
95
+
96
+ constructor(stateDir: string, private readonly bus: Bus) {
97
+ this.path = join(stateDir, 'plugin-trust.json')
98
+ }
99
+
100
+ private read(): { trusted?: unknown; adopted?: unknown } {
101
+ return readJsonOr<{ trusted?: unknown; adopted?: unknown }>(this.path, {})
102
+ }
103
+
104
+ list(): TrustRecord[] {
105
+ const stored = this.read()
106
+ return Array.isArray(stored.trusted) ? (stored.trusted as TrustRecord[]).filter((record) => typeof record?.hash === 'string') : []
107
+ }
108
+
109
+ /** Whether the packages that loaded before trust was asked per package were taken over as trusted. */
110
+ get adopted(): boolean {
111
+ return this.read().adopted === true
112
+ }
113
+
114
+ /**
115
+ * Trusts the packages present the first time this runs, so nothing that loaded before stops.
116
+ * It happens once; a package installed later, or one of these after a change, waits for a person.
117
+ */
118
+ adopt(codes: readonly { code: ProjectCode; folder: string }[]): void {
119
+ const records = this.list()
120
+ for (const { code, folder } of codes) if (!records.some((record) => record.hash === code.hash)) records.push({ hash: code.hash, folder, files: code.files, trustedAt: now() })
121
+ writeJson(this.path, { trusted: records, adopted: true })
122
+ chmodSync(this.path, 0o600)
123
+ }
124
+
125
+ has(hash: string): boolean {
126
+ return this.list().some((record) => record.hash === hash)
127
+ }
128
+
129
+ private save(records: TrustRecord[]): void {
130
+ writeJson(this.path, { trusted: records, ...(this.adopted ? { adopted: true } : {}) })
131
+ chmodSync(this.path, 0o600)
132
+ this.bus.emit({ type: 'plugins.changed' })
133
+ }
134
+
135
+ grant(code: ProjectCode, folder: string): void {
136
+ this.save([...this.list().filter((record) => record.hash !== code.hash), { hash: code.hash, folder, files: code.files, trustedAt: now() }])
137
+ }
138
+
139
+ revoke(hash: string): void {
140
+ this.save(this.list().filter((record) => record.hash !== hash))
141
+ }
142
+ }