@xingular/harness 0.1.6 → 0.1.7
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.
- package/CHANGELOG.md +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { Bus } from '../core/bus'
|
|
7
|
+
import { Harness } from '../harness'
|
|
8
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
9
|
+
import * as registry from '../skills/registry'
|
|
10
|
+
import { Hub } from './hub'
|
|
11
|
+
import { hubCatalogSchema, hubEntrySchema, type HubEntry } from './schema'
|
|
12
|
+
import { runHubMenu } from '../terminal/hubMenu'
|
|
13
|
+
import { SessionSocket } from '../terminal/socket'
|
|
14
|
+
import { HarnessError } from '../core/errors'
|
|
15
|
+
import { runHubCli } from './cli'
|
|
16
|
+
import * as packageApi from '../plugins/api'
|
|
17
|
+
import * as packageInstall from '../plugins/install'
|
|
18
|
+
import type { Plugin } from '../plugins/types'
|
|
19
|
+
|
|
20
|
+
const folders: string[] = []
|
|
21
|
+
afterEach(() => { vi.restoreAllMocks(); for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true }) })
|
|
22
|
+
function entry(kind: 'document' | 'registry' = 'document', content = '{"message":"data only"}'): HubEntry {
|
|
23
|
+
return hubEntrySchema.parse({ id: 'example/review', title: 'Review', description: 'A reviewed item.', publisher: 'Example', license: 'MIT', version: '1.0.0', category: kind === 'document' ? 'preset' : 'skill', channel: 'community', tags: [], requires: {}, sourceUrl: 'https://github.com/example/review', delivery: { kind, ...(kind === 'registry' ? { registryKind: 'skills' } : {}), url: 'https://raw.githubusercontent.com/example/review/main/payload.json', sha256: createHash('sha256').update(content).digest('hex'), bundled: content } })
|
|
24
|
+
}
|
|
25
|
+
function fixture(items: HubEntry[], fetcher?: typeof fetch) {
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), 'hub-test-')); folders.push(root)
|
|
27
|
+
const harness = { dataRoot: join(root, 'data'), stateDir: join(root, 'state'), env: { root: join(root, 'data') }, bus: new Bus() } as Harness
|
|
28
|
+
const hub = new Hub(harness, { catalog: { schemaVersion: 1, entries: items }, fetch: fetcher })
|
|
29
|
+
return { hub, harness, root: harness.env.root }
|
|
30
|
+
}
|
|
31
|
+
const skill = (name = 'review', path = 'SKILL.md') => JSON.stringify({ items: [{ name, files: [{ path, encoding: 'utf8', content: '---\nname: review\ndescription: Review evidence.\n---\n\nRead sources.' }] }] })
|
|
32
|
+
|
|
33
|
+
describe('Hub installation', () => {
|
|
34
|
+
it('stores documents inertly, idempotently, and checks their digest on read', async () => {
|
|
35
|
+
const { hub, root } = fixture([entry()])
|
|
36
|
+
const receipt = await hub.install('example/review', '1.0.0')
|
|
37
|
+
expect(receipt.resourceIds).toEqual([])
|
|
38
|
+
expect(await hub.install('example/review', '1.0.0')).toEqual(receipt)
|
|
39
|
+
expect(hub.readDocument('example/review', '1.0.0').content).toContain('data only')
|
|
40
|
+
expect(existsSync(join(root, 'config', 'plugin-attachments.json'))).toBe(false)
|
|
41
|
+
writeFileSync(join(root, 'hub', 'documents', receipt.document!), '{"edited":true}')
|
|
42
|
+
expect(() => hub.readDocument('example/review', '1.0.0')).toThrow('differs')
|
|
43
|
+
})
|
|
44
|
+
it('imports complete skills disabled and refuses overwriting local files', async () => {
|
|
45
|
+
const { hub, root } = fixture([entry('registry', skill())])
|
|
46
|
+
const receipt = await hub.install('example/review', '1.0.0')
|
|
47
|
+
expect(receipt.resourceIds).toEqual(['skills/review'])
|
|
48
|
+
expect(registry.enabledFor(root, null)).toEqual([])
|
|
49
|
+
const content = readFileSync(join(root, 'registry', 'skills', 'review', 'SKILL.md'), 'utf8')
|
|
50
|
+
const collision = { ...entry('registry', skill()), id: 'other/review' }
|
|
51
|
+
const another = new Hub(({ dataRoot: root, env: { root }, stateDir: join(root, 'state'), bus: new Bus() }) as Harness, { catalog: { schemaVersion: 1, entries: [collision] } })
|
|
52
|
+
await expect(another.install(collision.id, collision.version)).rejects.toThrow('already exists')
|
|
53
|
+
expect(readFileSync(join(root, 'registry', 'skills', 'review', 'SKILL.md'), 'utf8')).toBe(content)
|
|
54
|
+
})
|
|
55
|
+
it.each(['../escape', 'folder/../../escape', 'C:/escape', '.hidden/SKILL.md'])('rejects registry path %s before writing', async (path) => {
|
|
56
|
+
const { hub, root } = fixture([entry('registry', skill('review', path))])
|
|
57
|
+
await expect(hub.install('example/review', '1.0.0')).rejects.toThrow()
|
|
58
|
+
expect(registry.list(root)).toEqual([])
|
|
59
|
+
expect(existsSync(join(root, 'hub', 'installed.json'))).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
it('imports prompts and rules as single Markdown files, disabled', async () => {
|
|
62
|
+
for (const kind of ['prompts', 'rules'] as const) {
|
|
63
|
+
const item = entry('registry', JSON.stringify({ items: [{ name: 'review', files: [{ path: 'CONTENT.md', encoding: 'utf8', content: '# Review\nRead evidence.' }] }] }))
|
|
64
|
+
if (item.delivery.kind === 'registry') item.delivery.registryKind = kind
|
|
65
|
+
const { hub, root } = fixture([item])
|
|
66
|
+
await hub.install(item.id, item.version)
|
|
67
|
+
expect(readFileSync(join(root, 'registry', kind, 'review.md'), 'utf8')).toContain('Read evidence')
|
|
68
|
+
expect(registry.enabledFor(root, null)).toEqual([])
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
it('does not commit a download with the wrong digest', async () => {
|
|
72
|
+
const item = entry(); if (item.delivery.kind !== 'pi-package') delete item.delivery.bundled
|
|
73
|
+
const { hub, root } = fixture([item], (async () => new Response('{"different":true}')) as typeof fetch)
|
|
74
|
+
await expect(hub.install(item.id, item.version)).rejects.toThrow('differs')
|
|
75
|
+
expect(existsSync(join(root, 'hub', 'installed.json'))).toBe(false)
|
|
76
|
+
expect(hub.busy).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
it('rejects malformed UTF-8 JSON even when its byte digest matches', async () => {
|
|
79
|
+
const bytes = Buffer.from([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d])
|
|
80
|
+
const item = entry()
|
|
81
|
+
if (item.delivery.kind !== 'pi-package') { delete item.delivery.bundled; item.delivery.sha256 = createHash('sha256').update(bytes).digest('hex') }
|
|
82
|
+
const { hub, root } = fixture([item], (async () => new Response(bytes)) as typeof fetch)
|
|
83
|
+
await expect(hub.install(item.id, item.version)).rejects.toThrow('valid UTF-8')
|
|
84
|
+
expect(existsSync(join(root, 'hub', 'installed.json'))).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
it('previews reviewed bytes without creating files or receipts', async () => {
|
|
87
|
+
const item = entry(); if (item.delivery.kind !== 'pi-package') delete item.delivery.bundled
|
|
88
|
+
const { hub, root } = fixture([item], (async () => new Response('{"message":"data only"}')) as typeof fetch)
|
|
89
|
+
const preview = await hub.get(item.id, item.version, true)
|
|
90
|
+
expect(preview.delivery.kind !== 'pi-package' && preview.delivery.bundled).toContain('data only')
|
|
91
|
+
expect(existsSync(join(root, 'hub'))).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
it('can reinstall deleted resources and clears stale attachment grants', async () => {
|
|
94
|
+
const { hub, root } = fixture([entry('registry', skill())])
|
|
95
|
+
await hub.install('example/review', '1.0.0')
|
|
96
|
+
registry.setEnabled(root, null, ['skills/review'])
|
|
97
|
+
rmSync(join(root, 'registry', 'skills', 'review'), { recursive: true })
|
|
98
|
+
expect((await hub.list()).installed).toEqual([])
|
|
99
|
+
await hub.install('example/review', '1.0.0')
|
|
100
|
+
expect(registry.enabledFor(root, null)).toEqual([])
|
|
101
|
+
expect((await hub.list()).installed).toHaveLength(1)
|
|
102
|
+
})
|
|
103
|
+
it('preserves corrupt installation records rather than overwriting them', async () => {
|
|
104
|
+
const { hub, root } = fixture([entry()])
|
|
105
|
+
mkdirSync(join(root, 'hub'), { recursive: true })
|
|
106
|
+
writeFileSync(join(root, 'hub', 'installed.json'), 'corrupt')
|
|
107
|
+
await expect(hub.install('example/review', '1.0.0')).rejects.toThrow('could not be read')
|
|
108
|
+
expect(readFileSync(join(root, 'hub', 'installed.json'), 'utf8')).toBe('corrupt')
|
|
109
|
+
})
|
|
110
|
+
it('requires the terminal extension control key for hub mutations', async () => {
|
|
111
|
+
const { hub, root } = fixture([entry()])
|
|
112
|
+
const socket = new SessionSocket(join(root, 'unused.sock'), { memory: () => '', apps: async () => null,
|
|
113
|
+
plugins: async (_grant, method, params, control) => {
|
|
114
|
+
if (method === 'hub.list') return hub.list()
|
|
115
|
+
if (!control) throw new HarnessError('unauthorized', 'Only the terminal controls installations.')
|
|
116
|
+
return hub.install(String(params.id), String(params.version))
|
|
117
|
+
} })
|
|
118
|
+
const token = socket.issue({ sessionId: 'terminal', caller: () => ({}), broker: null })
|
|
119
|
+
const install = { token, call: 'hub.install', params: { id: 'example/review', version: '1.0.0' } }
|
|
120
|
+
expect(await socket.handle(JSON.stringify(install))).toHaveProperty('error')
|
|
121
|
+
const claim = await socket.handle(JSON.stringify({ token, call: 'control.claim' })) as { result: { key: string } }
|
|
122
|
+
expect(await socket.handle(JSON.stringify({ ...install, control: claim.result.key }))).toHaveProperty('result.document')
|
|
123
|
+
await socket.close()
|
|
124
|
+
})
|
|
125
|
+
it('CLI installs into the explicit library without opening or settling runs', async () => {
|
|
126
|
+
const { hub, harness, root } = fixture([entry()])
|
|
127
|
+
const output: string[] = []
|
|
128
|
+
const code = await runHubCli(['install', 'example/review@1.0.0', '--data', root, '--state', harness.stateDir], { write: (text) => output.push(text), createHarness: (options) => {
|
|
129
|
+
expect(options.dataRoot).toBe(root)
|
|
130
|
+
return { ...harness, hub, close: async () => undefined } as unknown as Harness
|
|
131
|
+
} })
|
|
132
|
+
expect(code).toBe(0)
|
|
133
|
+
expect(output.join('')).toContain('Saved example/review@1.0.0')
|
|
134
|
+
expect(existsSync(join(root, 'runs'))).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
it('uses the last valid catalog when refresh fails or has duplicate versions', async () => {
|
|
137
|
+
let response = JSON.stringify({ schemaVersion: 1, entries: [entry()] })
|
|
138
|
+
const { hub } = fixture([], (async () => new Response(response)) as typeof fetch)
|
|
139
|
+
expect((await hub.list(true)).origin).toBe('remote')
|
|
140
|
+
response = JSON.stringify({ schemaVersion: 1, entries: [entry(), entry()] })
|
|
141
|
+
const cached = await hub.list(true)
|
|
142
|
+
expect(cached.origin).toBe('cached')
|
|
143
|
+
expect(cached.error).toBeTruthy()
|
|
144
|
+
expect(cached.entries).toHaveLength(1)
|
|
145
|
+
expect((await hub.list()).entries).toHaveLength(1)
|
|
146
|
+
})
|
|
147
|
+
it('rejects simultaneous writers and releases locks after a failed download', async () => {
|
|
148
|
+
const item = entry(); if (item.delivery.kind !== 'pi-package') delete item.delivery.bundled
|
|
149
|
+
let resolve!: (response: Response) => void
|
|
150
|
+
const { hub } = fixture([item], (() => new Promise<Response>((done) => { resolve = done })) as typeof fetch)
|
|
151
|
+
const first = hub.install(item.id, item.version)
|
|
152
|
+
await expect(hub.install(item.id, item.version)).rejects.toThrow('Another installation')
|
|
153
|
+
resolve(new Response('bad digest'))
|
|
154
|
+
await expect(first).rejects.toThrow('differs')
|
|
155
|
+
expect(hub.busy).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
it('keeps an in-progress installation pinned to its data root', async () => {
|
|
158
|
+
const item = entry(); if (item.delivery.kind !== 'pi-package') delete item.delivery.bundled
|
|
159
|
+
const root = mkdtempSync(join(tmpdir(), 'hub-root-pin-')); folders.push(root)
|
|
160
|
+
let resolve!: (response: Response) => void
|
|
161
|
+
const harness = new Harness({ dataRoot: join(root, 'data'), stateDir: join(root, 'state'), secrets: memorySecretStore(), hub: { catalog: { schemaVersion: 1, entries: [item] }, fetch: (() => new Promise<Response>((done) => { resolve = done })) as typeof fetch } })
|
|
162
|
+
try {
|
|
163
|
+
const pending = harness.hub.install(item.id, item.version)
|
|
164
|
+
expect(() => harness.open(join(root, 'other'))).toThrow('Wait for the hub installation')
|
|
165
|
+
expect(harness.dataRoot).toBe(join(root, 'data'))
|
|
166
|
+
resolve(new Response('{"message":"data only"}'))
|
|
167
|
+
await pending
|
|
168
|
+
expect((await harness.hub.list()).installed).toHaveLength(1)
|
|
169
|
+
expect(existsSync(join(root, 'other'))).toBe(false)
|
|
170
|
+
} finally { await harness.close() }
|
|
171
|
+
})
|
|
172
|
+
it('rejects unpinned packages and incompatible runtimes', async () => {
|
|
173
|
+
expect(() => hubEntrySchema.parse({ ...entry(), delivery: { kind: 'pi-package', source: 'npm:unknown@latest' } })).toThrow()
|
|
174
|
+
expect(() => hubCatalogSchema.parse({ schemaVersion: 2, entries: [] })).toThrow()
|
|
175
|
+
const item = { ...entry(), requires: { harness: '99.0.0' } }
|
|
176
|
+
const { hub } = fixture([item])
|
|
177
|
+
await expect(hub.install(item.id, item.version)).rejects.toThrow('requires Harness')
|
|
178
|
+
})
|
|
179
|
+
it('records only the requested package when another plugin appears during installation', async () => {
|
|
180
|
+
const source = 'npm:@example/review@1.0.0'
|
|
181
|
+
const item = hubEntrySchema.parse({ ...entry(), category: 'plugin', delivery: { kind: 'pi-package', source } })
|
|
182
|
+
const { hub } = fixture([item])
|
|
183
|
+
vi.spyOn(packageApi, 'list').mockResolvedValue([])
|
|
184
|
+
vi.spyOn(packageInstall, 'install').mockResolvedValue([
|
|
185
|
+
{ id: 'package:review', install: { kind: 'settings', source } } as Plugin,
|
|
186
|
+
{ id: 'package:another', install: { kind: 'settings', source: 'npm:@someone/other@1.0.0' } } as Plugin
|
|
187
|
+
])
|
|
188
|
+
expect((await hub.install(item.id, item.version)).resourceIds).toEqual(['package:review'])
|
|
189
|
+
expect(packageInstall.install).toHaveBeenCalledWith(expect.anything(), { source })
|
|
190
|
+
})
|
|
191
|
+
it('does not treat cancelling the terminal review as an install', async () => {
|
|
192
|
+
const { hub } = fixture([entry()])
|
|
193
|
+
const calls: string[] = []
|
|
194
|
+
const choices = ['Review · preset · example/review@1.0.0', 'Done']
|
|
195
|
+
await runHubMenu({ select: async () => choices.shift(), confirm: async () => false, notify: () => undefined }, async <T>(method: string) => { calls.push(method); return await hub.list() as T })
|
|
196
|
+
expect(calls).toEqual(['hub.list', 'hub.list'])
|
|
197
|
+
})
|
|
198
|
+
it('refuses an already-held library lock', async () => {
|
|
199
|
+
const { hub, root } = fixture([entry()])
|
|
200
|
+
mkdirSync(join(root, 'hub'), { recursive: true })
|
|
201
|
+
writeFileSync(join(root, 'hub', 'install.lock'), String(process.pid))
|
|
202
|
+
await expect(hub.install('example/review', '1.0.0')).rejects.toThrow('Another installation')
|
|
203
|
+
expect(hub.busy).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
})
|
package/src/hub/hub.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { watch } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
import { HarnessError, invalid } from '../core/errors'
|
|
7
|
+
import { atomicWrite, now, readJson, readJsonOr, writeJson } from '../core/fs'
|
|
8
|
+
import type { Harness } from '../harness'
|
|
9
|
+
import * as pluginApi from '../plugins/api'
|
|
10
|
+
import * as packages from '../plugins/install'
|
|
11
|
+
import * as registry from '../skills/registry'
|
|
12
|
+
import * as attachments from '../plugins/attachments'
|
|
13
|
+
import { version as harnessVersion } from '../../package.json'
|
|
14
|
+
import bundled from './catalog.json'
|
|
15
|
+
import { HUB_CATALOG_URL, HUB_MAX_BYTES, hubCatalogSchema, hubEntrySchema, hubReceiptSchema, meetsMinimum, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './schema'
|
|
16
|
+
|
|
17
|
+
export interface HubOptions {
|
|
18
|
+
catalogUrl?: string
|
|
19
|
+
catalog?: HubCatalog
|
|
20
|
+
fetch?: typeof globalThis.fetch
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const digest = (bytes: Uint8Array | string): string => createHash('sha256').update(bytes).digest('hex')
|
|
24
|
+
const receiptName = (entry: Pick<HubEntry, 'id' | 'version'>): string => digest(`${entry.id}@${entry.version}`)
|
|
25
|
+
|
|
26
|
+
/** Acquires an exclusive writer lock. A crashed writer's lock is recovered only when its PID is gone. */
|
|
27
|
+
function acquire(path: string): () => void {
|
|
28
|
+
mkdirSync(join(path, '..'), { recursive: true })
|
|
29
|
+
try {
|
|
30
|
+
const file = openSync(path, 'wx', 0o600)
|
|
31
|
+
try { writeFileSync(file, `${process.pid}`) } finally { closeSync(file) }
|
|
32
|
+
return () => rmSync(path, { force: true })
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
35
|
+
// Serialize stale-lock recovery too: two reapers must never remove a new writer's lock.
|
|
36
|
+
let recovery: number
|
|
37
|
+
try { recovery = openSync(`${path}.recovery`, 'wx', 0o600) } catch {
|
|
38
|
+
throw new HarnessError('hub_busy', 'Another installation is using this library. Wait and retry.')
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const pid = Number(readFileSync(path, 'utf8'))
|
|
42
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
43
|
+
try { process.kill(pid, 0) } catch (probe) {
|
|
44
|
+
if ((probe as NodeJS.ErrnoException).code === 'ESRCH') {
|
|
45
|
+
rmSync(path)
|
|
46
|
+
return acquire(path)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new HarnessError('hub_busy', 'Another installation is using this library. Wait for it to finish and retry.')
|
|
51
|
+
} finally { closeSync(recovery); rmSync(`${path}.recovery`, { force: true }) }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Catalog data never loads extensions or changes attachment, trust, or connector scopes. */
|
|
56
|
+
export class Hub {
|
|
57
|
+
private readonly fallback: HubCatalog
|
|
58
|
+
private readonly fetcher: typeof globalThis.fetch
|
|
59
|
+
private readonly catalogUrl: string
|
|
60
|
+
private watcher: ReturnType<typeof watch> | null = null
|
|
61
|
+
private refresh: Promise<HubOverview> | null = null
|
|
62
|
+
private installing = false
|
|
63
|
+
get busy(): boolean { return this.installing }
|
|
64
|
+
|
|
65
|
+
constructor(private readonly harness: Harness, options: HubOptions = {}) {
|
|
66
|
+
this.fallback = hubCatalogSchema.parse(options.catalog ?? bundled)
|
|
67
|
+
this.fetcher = options.fetch ?? globalThis.fetch
|
|
68
|
+
this.catalogUrl = options.catalogUrl ?? HUB_CATALOG_URL
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private receipts(): HubReceipt[] {
|
|
72
|
+
const root = this.harness.dataRoot
|
|
73
|
+
if (!root) return []
|
|
74
|
+
const values = readJson<unknown[]>(join(root, 'hub', 'installed.json')) ?? []
|
|
75
|
+
if (!Array.isArray(values)) throw invalid('The hub installation records could not be read.')
|
|
76
|
+
return values.map((value) => hubReceiptSchema.parse(value))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private snapshot(): HubOverview {
|
|
80
|
+
const cached = readJsonOr<{ catalog: unknown; at: string } | null>(join(this.harness.stateDir, 'hub', 'catalog.json'), null)
|
|
81
|
+
const parsed = hubCatalogSchema.safeParse(cached?.catalog)
|
|
82
|
+
const catalog = parsed.success ? parsed.data : this.fallback
|
|
83
|
+
const installed = this.receipts().filter((receipt) => this.present(receipt))
|
|
84
|
+
const entries = new Map(catalog.entries.map((entry) => [`${entry.id}@${entry.version}`, entry]))
|
|
85
|
+
for (const receipt of installed) if (!entries.has(`${receipt.entry.id}@${receipt.entry.version}`)) entries.set(`${receipt.entry.id}@${receipt.entry.version}`, receipt.entry)
|
|
86
|
+
return { entries: [...entries.values()], installed, origin: parsed.success ? 'cached' : 'bundled', refreshedAt: parsed.success ? cached!.at : null, error: null, dataRoot: this.harness.dataRoot, stateDir: this.harness.stateDir }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private present(receipt: HubReceipt): boolean {
|
|
90
|
+
const root = this.harness.dataRoot
|
|
91
|
+
if (!root) return false
|
|
92
|
+
try {
|
|
93
|
+
if (receipt.document) {
|
|
94
|
+
if (receipt.entry.delivery.kind !== 'document' || receipt.document !== `${receiptName(receipt.entry)}.json`) return false
|
|
95
|
+
const path = join(root, 'hub', 'documents', receipt.document)
|
|
96
|
+
const stat = lstatSync(path)
|
|
97
|
+
return stat.isFile() && stat.size <= HUB_MAX_BYTES && digest(readFileSync(path)) === receipt.entry.delivery.sha256
|
|
98
|
+
}
|
|
99
|
+
if (receipt.entry.delivery.kind === 'registry') return receipt.resourceIds.length > 0 && receipt.resourceIds.every((id) => {
|
|
100
|
+
if (!/^(skills|prompts|rules)\/[a-z0-9-]{1,64}$/.test(id)) return false
|
|
101
|
+
const [kind, name] = id.split('/')
|
|
102
|
+
return lstatSync(join(root, 'registry', kind, kind === 'skills' ? `${name}/SKILL.md` : `${name}.md`)).isFile()
|
|
103
|
+
})
|
|
104
|
+
if (receipt.entry.delivery.kind === 'pi-package') {
|
|
105
|
+
const source = receipt.entry.delivery.source
|
|
106
|
+
const settings = readJsonOr<{ packages?: Array<string | { source: string }> }>(join(this.harness.stateDir, 'accounts', '_shared', 'pi', 'settings.json'), {})
|
|
107
|
+
return settings.packages?.some((item) => (typeof item === 'string' ? item : item.source) === source) ?? false
|
|
108
|
+
}
|
|
109
|
+
} catch { return false }
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async list(refresh = false): Promise<HubOverview> {
|
|
114
|
+
if (!refresh) return this.snapshot()
|
|
115
|
+
this.refresh ??= this.refreshCatalog().finally(() => { this.refresh = null })
|
|
116
|
+
return this.refresh
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async bytes(url: string): Promise<Buffer> {
|
|
120
|
+
const parsed = new URL(url)
|
|
121
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.port || parsed.hostname !== 'raw.githubusercontent.com') throw invalid('Hub downloads must use public raw.githubusercontent.com HTTPS URLs.')
|
|
122
|
+
const response = await this.fetcher(url, { redirect: 'error', credentials: 'omit', signal: AbortSignal.timeout(30_000) })
|
|
123
|
+
if (!response.ok) throw new Error(`The catalog source returned ${response.status}.`)
|
|
124
|
+
if (Number(response.headers.get('content-length') ?? 0) > HUB_MAX_BYTES) throw invalid('The hub download exceeds 16 MiB.')
|
|
125
|
+
if (!response.body) throw invalid('The hub download has no body.')
|
|
126
|
+
const reader = response.body.getReader()
|
|
127
|
+
const chunks: Uint8Array[] = []
|
|
128
|
+
let size = 0
|
|
129
|
+
try {
|
|
130
|
+
for (;;) {
|
|
131
|
+
const { done, value } = await reader.read()
|
|
132
|
+
if (done) break
|
|
133
|
+
size += value.byteLength
|
|
134
|
+
if (size > HUB_MAX_BYTES) throw invalid('The hub download exceeds 16 MiB.')
|
|
135
|
+
chunks.push(value)
|
|
136
|
+
}
|
|
137
|
+
} finally { await reader.cancel().catch(() => undefined) }
|
|
138
|
+
return Buffer.concat(chunks)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async refreshCatalog(): Promise<HubOverview> {
|
|
142
|
+
try {
|
|
143
|
+
const catalog = hubCatalogSchema.parse(JSON.parse(this.decode(await this.bytes(this.catalogUrl))))
|
|
144
|
+
writeJson(join(this.harness.stateDir, 'hub', 'catalog.json'), { catalog, at: now() })
|
|
145
|
+
this.harness.bus.emit({ type: 'hub.changed' })
|
|
146
|
+
return { ...this.snapshot(), origin: 'remote' }
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { ...this.snapshot(), error: error instanceof Error ? error.message : String(error) }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private decode(bytes: Buffer): string {
|
|
153
|
+
const text = bytes.toString('utf8')
|
|
154
|
+
if (!Buffer.from(text).equals(bytes)) throw invalid('Hub JSON must use valid UTF-8.')
|
|
155
|
+
return text
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private find(id: string, version: string): HubEntry {
|
|
159
|
+
const entry = this.snapshot().entries.find((item) => item.id === id && item.version === version)
|
|
160
|
+
if (!entry) throw invalid('That item version is not in this catalog. Refresh the Hub and try again.')
|
|
161
|
+
return entry
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async get(id: string, version: string, preview = false): Promise<HubEntry> {
|
|
165
|
+
const entry = this.find(id, version)
|
|
166
|
+
if (!preview || entry.delivery.kind === 'pi-package' || entry.delivery.bundled !== undefined) return entry
|
|
167
|
+
const bytes = await this.bytes(entry.delivery.url)
|
|
168
|
+
if (digest(bytes) !== entry.delivery.sha256) throw invalid('The downloaded item differs from the reviewed version.')
|
|
169
|
+
return { ...entry, delivery: { ...entry.delivery, bundled: this.decode(bytes) } }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async install(id: string, version: string): Promise<HubReceipt> {
|
|
173
|
+
const root = this.harness.env.root
|
|
174
|
+
const entry = hubEntrySchema.parse(this.find(id, version))
|
|
175
|
+
if (entry.requires.harness && !meetsMinimum(harnessVersion, entry.requires.harness)) throw invalid(`This item requires Harness ${entry.requires.harness} or newer.`)
|
|
176
|
+
const releaseState = acquire(join(this.harness.stateDir, 'hub', 'install.lock'))
|
|
177
|
+
this.installing = true
|
|
178
|
+
let releaseData: (() => void) | null = null
|
|
179
|
+
try {
|
|
180
|
+
releaseData = acquire(join(root, 'hub', 'install.lock'))
|
|
181
|
+
const records = this.receipts()
|
|
182
|
+
const existing = records.find((receipt) => receipt.entry.id === id && receipt.entry.version === version)
|
|
183
|
+
if (existing && this.present(existing)) return existing
|
|
184
|
+
const remaining = records.filter((record) => record !== existing)
|
|
185
|
+
const receipt: HubReceipt = { entry, installedAt: now(), resourceIds: [], document: null }
|
|
186
|
+
const choicesBefore = attachments.load(root)
|
|
187
|
+
if (entry.delivery.kind === 'pi-package') {
|
|
188
|
+
const source = entry.delivery.source
|
|
189
|
+
if (remaining.some((record) => record.entry.id === id && this.present(record))) throw invalid('Another version is already installed. Manage the existing plugin before replacing it.')
|
|
190
|
+
const before = await pluginApi.list(this.harness, {})
|
|
191
|
+
const identity = (value: string): string => value.slice(0, value.lastIndexOf('@'))
|
|
192
|
+
if (before.some((plugin) => plugin.install && identity(plugin.install.source) === identity(source))) throw invalid('This package is already installed. Manage it in Plugins.')
|
|
193
|
+
const after = await packages.install(this.harness, { source })
|
|
194
|
+
receipt.resourceIds = after.filter((plugin) => plugin.install?.source === source && !before.some((old) => old.id === plugin.id)).map((plugin) => plugin.id)
|
|
195
|
+
if (!receipt.resourceIds.length) throw invalid('The package did not add an installable plugin. No hub installation was recorded.')
|
|
196
|
+
} else {
|
|
197
|
+
const delivery = entry.delivery
|
|
198
|
+
const bytes = delivery.bundled === undefined ? await this.bytes(delivery.url) : Buffer.from(delivery.bundled)
|
|
199
|
+
if (digest(bytes) !== delivery.sha256) throw invalid('The downloaded item differs from the reviewed version. Nothing was installed.')
|
|
200
|
+
const text = this.decode(bytes)
|
|
201
|
+
if (delivery.kind === 'document') {
|
|
202
|
+
JSON.parse(text)
|
|
203
|
+
receipt.document = `${receiptName(entry)}.json`
|
|
204
|
+
atomicWrite(join(root, 'hub', 'documents', receipt.document), bytes)
|
|
205
|
+
} else {
|
|
206
|
+
const payload = JSON.parse(text)
|
|
207
|
+
const previous = new Set(registry.list(root).map((item) => item.id))
|
|
208
|
+
const imported = registry.importItems(root, delivery.registryKind, payload.items)
|
|
209
|
+
receipt.resourceIds = imported.items.filter((item) => !previous.has(item.id)).map((item) => item.id)
|
|
210
|
+
const choices = structuredClone(choicesBefore)
|
|
211
|
+
const ids = new Set(receipt.resourceIds)
|
|
212
|
+
for (const scope of [choices.global, ...Object.values(choices.agents), ...Object.values(choices.projects)]) {
|
|
213
|
+
scope.on = scope.on.filter((id) => !ids.has(id))
|
|
214
|
+
scope.off = scope.off.filter((id) => !ids.has(id))
|
|
215
|
+
}
|
|
216
|
+
attachments.save(root, choices)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
try { writeJson(join(root, 'hub', 'installed.json'), [...remaining, receipt]) } catch (error) {
|
|
220
|
+
if (receipt.document) rmSync(join(root, 'hub', 'documents', receipt.document), { force: true })
|
|
221
|
+
if (entry.delivery.kind === 'registry') for (const id of receipt.resourceIds) {
|
|
222
|
+
const [kind, name] = id.split('/')
|
|
223
|
+
rmSync(join(root, 'registry', kind, kind === 'skills' ? name : `${name}.md`), { recursive: true, force: true })
|
|
224
|
+
}
|
|
225
|
+
if (entry.delivery.kind === 'registry') attachments.save(root, choicesBefore)
|
|
226
|
+
if (entry.delivery.kind === 'pi-package') for (const pluginId of receipt.resourceIds) await packages.remove(this.harness, { pluginId })
|
|
227
|
+
throw error
|
|
228
|
+
}
|
|
229
|
+
this.harness.bus.emit({ type: 'hub.changed' })
|
|
230
|
+
this.harness.bus.emit({ type: 'plugins.changed' })
|
|
231
|
+
return receipt
|
|
232
|
+
} finally { this.installing = false; releaseData?.(); releaseState() }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
readDocument(id: string, version: string): { entry: HubEntry; content: string } {
|
|
236
|
+
const receipt = this.receipts().find((item) => item.entry.id === id && item.entry.version === version)
|
|
237
|
+
if (!receipt?.document || receipt.document !== `${receiptName(receipt.entry)}.json`) throw invalid('Install this document before opening it.')
|
|
238
|
+
const path = join(this.harness.env.root, 'hub', 'documents', receipt.document)
|
|
239
|
+
const stat = lstatSync(path)
|
|
240
|
+
if (!stat.isFile() || stat.size > HUB_MAX_BYTES) throw invalid('The saved document is not a bounded regular file.')
|
|
241
|
+
const content = readFileSync(path, 'utf8')
|
|
242
|
+
if (receipt.entry.delivery.kind === 'pi-package' || digest(content) !== receipt.entry.delivery.sha256) throw invalid('The saved document differs from the installed version. Import edited files explicitly instead.')
|
|
243
|
+
return { entry: receipt.entry, content }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
watch(root: string): void {
|
|
247
|
+
this.watcher?.close()
|
|
248
|
+
mkdirSync(join(root, 'hub'), { recursive: true })
|
|
249
|
+
this.watcher = watch(join(root, 'hub'), { persistent: false }, (_event, name) => {
|
|
250
|
+
if (name === 'installed.json') this.harness.bus.emit({ type: 'hub.changed' })
|
|
251
|
+
})
|
|
252
|
+
this.watcher.on('error', () => { this.watcher?.close(); this.watcher = null })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
close(): void { this.watcher?.close(); this.watcher = null }
|
|
256
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const HUB_CATALOG_URL = 'https://raw.githubusercontent.com/tanntnny/xingular-hub/main/catalog.json'
|
|
4
|
+
export const HUB_MAX_BYTES = 16 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
const httpsUrl = z.string().max(2000).url().refine((value) => {
|
|
7
|
+
const url = new URL(value)
|
|
8
|
+
return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash && !url.port
|
|
9
|
+
}, 'Use a public HTTPS URL without credentials, queries, or fragments.')
|
|
10
|
+
const payload = {
|
|
11
|
+
url: httpsUrl,
|
|
12
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
13
|
+
bundled: z.string().max(HUB_MAX_BYTES).optional()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const hubDeliverySchema = z.discriminatedUnion('kind', [
|
|
17
|
+
z.object({ kind: z.literal('registry'), registryKind: z.enum(['skills', 'prompts', 'rules']), ...payload }).strict(),
|
|
18
|
+
z.object({ kind: z.literal('document'), ...payload }).strict(),
|
|
19
|
+
z.object({ kind: z.literal('pi-package'), source: z.string().max(2000).refine((source) =>
|
|
20
|
+
/^npm:(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+@\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(source)
|
|
21
|
+
|| /^git:https:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(?:\.git)?@[a-f0-9]{40}$/.test(source),
|
|
22
|
+
'Packages must use an exact npm version or a GitHub commit.') }).strict()
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export const hubEntrySchema = z.object({
|
|
26
|
+
id: z.string().regex(/^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/),
|
|
27
|
+
title: z.string().min(1).max(100),
|
|
28
|
+
description: z.string().min(1).max(500),
|
|
29
|
+
publisher: z.string().min(1).max(100),
|
|
30
|
+
license: z.string().min(1).max(100),
|
|
31
|
+
version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/),
|
|
32
|
+
category: z.string().regex(/^[a-z][a-z-]{0,40}$/),
|
|
33
|
+
channel: z.enum(['official', 'community']),
|
|
34
|
+
tags: z.array(z.string().max(60)).max(20),
|
|
35
|
+
requires: z.record(z.string(), z.string().regex(/^\d+\.\d+\.\d+$/)),
|
|
36
|
+
sourceUrl: httpsUrl,
|
|
37
|
+
delivery: hubDeliverySchema
|
|
38
|
+
}).strict()
|
|
39
|
+
|
|
40
|
+
export const hubCatalogSchema = z.object({
|
|
41
|
+
schemaVersion: z.literal(1),
|
|
42
|
+
entries: z.array(hubEntrySchema).max(2000)
|
|
43
|
+
}).strict().superRefine((catalog, ctx) => {
|
|
44
|
+
const keys = new Set<string>()
|
|
45
|
+
catalog.entries.forEach((entry, index) => {
|
|
46
|
+
const key = `${entry.id}@${entry.version}`
|
|
47
|
+
if (keys.has(key)) ctx.addIssue({ code: 'custom', path: ['entries', index, 'id'], message: 'Duplicate catalog item version.' })
|
|
48
|
+
keys.add(key)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const hubReceiptSchema = z.object({
|
|
53
|
+
entry: hubEntrySchema,
|
|
54
|
+
installedAt: z.string(),
|
|
55
|
+
resourceIds: z.array(z.string()),
|
|
56
|
+
document: z.string().nullable()
|
|
57
|
+
}).strict()
|
|
58
|
+
|
|
59
|
+
export const hubOverviewSchema = z.object({
|
|
60
|
+
entries: z.array(hubEntrySchema),
|
|
61
|
+
installed: z.array(hubReceiptSchema),
|
|
62
|
+
origin: z.enum(['bundled', 'cached', 'remote']),
|
|
63
|
+
refreshedAt: z.string().nullable(),
|
|
64
|
+
error: z.string().nullable(),
|
|
65
|
+
dataRoot: z.string().nullable(),
|
|
66
|
+
stateDir: z.string()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export type HubEntry = z.infer<typeof hubEntrySchema>
|
|
70
|
+
export type HubCatalog = z.infer<typeof hubCatalogSchema>
|
|
71
|
+
export type HubReceipt = z.infer<typeof hubReceiptSchema>
|
|
72
|
+
export type HubOverview = z.infer<typeof hubOverviewSchema>
|
|
73
|
+
|
|
74
|
+
export function meetsMinimum(actual: string, minimum: string): boolean {
|
|
75
|
+
const left = actual.split(/[.-]/).slice(0, 3).map(Number)
|
|
76
|
+
const right = minimum.split('.').map(Number)
|
|
77
|
+
for (let index = 0; index < 3; index += 1) {
|
|
78
|
+
if (left[index] !== right[index]) return left[index] > right[index]
|
|
79
|
+
}
|
|
80
|
+
return true
|
|
81
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// providers, a tool policy, a secret store, a directory of its own names, and a description of
|
|
8
8
|
// its own parts as plugins. Everything else is reachable by path, e.g. `@xingular/harness/memory/wiki`.
|
|
9
9
|
export { createHarness, Harness, type HarnessOptions, type Recovery } from './harness'
|
|
10
|
+
export { hubCatalogSchema, hubEntrySchema, hubOverviewSchema, hubReceiptSchema, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './hub/schema'
|
|
10
11
|
|
|
11
12
|
export { Bus, log, type Notice } from './core/bus'
|
|
12
13
|
export { EMPTY_DIRECTORY, type Directory, type HarnessEnv } from './core/env'
|
|
@@ -15,7 +16,7 @@ export { findBinary, searchPath } from './core/path'
|
|
|
15
16
|
|
|
16
17
|
export { fileSecretStore, memorySecretStore, secretKey, type SecretStore } from './accounts/secrets'
|
|
17
18
|
export type { Account, AccountsOverview, Pool } from './accounts/store'
|
|
18
|
-
export type { ModelOption } from './accounts/accounts'
|
|
19
|
+
export type { AccountLoginSnapshot, ModelOption } from './accounts/accounts'
|
|
19
20
|
export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
|
|
20
21
|
export type { RunCapabilitySnapshot } from './capabilities/runSnapshot'
|
|
21
22
|
|
|
@@ -46,7 +47,11 @@ export type { Effort } from './sessions/runner'
|
|
|
46
47
|
|
|
47
48
|
export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
|
|
48
49
|
export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
|
|
50
|
+
export { SPAWN_SUBAGENT, SUBAGENT_RELATION, DEFAULT_SUBAGENT_DEPTH, MAX_SUBAGENT_DEPTH, type SubagentOptions } from './tools/spawnSubagent'
|
|
51
|
+
export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
|
|
52
|
+
export type { CurationJob } from './memory/curation'
|
|
49
53
|
export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
|
|
54
|
+
export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, type RegistryToolOptions } from './tools/registryWrite'
|
|
50
55
|
export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
|
|
51
56
|
|
|
52
57
|
export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
|
package/src/memory/api.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { findRun, readEvents } from '../sessions/runStore'
|
|
|
11
11
|
import type { RunEvent } from '../sessions/contracts'
|
|
12
12
|
import { takeChars } from '../core/text'
|
|
13
13
|
import * as registry from '../skills/registry'
|
|
14
|
+
import { enqueueCuration } from './curation'
|
|
14
15
|
import * as blocks from './blocks'
|
|
15
16
|
import { actorOf, blockCaller, projectOf, type CallerContext } from './caller'
|
|
16
17
|
import * as facts from './facts'
|
|
@@ -104,7 +105,7 @@ export function search(env: HarnessEnv, context: CallerContext, params: Params):
|
|
|
104
105
|
// Another agent's blocks never appear; a block of the caller's own does.
|
|
105
106
|
const own = ownBlocksPrefix(env, context)
|
|
106
107
|
const hits = searchEntries(root, query, { kinds, namespace: textParam(params, 'namespace') || null, projectId: context.projectId, limit: limit + 6 })
|
|
107
|
-
.filter((hit) => !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
|
|
108
|
+
.filter((hit) => context.vaultAccess === 'curator' || !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
|
|
108
109
|
.slice(0, limit)
|
|
109
110
|
if (!hits.length) return 'No matching Wiki entries.'
|
|
110
111
|
const wiki = store.wikiDir(root)
|
|
@@ -126,7 +127,7 @@ export function read(env: HarnessEnv, context: CallerContext, params: Params): s
|
|
|
126
127
|
// An agent's own blocks are not readable by other agents or by sessions.
|
|
127
128
|
if (entry.kind === 'block' && entry.block.scope === 'agent') {
|
|
128
129
|
const own = ownBlocksPrefix(env, context)
|
|
129
|
-
if (own === null || `blocks/agents/${entry.block.owner}/` !== own) throw notFound('Wiki entry')
|
|
130
|
+
if (context.vaultAccess !== 'curator' && (own === null || `blocks/agents/${entry.block.owner}/` !== own)) throw notFound('Wiki entry')
|
|
130
131
|
}
|
|
131
132
|
const linking = backlinks(root, entryId(entry))
|
|
132
133
|
const linked = linking.length ? linking.map((link) => `${link.fromId} (${link.type})`).join(', ') : 'none'
|
|
@@ -272,13 +273,26 @@ export function propose(env: HarnessEnv, context: CallerContext, params: Params)
|
|
|
272
273
|
proposals = {
|
|
273
274
|
memories: [{ id: optional(params, 'id'), title, kind: optional(params, 'type') ?? 'fact', body, namespace: optional(params, 'namespace'), confidence: optional(params, 'confidence'), supersedes: optional(params, 'supersedes'), volatile: typeof params?.volatile === 'boolean' ? params.volatile : null, sourceRefs: sourceRefs(params, context), links: links(params) }]
|
|
274
275
|
}
|
|
276
|
+
} else if (kind === 'block') {
|
|
277
|
+
const id = textParam(params, 'id')
|
|
278
|
+
const block = blocks.find(wiki, id)
|
|
279
|
+
if (block.front.readOnly) throw invalid('This block is user-only; only the user can change it.')
|
|
280
|
+
proposals = { blocks: [{ block: id, body, sourceRefs: sourceRefs(params, context) }] }
|
|
275
281
|
} else if (kind === 'skill' || kind === 'rule' || kind === 'prompt') {
|
|
282
|
+
if (context.vaultAccess) {
|
|
283
|
+
const job = enqueueCuration(root, { kind: 'registry', proposal: { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') }, caller: context })
|
|
284
|
+
return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
|
|
285
|
+
}
|
|
276
286
|
const id = parkRegistryProposal(wiki, trigger, { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') })
|
|
277
287
|
return `The ${kind} change (${id}) is waiting in the Wiki Inbox. Skills, prompts, and rules change only after the user accepts them.`
|
|
278
288
|
} else {
|
|
279
289
|
throw invalid('kind must be page, fact, skill, rule, or prompt.')
|
|
280
290
|
}
|
|
281
291
|
// The caller's own run is evidence only for what it can quote from it.
|
|
292
|
+
if (context.vaultAccess) {
|
|
293
|
+
const job = enqueueCuration(root, { kind: 'proposal', namespace: defaultNamespace, proposals: parseProposalSet(proposals), caller: context })
|
|
294
|
+
return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
|
|
295
|
+
}
|
|
282
296
|
const outcome = apply(wiki, defaultNamespace, trigger, parseProposalSet(proposals), captureHashes(wiki), { runText: context.runId ? evidenceText(root, context.runId) : null })
|
|
283
297
|
let text = outcome.notes.join('\n')
|
|
284
298
|
if (outcome.operation) {
|
package/src/memory/blocks.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface Block {
|
|
|
52
52
|
export type Caller =
|
|
53
53
|
| { kind: 'user' }
|
|
54
54
|
/** An agent with blocks of its own, and the name recorded on its revisions. */
|
|
55
|
-
| { kind: 'agent'; id: string; name: string }
|
|
55
|
+
| { kind: 'agent'; id: string; name: string; vaultAccess?: 'self' | 'propose' | 'curator' }
|
|
56
56
|
/** A session without blocks of its own, and its project, if any. */
|
|
57
57
|
| { kind: 'session'; projectSlug: string | null }
|
|
58
58
|
|
|
@@ -192,6 +192,8 @@ export function editRefusal(caller: Caller, block: Block): string | null {
|
|
|
192
192
|
const label = block.front.label
|
|
193
193
|
if (caller.kind === 'user') return null
|
|
194
194
|
if (block.front.readOnly) return `The ${label} block is read-only; only the user can change it.`
|
|
195
|
+
if (caller.kind === 'agent' && caller.vaultAccess === 'propose') return 'Memory changes are proposals in this session. Submit the block change with wiki_propose; the next memory merge applies it.'
|
|
196
|
+
if (caller.kind === 'agent' && caller.vaultAccess === 'self' && (block.scope !== 'agent' || block.owner !== caller.id || !['persona', 'notes'].includes(label))) return 'This field is outside your writable memory. Submit a block change with wiki_propose for the curator to review.'
|
|
195
197
|
if (caller.kind === 'agent') {
|
|
196
198
|
if (block.scope === 'agent') return block.owner === caller.id ? null : 'That block belongs to another agent.'
|
|
197
199
|
if (block.scope === 'shared') return !block.front.agents.length || block.front.agents.includes(caller.id) ? null : `The ${label} block is not attached to you.`
|