@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.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /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 enabled registry items with them, and its own extension last', async () => {
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 prepare = async () => (await harness.terminals.prepare({ sessionId: 'term-1', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder: join(root, 'sessions', 'p1', 'term-1') })).args.slice(2)
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(root, 'registry/skills/release/SKILL.md'), '--skill', '/shared/skills/alpha/SKILL.md',
134
- '--prompt-template', join(root, 'registry/prompts/review.md'), '--prompt-template', '/shared/prompts/p.md',
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 memory and connected apps to the session over the socket, until the terminal ends', async () => {
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, 'wiki.entrypoint')).toContain('# Xingular Harness context')
396
- expect(await socketCall<string>(connection, 'wiki.list')).toContain('library')
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 memory and Wiki calls', () => {
433
- expect(parseCli(['search', 'deploy', 'steps', '--kind', 'page'])).toEqual({ kind: 'call', call: 'wiki.search', params: { query: 'deploy steps', kinds: ['page'] } })
434
- expect(parseCli(['read', '/projects/app/arch'])).toEqual({ kind: 'call', call: 'wiki.read', params: { id: '/projects/app/arch' } })
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(['skill', 'review'])).toEqual({ kind: 'call', call: 'wiki.skill', params: { name: 'review' } })
437
- expect(parseCli(['memory', '--namespace', '/projects/app'])).toEqual({ kind: 'call', call: 'wiki.memory', params: { namespace: '/projects/app' } })
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(['nonsense'])).toEqual({ kind: 'help' })
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 '../memory/caller'
28
- import { dispatch } from '../memory/dispatch'
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, memoryDelta, record, render, saveForSession, type Launch } from '../prompt/compile'
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 pack = compile(env, caller, { input: '', ...input.launch })
517
- record(root, caller, pack)
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 enabled = resources(root, input.projectId ?? null)
588
- for (const path of [...loading.extensionPaths, ...loading.builtins]) args.push('--extension', path)
589
- for (const path of [...enabled.skillPaths, ...loading.skillPaths]) args.push('--skill', path)
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, note ? `${note}\n\n${input.text}` : input.text)
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
- this.ui.pasteToEditor(files.join('\n'))
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(() => { void rm(directory, { recursive: true, force: true }) })
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 { getPackageDir, VERSION, type InlineExtension, type Theme, type ToolRenderers } from '@earendil-works/pi-coding-agent'
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 !== '1.2.0') throw new Error(`The Harness terminal adapter requires Pi 1.2.0; found ${version}. Install the Harness package's pinned dependencies.`)
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
- assertPiAdapterVersion(VERSION)
28
- const root = getPackageDir()
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)