@xingular/harness 2.1.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/NOTICE.md +22 -0
- package/README.md +67 -36
- package/dist/cli.js +6023 -6621
- package/dist/terminal-pi.js +19 -24
- package/docs/terminal-tui.md +27 -7
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +6 -1
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/registry.ts +5 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +45 -6
- package/src/sessions/sessions.ts +40 -17
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +21 -39
- package/src/terminal/terminals.ts +20 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +10 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +1 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
|
@@ -107,11 +107,8 @@ describe('a pi terminal session', () => {
|
|
|
107
107
|
expect(`${result.stdout}${result.stderr}`).toMatch(/usage|options|pi coding agent/i)
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
it('passes what the harness found as paths, the
|
|
110
|
+
it('passes what the harness found as paths, the harness tree\'s skills and trusted plugins with them, and its own extension last', async () => {
|
|
111
111
|
const root = rootWithProject()
|
|
112
|
-
writeFile(join(root, 'registry/skills/release/SKILL.md'), '# Release\n')
|
|
113
|
-
writeFile(join(root, 'registry/prompts/review.md'), 'Review this.\n')
|
|
114
|
-
writeFile(join(root, 'config/registry-enabled.json'), JSON.stringify({ p1: ['skills/release', 'prompts/review'] }))
|
|
115
112
|
writeFile(join(root, '.pi/extensions/local.ts'), 'export default function () {}\n')
|
|
116
113
|
const item = (kind: Found['kind'], path: string, origin: Found['origin'], enabled = true): Found => ({ kind, path, origin, project: origin === 'project', packageRoot: null, source: null, enabled })
|
|
117
114
|
const found = [
|
|
@@ -126,12 +123,17 @@ describe('a pi terminal session', () => {
|
|
|
126
123
|
const harness = createHarness({ dataRoot: root, stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' }, resolver: async () => found })
|
|
127
124
|
open.push(harness)
|
|
128
125
|
const account = harness.accounts.create('Main', 'apiKey').accounts[0]
|
|
129
|
-
const
|
|
126
|
+
const tree = { 'systemprompt.md': 'Ship releases.\n', 'guardrails.md': '- When cutting a release, load the `release` skill.\n', 'memory.md': '', 'tool_guidance.md': '', 'skills/release/SKILL.md': '---\nname: release\ndescription: cutting a release\n---\nTag it.\n', 'plugins/guard.ts': 'export default function (pi) {}\n' }
|
|
127
|
+
const { commit, versionPath, head } = await import('../tree/store')
|
|
128
|
+
commit(root, 'term-agent', tree, { kind: 'seed' })
|
|
129
|
+
harness.trust.grant(tree['plugins/guard.ts'], 'plugins/guard.ts', 'term-agent')
|
|
130
|
+
const version = versionPath(root, 'term-agent', head(root, 'term-agent')!.fingerprint)
|
|
131
|
+
const prepare = async () => (await harness.terminals.prepare({ sessionId: 'term-1', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder: join(root, 'sessions', 'p1', 'term-1'), harness: { scope: 'term-agent' } })).args.slice(2)
|
|
130
132
|
|
|
131
133
|
const loaded = [
|
|
132
|
-
'--extension', '/shared/extensions/mine.ts', '--extension', 'builtin:mcp',
|
|
133
|
-
'--skill', join(
|
|
134
|
-
'--prompt-template',
|
|
134
|
+
'--extension', '/shared/extensions/mine.ts', '--extension', 'builtin:mcp', '--extension', join(version, 'plugins/guard.ts'),
|
|
135
|
+
'--skill', join(version, 'skills'), '--skill', '/shared/skills/alpha/SKILL.md',
|
|
136
|
+
'--prompt-template', '/shared/prompts/p.md',
|
|
135
137
|
'--theme', '/shared/themes/dark.json',
|
|
136
138
|
'--extension', '/opt/harness/terminal-pi.js', '--session-dir', join(root, 'sessions', 'p1', 'term-1', 'pi')
|
|
137
139
|
]
|
|
@@ -191,24 +193,6 @@ describe('a pi terminal session', () => {
|
|
|
191
193
|
expect(loadRun(root, 'reserved').status).toBe('interrupted')
|
|
192
194
|
})
|
|
193
195
|
|
|
194
|
-
it('sends a memory block that changed since the session started along with the next instruction', async () => {
|
|
195
|
-
const { tell, harness, connection } = await setup()
|
|
196
|
-
await tell('ready', {})
|
|
197
|
-
await socketCall(connection, 'memory.append', { block: 'project', text: 'Prefers pnpm.' })
|
|
198
|
-
const first = harness.terminals.send({ sessionId: 'term-1', text: 'Continue' })
|
|
199
|
-
let delivered = ''
|
|
200
|
-
await vi.waitFor(async () => {
|
|
201
|
-
const { command } = await tell('poll')
|
|
202
|
-
expect(command?.type).toBe('send')
|
|
203
|
-
delivered = command.text
|
|
204
|
-
})
|
|
205
|
-
await tell('begin', { text: delivered })
|
|
206
|
-
await first
|
|
207
|
-
expect(delivered.startsWith('[Memory updated since this session')).toBe(true)
|
|
208
|
-
expect(delivered).toContain('Prefers pnpm.')
|
|
209
|
-
expect(delivered.endsWith('\n\nContinue')).toBe(true)
|
|
210
|
-
})
|
|
211
|
-
|
|
212
196
|
it('will not send over a draft the person is typing, and fails a delivery the native session never acknowledges', async () => {
|
|
213
197
|
const { tell, harness, root } = await setup()
|
|
214
198
|
await tell('ready', {})
|
|
@@ -389,11 +373,11 @@ describe('a pi terminal session', () => {
|
|
|
389
373
|
expect(harness.terminals.state('term-1').ready).toBe(false)
|
|
390
374
|
})
|
|
391
375
|
|
|
392
|
-
it('serves
|
|
376
|
+
it('serves the Wiki, skills and connected apps to the session over the socket, until the terminal ends', async () => {
|
|
393
377
|
const { connection, harness, tell } = await setup()
|
|
394
378
|
await tell('ready', {})
|
|
395
|
-
expect(await socketCall<string>(connection, '
|
|
396
|
-
expect(await socketCall<string>(connection, 'wiki.list')).toContain('
|
|
379
|
+
expect(await socketCall<string>(connection, 'skill.load')).toBe('You have no skills.')
|
|
380
|
+
expect(await socketCall<string>(connection, 'wiki.list')).toContain('home')
|
|
397
381
|
const listed = await socketCall<{ tools: { name: string; parameters: { type: string } }[] }>(connection, 'tools.list')
|
|
398
382
|
expect(listed.tools.map((tool) => tool.name)).toEqual(['mcp_list_tools', 'mcp_call_tool'])
|
|
399
383
|
expect(listed.tools[0].parameters.type).toBe('object')
|
|
@@ -429,19 +413,17 @@ describe('the session socket', () => {
|
|
|
429
413
|
})
|
|
430
414
|
|
|
431
415
|
describe('xagent-wiki command', () => {
|
|
432
|
-
it('maps commands onto
|
|
433
|
-
expect(parseCli(['search', 'deploy', 'steps', '--
|
|
434
|
-
expect(parseCli(['read', '
|
|
416
|
+
it('maps commands onto Wiki, skill and history calls', () => {
|
|
417
|
+
expect(parseCli(['search', 'deploy', 'steps', '--project', 'app'])).toEqual({ kind: 'call', call: 'wiki.search', params: { query: 'deploy steps', project: 'app' } })
|
|
418
|
+
expect(parseCli(['read', 'projects/app/arch'])).toEqual({ kind: 'call', call: 'wiki.read', params: { path: 'projects/app/arch' } })
|
|
435
419
|
expect(parseCli(['list'])).toEqual({ kind: 'call', call: 'wiki.list', params: {} })
|
|
436
|
-
expect(parseCli(['
|
|
437
|
-
expect(parseCli(['
|
|
420
|
+
expect(parseCli(['list', 'library'])).toEqual({ kind: 'call', call: 'wiki.list', params: { project: 'library' } })
|
|
421
|
+
expect(parseCli(['write', 'library/notes', 'Use', 'pnpm.'])).toEqual({ kind: 'call', call: 'wiki.write', params: { path: 'library/notes', text: 'Use pnpm.' } })
|
|
422
|
+
expect(parseCli(['write', 'library/notes', '-'])).toEqual({ kind: 'stdin-write', path: 'library/notes' })
|
|
423
|
+
expect(parseCli(['skill', 'review'])).toEqual({ kind: 'call', call: 'skill.load', params: { name: 'review' } })
|
|
438
424
|
expect(parseCli(['recall', 'the', 'outage', '--scope', 'all'])).toEqual({ kind: 'call', call: 'recall.search', params: { query: 'the outage', scope: 'all' } })
|
|
439
|
-
expect(parseCli(['propose', '{"kind":"memory","title":"t","body":"b"}'])).toMatchObject({ kind: 'call', call: 'wiki.propose', params: { kind: 'memory' } })
|
|
440
|
-
expect(parseCli(['propose', '-'])).toEqual({ kind: 'stdin-propose' })
|
|
441
|
-
expect(parseCli(['block', 'append', 'project', 'Uses', 'pnpm.'])).toEqual({ kind: 'call', call: 'memory.append', params: { block: 'project', text: 'Uses pnpm.' } })
|
|
442
|
-
expect(parseCli(['block', 'replace', 'project', '--old', 'npm', '--new', 'pnpm'])).toEqual({ kind: 'call', call: 'memory.replace', params: { block: 'project', old: 'npm', new: 'pnpm' } })
|
|
443
425
|
expect(parseCli(['search'])).toEqual({ kind: 'help' })
|
|
444
|
-
expect(parseCli(['
|
|
426
|
+
expect(parseCli(['block', 'append', 'x', 'y'])).toEqual({ kind: 'help' })
|
|
445
427
|
})
|
|
446
428
|
})
|
|
447
429
|
|
|
@@ -24,19 +24,20 @@ import { HarnessError, invalid } from '../core/errors'
|
|
|
24
24
|
import { newId, now } from '../core/fs'
|
|
25
25
|
import { prependSearchPath, searchPath } from '../core/path'
|
|
26
26
|
import type { BrokerSession, ConnectedApps } from '../mcp/service'
|
|
27
|
-
import type { CallerContext } from '../
|
|
28
|
-
import { dispatch } from '../
|
|
27
|
+
import type { CallerContext } from '../core/caller'
|
|
28
|
+
import { dispatch } from '../tools/dispatch'
|
|
29
|
+
import { materialise, type HarnessChoice } from '../tree/materialise'
|
|
30
|
+
import type { TreeTrust } from '../tree/trust'
|
|
29
31
|
import type { AttachParams, PluginScope } from '../plugins/api'
|
|
30
32
|
import type { Loading, Plugins } from '../plugins/plugins'
|
|
31
33
|
import type { Plugin } from '../plugins/types'
|
|
32
34
|
import type { Hub } from '../hub/hub'
|
|
33
|
-
import { compile, copyToRun,
|
|
35
|
+
import { compile, copyToRun, record, render, saveForSession, type Launch } from '../prompt/compile'
|
|
34
36
|
import type { RunRecord } from '../sessions/contracts'
|
|
35
37
|
import { isActiveStatus } from '../sessions/contracts'
|
|
36
38
|
import type { ToolPolicy } from '../sessions/policy'
|
|
37
39
|
import type { ActiveRun, RunRegistry } from '../sessions/registry'
|
|
38
40
|
import { findRun, loadRun, saveRun, updateRun } from '../sessions/runStore'
|
|
39
|
-
import { resources } from '../skills/registry'
|
|
40
41
|
import type { ToolSpec } from '../tools/types'
|
|
41
42
|
import { APPS_COMMAND, WIKI_COMMAND } from './cli'
|
|
42
43
|
import { SOCKET_ENV, TOKEN_ENV } from './client'
|
|
@@ -87,6 +88,8 @@ export interface TerminalPrepareInput {
|
|
|
87
88
|
readOnly?: boolean
|
|
88
89
|
/** What the application adds to the instructions, and which of its tools it describes. */
|
|
89
90
|
launch?: Launch
|
|
91
|
+
/** The harness tree the session runs; its version is pinned for the session. */
|
|
92
|
+
harness?: HarnessChoice | null
|
|
90
93
|
/** Give the session the connected-apps broker. Defaults to yes. */
|
|
91
94
|
connections?: boolean
|
|
92
95
|
/** Fields copied onto each run this session records. */
|
|
@@ -112,6 +115,8 @@ interface Live {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
export interface TerminalsOptions {
|
|
118
|
+
/** Which plugin code in harness trees a person trusted. */
|
|
119
|
+
trust?: TreeTrust | null
|
|
115
120
|
subagents?: Subagents
|
|
116
121
|
resolveCapabilities?: (caller: CallerContext, tools: readonly string[], cwd: string) => Promise<CapabilitySet>
|
|
117
122
|
env: HarnessEnv
|
|
@@ -144,6 +149,8 @@ const READ_ONLY_TOOLS = new Set(['read', 'grep', 'find', 'ls'])
|
|
|
144
149
|
|
|
145
150
|
export class Terminals {
|
|
146
151
|
private readonly sessions = new Map<string, Live>()
|
|
152
|
+
/** The harness tree each live session runs, for loading its skills. */
|
|
153
|
+
private readonly treeDirs = new Map<string, string>()
|
|
147
154
|
private readonly socket: SessionSocket
|
|
148
155
|
private readonly assets: TerminalAssets
|
|
149
156
|
/** A session's `/accounts` switch, taken once its Pi process has ended. */
|
|
@@ -167,7 +174,7 @@ export class Terminals {
|
|
|
167
174
|
}
|
|
168
175
|
})
|
|
169
176
|
this.socket = new SessionSocket(socketPath(options.stateDir), {
|
|
170
|
-
memory: (caller, call, params) => dispatch(options.env, caller, call, params),
|
|
177
|
+
memory: (caller, call, params) => dispatch(options.env, caller, call, params, { treeDir: (caller.sessionId && this.treeDirs.get(caller.sessionId)) || null }),
|
|
171
178
|
apps: (session, call, params) => this.appsCall(session, call, params),
|
|
172
179
|
plugins: (grant, call, params, control) => this.pluginsCall(grant.sessionId, call, params, control),
|
|
173
180
|
accounts: (grant, call, params, control) => this.accountsCall(grant.sessionId, call, params, control)
|
|
@@ -513,8 +520,10 @@ export class Terminals {
|
|
|
513
520
|
const broker = input.connections === false ? null : apps.grant({ sessionId, runId: null, caller, readOnly: !!input.readOnly, depth: 0 })
|
|
514
521
|
// The session keeps these instructions; each of its runs records a copy, and a memory
|
|
515
522
|
// block that changes later is sent along as a note.
|
|
516
|
-
const
|
|
517
|
-
|
|
523
|
+
const tree = input.harness ? materialise(root, input.harness, { trust: this.options.trust ?? null }) : null
|
|
524
|
+
if (tree) this.treeDirs.set(sessionId, tree.dir)
|
|
525
|
+
const pack = compile(env, caller, input.launch ?? {}, tree)
|
|
526
|
+
record(root, pack)
|
|
518
527
|
mkdirSync(this.historyDir(input), { recursive: true })
|
|
519
528
|
saveForSession(input.sessionFolder, pack)
|
|
520
529
|
|
|
@@ -584,10 +593,9 @@ export class Terminals {
|
|
|
584
593
|
if (loading) {
|
|
585
594
|
// Pi's own discovery is off: the terminal loads what the harness found and nothing else.
|
|
586
595
|
args.push('--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes')
|
|
587
|
-
const
|
|
588
|
-
for (const path of [...
|
|
589
|
-
for (const path of
|
|
590
|
-
for (const path of [...enabled.promptPaths, ...loading.promptPaths]) args.push('--prompt-template', path)
|
|
596
|
+
for (const path of [...loading.extensionPaths, ...loading.builtins, ...(tree?.extensionPaths ?? [])]) args.push('--extension', path)
|
|
597
|
+
for (const path of [...(tree?.skillPaths ?? []), ...loading.skillPaths]) args.push('--skill', path)
|
|
598
|
+
for (const path of loading.promptPaths) args.push('--prompt-template', path)
|
|
591
599
|
for (const path of loading.themePaths) args.push('--theme', path)
|
|
592
600
|
}
|
|
593
601
|
// Last, so its gate sees a tool call as every other extension left it.
|
|
@@ -626,10 +634,9 @@ export class Terminals {
|
|
|
626
634
|
const runId = input.runId || newId()
|
|
627
635
|
if (findRun(root, runId)) throw invalid('This run already exists.')
|
|
628
636
|
saveRun(root, this.runRecord(live, runId, input.text))
|
|
629
|
-
const note = memoryDelta(env, live.input.sessionFolder, { ...live.caller, runId })
|
|
630
637
|
copyToRun(root, live.input.sessionFolder, runId)
|
|
631
638
|
try {
|
|
632
|
-
await live.controller.send(runId,
|
|
639
|
+
await live.controller.send(runId, input.text)
|
|
633
640
|
} catch (error) {
|
|
634
641
|
updateRun(root, runId, (run) => {
|
|
635
642
|
if (!isActiveStatus(run.status)) return
|
|
@@ -3,7 +3,17 @@ import { stripTerminalSequences } from '@earendil-works/pi-tui'
|
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
5
5
|
vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
|
|
6
|
+
vi.mock('./piAdapter', async importOriginal => ({
|
|
7
|
+
...await importOriginal<typeof import('./piAdapter')>(),
|
|
8
|
+
loadPiAdapter: vi.fn()
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('./clipboardFiles', async importOriginal => ({
|
|
11
|
+
...await importOriginal<typeof import('./clipboardFiles')>(),
|
|
12
|
+
stageClipboardFiles: vi.fn()
|
|
13
|
+
}))
|
|
6
14
|
import { controlCall } from '../control'
|
|
15
|
+
import { loadPiAdapter, type PiAdapter } from './piAdapter'
|
|
16
|
+
import { stageClipboardFiles } from './clipboardFiles'
|
|
7
17
|
import { TerminalApplication } from './application'
|
|
8
18
|
import { createTerminalCommands } from './commands'
|
|
9
19
|
import type { TerminalDecision } from './runtime'
|
|
@@ -62,6 +72,54 @@ function setup() {
|
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
describe('the owned terminal application', () => {
|
|
75
|
+
it('pastes staged Finder image paths and removes their temporary files on shutdown', async () => {
|
|
76
|
+
const { application, app, ui } = setup()
|
|
77
|
+
const paths = ['/Photos/portrait.heic', '/work/notes.md']
|
|
78
|
+
const pasteToEditor = vi.fn()
|
|
79
|
+
const shutdownOrder: string[] = []
|
|
80
|
+
const cleanup = vi.fn(async () => { shutdownOrder.push('clipboard files removed') })
|
|
81
|
+
const adapter = {
|
|
82
|
+
builtins: [], setTheme: vi.fn(), withBuiltInRenderers: vi.fn(),
|
|
83
|
+
readClipboardText: vi.fn(async () => null), readClipboardFilePaths: vi.fn(async () => paths),
|
|
84
|
+
readClipboardImage: vi.fn(async () => null), extensionForImageMimeType: vi.fn(() => null)
|
|
85
|
+
}
|
|
86
|
+
Object.assign(ui, { pasteToEditor })
|
|
87
|
+
vi.mocked(loadPiAdapter).mockResolvedValue(adapter as unknown as PiAdapter)
|
|
88
|
+
vi.mocked(stageClipboardFiles).mockResolvedValue({ paths: ['/tmp/prepared.png', paths[1]!], cleanup })
|
|
89
|
+
app.runtime.dispose.mockImplementation(async () => { shutdownOrder.push('runtime disposed') })
|
|
90
|
+
|
|
91
|
+
await (application as unknown as { pasteClipboard(): Promise<void> }).pasteClipboard()
|
|
92
|
+
|
|
93
|
+
expect(stageClipboardFiles).toHaveBeenCalledWith(paths)
|
|
94
|
+
expect(adapter.readClipboardImage).not.toHaveBeenCalled()
|
|
95
|
+
expect(pasteToEditor).toHaveBeenCalledWith(`/tmp/prepared.png\n${paths[1]}`)
|
|
96
|
+
await application.shutdown()
|
|
97
|
+
expect(cleanup).toHaveBeenCalledOnce()
|
|
98
|
+
expect(shutdownOrder).toEqual(['runtime disposed', 'clipboard files removed'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('leaves the prompt unchanged and reports an error when clipboard staging fails', async () => {
|
|
102
|
+
const { application, app, ui } = setup()
|
|
103
|
+
const message = 'Could not prepare HEIC image "portrait.heic" for the terminal.'
|
|
104
|
+
const adapter = {
|
|
105
|
+
builtins: [], setTheme: vi.fn(), withBuiltInRenderers: vi.fn(),
|
|
106
|
+
readClipboardText: vi.fn(async () => null), readClipboardFilePaths: vi.fn(async () => ['/Photos/portrait.heic']),
|
|
107
|
+
readClipboardImage: vi.fn(async () => null), extensionForImageMimeType: vi.fn(() => null)
|
|
108
|
+
}
|
|
109
|
+
const pasteToEditor = vi.fn()
|
|
110
|
+
const notice = vi.spyOn(app.transcript, 'notice')
|
|
111
|
+
Object.assign(ui, { pasteToEditor })
|
|
112
|
+
vi.mocked(loadPiAdapter).mockResolvedValue(adapter as unknown as PiAdapter)
|
|
113
|
+
vi.mocked(stageClipboardFiles).mockRejectedValueOnce(new Error(message))
|
|
114
|
+
const pasteClipboard = (application as unknown as { pasteClipboard(): Promise<void> }).pasteClipboard.bind(application)
|
|
115
|
+
;(application as unknown as { attempt(action: () => Promise<void>): void }).attempt(() => pasteClipboard())
|
|
116
|
+
|
|
117
|
+
await vi.waitFor(() => expect(notice).toHaveBeenCalledWith(message, 'error'))
|
|
118
|
+
|
|
119
|
+
expect(pasteToEditor).not.toHaveBeenCalled()
|
|
120
|
+
await application.shutdown()
|
|
121
|
+
})
|
|
122
|
+
|
|
65
123
|
it('shows a stable animated verb, then keeps completion status until the next turn', async () => {
|
|
66
124
|
const { application, app } = setup()
|
|
67
125
|
let now = 1000
|
|
@@ -24,6 +24,7 @@ import { createTerminalUi, type TerminalUi } from './ui'
|
|
|
24
24
|
import { chooseWorkingVerb, createCompletionStatus, getWorkingSpinnerFrames, SPINNER_INTERVAL_MS, spinnerFrameAt } from './workingStatus'
|
|
25
25
|
import { HarnessWelcome } from './welcome'
|
|
26
26
|
import { answerTerminalQuestion } from './questions'
|
|
27
|
+
import { stageClipboardFiles } from './clipboardFiles'
|
|
27
28
|
import type { QuestionAnswer } from '../../sessions/registry'
|
|
28
29
|
import { readHarnessLogSnapshot, type HarnessLogChunk, type HarnessLogInfo } from '../logs'
|
|
29
30
|
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
@@ -114,7 +115,7 @@ export class TerminalApplication {
|
|
|
114
115
|
private failure?: Error
|
|
115
116
|
private finish!: () => void
|
|
116
117
|
private done = new Promise<void>(resolve => { this.finish = resolve })
|
|
117
|
-
private cleanup: (() => void)[] = []
|
|
118
|
+
private cleanup: (() => void | Promise<void>)[] = []
|
|
118
119
|
private finalCleanup: (() => void)[] = []
|
|
119
120
|
private draftSync = Promise.resolve()
|
|
120
121
|
private lastInput = ''
|
|
@@ -648,7 +649,9 @@ export class TerminalApplication {
|
|
|
648
649
|
const files = await adapter.readClipboardFilePaths()
|
|
649
650
|
if (files?.length) {
|
|
650
651
|
if (files.some(file => /[\x00-\x1f\x7f]/.test(file))) throw new Error('Clipboard file paths contain control characters.')
|
|
651
|
-
|
|
652
|
+
const staged = await stageClipboardFiles(files)
|
|
653
|
+
if (staged.cleanup) this.cleanup.push(staged.cleanup)
|
|
654
|
+
this.ui.pasteToEditor(staged.paths.join('\n'))
|
|
652
655
|
return
|
|
653
656
|
}
|
|
654
657
|
const image = await adapter.readClipboardImage()
|
|
@@ -656,7 +659,7 @@ export class TerminalApplication {
|
|
|
656
659
|
const directory = await mkdtemp(join(tmpdir(), 'harness-clipboard-'))
|
|
657
660
|
const file = join(directory, `${randomUUID()}.${adapter.extensionForImageMimeType(image.mimeType) ?? 'png'}`)
|
|
658
661
|
await writeFile(file, image.bytes, { mode: 0o600 })
|
|
659
|
-
this.cleanup.push(() =>
|
|
662
|
+
this.cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
|
660
663
|
this.ui.pasteToEditor(file)
|
|
661
664
|
return
|
|
662
665
|
}
|
|
@@ -689,12 +692,15 @@ export class TerminalApplication {
|
|
|
689
692
|
this.stopped = true
|
|
690
693
|
protect(() => this.pendingAbort?.abort())
|
|
691
694
|
protect(() => this.ui?.dispose())
|
|
692
|
-
for (const cleanup of this.cleanup.splice(0)) protect(cleanup)
|
|
693
695
|
// Captured while the runtime and its components still exist; written only after the alternate screen is left.
|
|
694
696
|
const scrollback = this.exitScrollback()
|
|
695
697
|
try { await this.runtime?.dispose() }
|
|
696
698
|
catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
|
|
697
699
|
finally {
|
|
700
|
+
for (const cleanup of this.cleanup.splice(0)) {
|
|
701
|
+
try { await cleanup() }
|
|
702
|
+
catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
|
|
703
|
+
}
|
|
698
704
|
await this.terminal.drainInput(200, 30).catch(() => {})
|
|
699
705
|
protect(() => this.tui.stop())
|
|
700
706
|
if (scrollback) protect(() => writeScrollback(this.terminal, scrollback))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { stageClipboardFiles } from './clipboardFiles'
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile)
|
|
10
|
+
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
11
|
+
|
|
12
|
+
async function createRoot() {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'harness-heic-test-'))
|
|
14
|
+
return root
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('clipboard image files', () => {
|
|
18
|
+
it('converts HEIC and HEIF paths to private PNGs while preserving order and other paths', async () => {
|
|
19
|
+
const root = await createRoot()
|
|
20
|
+
const convert = vi.fn(async (_source: string, output: string) => { await writeFile(output, pngSignature) })
|
|
21
|
+
const staged = await stageClipboardFiles([
|
|
22
|
+
'/Photos/first photo.HEIC',
|
|
23
|
+
'/work/notes.md',
|
|
24
|
+
'/Photos/second.heif'
|
|
25
|
+
], { platform: 'darwin', tempRoot: root, convert })
|
|
26
|
+
|
|
27
|
+
expect(staged.paths).toHaveLength(3)
|
|
28
|
+
expect(staged.paths[0]).toMatch(/\.png$/)
|
|
29
|
+
expect(staged.paths[1]).toBe('/work/notes.md')
|
|
30
|
+
expect(staged.paths[2]).toMatch(/\.png$/)
|
|
31
|
+
expect(convert.mock.calls.map(([source]) => source)).toEqual(['/Photos/first photo.HEIC', '/Photos/second.heif'])
|
|
32
|
+
expect(await readFile(staged.paths[0])).toEqual(pngSignature)
|
|
33
|
+
expect(await readFile(staged.paths[2])).toEqual(pngSignature)
|
|
34
|
+
|
|
35
|
+
await staged.cleanup?.()
|
|
36
|
+
await expect(readdir(dirname(staged.paths[0]))).rejects.toThrow()
|
|
37
|
+
await rm(root, { recursive: true, force: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('leaves clipboard paths unchanged on non-macOS platforms', async () => {
|
|
41
|
+
const convert = vi.fn(async () => {})
|
|
42
|
+
const paths = ['/Photos/portrait.heic', '/work/notes.md']
|
|
43
|
+
|
|
44
|
+
const staged = await stageClipboardFiles(paths, { platform: 'linux', convert })
|
|
45
|
+
|
|
46
|
+
expect(staged.paths).toEqual(paths)
|
|
47
|
+
expect(staged.cleanup).toBeUndefined()
|
|
48
|
+
expect(convert).not.toHaveBeenCalled()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('cleans staged files and rejects the whole paste if conversion fails', async () => {
|
|
52
|
+
const root = await createRoot()
|
|
53
|
+
const convert = vi.fn(async (source: string, output: string) => {
|
|
54
|
+
if (source.endsWith('broken.heic')) throw new Error('decode failed')
|
|
55
|
+
await writeFile(output, pngSignature)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await expect(stageClipboardFiles([
|
|
59
|
+
'/Photos/valid.heic',
|
|
60
|
+
'/Photos/broken.heic'
|
|
61
|
+
], { platform: 'darwin', tempRoot: root, convert })).rejects.toThrow('Could not prepare HEIC image "broken.heic" for the terminal.')
|
|
62
|
+
expect(await readdir(root)).toEqual([])
|
|
63
|
+
await rm(root, { recursive: true, force: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('rejects an empty or non-PNG conversion result', async () => {
|
|
67
|
+
const root = await createRoot()
|
|
68
|
+
await expect(stageClipboardFiles(['/Photos/invalid.heic'], {
|
|
69
|
+
platform: 'darwin',
|
|
70
|
+
tempRoot: root,
|
|
71
|
+
convert: async (_source, output) => { await writeFile(output, 'not a PNG') }
|
|
72
|
+
})).rejects.toThrow('Could not prepare HEIC image "invalid.heic" for the terminal.')
|
|
73
|
+
expect(await readdir(root)).toEqual([])
|
|
74
|
+
await rm(root, { recursive: true, force: true })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it.skipIf(process.platform !== 'darwin')('converts a real HEIC file with macOS sips without changing the original', async () => {
|
|
78
|
+
const root = await createRoot()
|
|
79
|
+
const sourcePng = join(root, 'pixel.png')
|
|
80
|
+
const sourceHeic = join(root, 'portrait.heic')
|
|
81
|
+
await writeFile(sourcePng, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
|
82
|
+
await exec('/usr/bin/sips', ['-s', 'format', 'heic', sourcePng, '--out', sourceHeic])
|
|
83
|
+
const original = await readFile(sourceHeic)
|
|
84
|
+
|
|
85
|
+
const staged = await stageClipboardFiles([sourceHeic], { tempRoot: root })
|
|
86
|
+
expect(staged.paths[0]).not.toBe(sourceHeic)
|
|
87
|
+
expect((await readFile(staged.paths[0]!)).subarray(0, 8)).toEqual(pngSignature)
|
|
88
|
+
expect(await readFile(sourceHeic)).toEqual(original)
|
|
89
|
+
|
|
90
|
+
await staged.cleanup?.()
|
|
91
|
+
expect((await readdir(root)).sort()).toEqual(['pixel.png', 'portrait.heic'])
|
|
92
|
+
await rm(root, { recursive: true, force: true })
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { chmod, mkdtemp, open, rm, stat } from 'node:fs/promises'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { basename, join } from 'node:path'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile)
|
|
9
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
10
|
+
|
|
11
|
+
export interface ClipboardFileOptions {
|
|
12
|
+
platform?: NodeJS.Platform
|
|
13
|
+
tempRoot?: string
|
|
14
|
+
convert?: (source: string, output: string) => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StagedClipboardFiles {
|
|
18
|
+
paths: string[]
|
|
19
|
+
cleanup?: () => Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isHeicPath(path: string): boolean {
|
|
23
|
+
return /\.hei[cf]$/i.test(path)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function convertWithSips(source: string, output: string): Promise<void> {
|
|
27
|
+
await exec('/usr/bin/sips', ['-s', 'format', 'png', source, '--out', output], {
|
|
28
|
+
timeout: 30_000,
|
|
29
|
+
maxBuffer: 1024 * 1024
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function assertPng(path: string): Promise<void> {
|
|
34
|
+
const info = await stat(path)
|
|
35
|
+
if (!info.isFile() || info.size < PNG_SIGNATURE.length) throw new Error('Conversion did not create an image.')
|
|
36
|
+
|
|
37
|
+
const file = await open(path, 'r')
|
|
38
|
+
try {
|
|
39
|
+
const signature = Buffer.alloc(PNG_SIGNATURE.length)
|
|
40
|
+
const { bytesRead } = await file.read(signature, 0, signature.length, 0)
|
|
41
|
+
if (bytesRead !== PNG_SIGNATURE.length || !signature.equals(PNG_SIGNATURE)) throw new Error('Conversion did not create a PNG image.')
|
|
42
|
+
} finally {
|
|
43
|
+
await file.close()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convert Finder-copied HEIC/HEIF paths to images that Pi's terminal reader can consume. */
|
|
48
|
+
export async function stageClipboardFiles(paths: string[], options: ClipboardFileOptions = {}): Promise<StagedClipboardFiles> {
|
|
49
|
+
const platform = options.platform ?? process.platform
|
|
50
|
+
if (platform !== 'darwin' || !paths.some(isHeicPath)) return { paths }
|
|
51
|
+
|
|
52
|
+
let directory: string | undefined
|
|
53
|
+
try {
|
|
54
|
+
directory = await mkdtemp(join(options.tempRoot ?? tmpdir(), 'harness-clipboard-'))
|
|
55
|
+
const stagedPaths: string[] = []
|
|
56
|
+
for (const [index, path] of paths.entries()) {
|
|
57
|
+
if (!isHeicPath(path)) {
|
|
58
|
+
stagedPaths.push(path)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const output = join(directory, `${index}-${randomUUID()}.png`)
|
|
63
|
+
try {
|
|
64
|
+
await (options.convert ?? convertWithSips)(path, output)
|
|
65
|
+
await chmod(output, 0o600)
|
|
66
|
+
await assertPng(output)
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(`Could not prepare HEIC image "${basename(path)}" for the terminal.`)
|
|
69
|
+
}
|
|
70
|
+
stagedPaths.push(output)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let cleaned = false
|
|
74
|
+
return {
|
|
75
|
+
paths: stagedPaths,
|
|
76
|
+
cleanup: async () => {
|
|
77
|
+
if (cleaned || !directory) return
|
|
78
|
+
cleaned = true
|
|
79
|
+
await rm(directory, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {})
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Pi 1.2.0 omits CLI builtins, rich builtin renderers, clipboard reads and the global theme setter
|
|
2
2
|
// from its public exports. Keep those version-sensitive presentation hooks here; session/runtime
|
|
3
3
|
// work uses public APIs. Clipboard helpers are only called for an explicit user paste.
|
|
4
|
-
import {
|
|
4
|
+
import type { InlineExtension, Theme, ToolRenderers } from '@earendil-works/pi-coding-agent'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { pathToFileURL } from 'node:url'
|
|
7
7
|
|
|
8
|
+
import { loadPi, PINNED_PI_VERSION } from '../../pi/runtime'
|
|
9
|
+
|
|
8
10
|
export interface PiAdapter {
|
|
9
11
|
builtins: InlineExtension[]
|
|
10
12
|
setTheme(theme: Theme): void
|
|
@@ -19,13 +21,14 @@ let adapter: PiAdapter | undefined
|
|
|
19
21
|
let loading: Promise<PiAdapter> | undefined
|
|
20
22
|
|
|
21
23
|
export function assertPiAdapterVersion(version: string): void {
|
|
22
|
-
if (version !==
|
|
24
|
+
if (version !== PINNED_PI_VERSION) throw new Error(`The Harness terminal adapter requires Pi ${PINNED_PI_VERSION}; found ${version}. Install the Harness package's pinned dependencies.`)
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function loadPiAdapter(): Promise<PiAdapter> {
|
|
26
28
|
loading ??= (async () => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const pi = await loadPi()
|
|
30
|
+
assertPiAdapterVersion(pi.VERSION)
|
|
31
|
+
const root = pi.getPackageDir()
|
|
29
32
|
const [extensions, theme, renderers, clipboard, images] = await Promise.all([
|
|
30
33
|
import(pathToFileURL(join(root, 'dist/extensions/index.js')).href),
|
|
31
34
|
import(pathToFileURL(join(root, 'dist/modes/interactive/theme/theme.js')).href),
|
|
@@ -11,6 +11,7 @@ import terminalExtension, { type TerminalPlanRunState } from '../piExtension'
|
|
|
11
11
|
import { planModeTools } from './planMode'
|
|
12
12
|
import { claimTerminalControl, controlCall } from '../control'
|
|
13
13
|
import { loadPiAdapter } from './piAdapter'
|
|
14
|
+
import { loadPi } from '../../pi/runtime'
|
|
14
15
|
import type { SessionAccount } from '../accountsMenu'
|
|
15
16
|
import type { QuestionAnswer } from '../../sessions/registry'
|
|
16
17
|
import type { TerminalQuestion } from './questions'
|
|
@@ -174,6 +175,7 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
|
|
|
174
175
|
assertTerminalSessionScope(resume, cwd)
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
await loadPi()
|
|
177
179
|
// Claim before loading any plugin code and retain it in the trusted process across reloads.
|
|
178
180
|
await claimTerminalControl()
|
|
179
181
|
const identity = await controlCall<SessionAccount>('accounts.session').catch(() => null)
|