@xingular/harness 2.1.1 → 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 (152) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +70 -37
  4. package/dist/cli.js +6572 -6700
  5. package/dist/terminal-pi.js +51 -38
  6. package/docs/terminal-tui.md +50 -12
  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/bus.test.ts +15 -1
  12. package/src/core/bus.ts +39 -0
  13. package/src/core/caller.ts +34 -0
  14. package/src/core/fs.ts +17 -1
  15. package/src/harness.ts +55 -66
  16. package/src/{memory → history}/recall.test.ts +1 -1
  17. package/src/{memory → history}/recall.ts +1 -1
  18. package/src/{memory → history}/trajectory.test.ts +4 -4
  19. package/src/{memory → history}/trajectory.ts +1 -1
  20. package/src/hub/hub.test.ts +4 -5
  21. package/src/hub/hub.ts +2 -2
  22. package/src/index.ts +28 -14
  23. package/src/mcp/connections.ts +1 -1
  24. package/src/mcp/service.ts +1 -1
  25. package/src/pi/proxy.test.ts +68 -0
  26. package/src/pi/runtime.test.ts +61 -0
  27. package/src/pi/runtime.ts +50 -0
  28. package/src/plugins/api.ts +11 -9
  29. package/src/plugins/builtin.ts +22 -40
  30. package/src/plugins/catalogue.test.ts +13 -41
  31. package/src/plugins/catalogue.ts +3 -32
  32. package/src/plugins/discover.ts +2 -1
  33. package/src/plugins/firstParty.test.ts +1 -1
  34. package/src/plugins/hooks.test.ts +21 -71
  35. package/src/plugins/hooks.ts +6 -7
  36. package/src/plugins/identity.ts +16 -2
  37. package/src/plugins/install.test.ts +1 -1
  38. package/src/plugins/install.ts +2 -1
  39. package/src/plugins/inventory.ts +19 -18
  40. package/src/plugins/plugins.ts +38 -2
  41. package/src/prompt/compile.test.ts +82 -193
  42. package/src/prompt/compile.ts +98 -363
  43. package/src/refine/cli.ts +114 -0
  44. package/src/refine/demo.ts +124 -0
  45. package/src/refine/evidence.ts +349 -0
  46. package/src/refine/host.ts +102 -0
  47. package/src/refine/ledger.ts +104 -0
  48. package/src/refine/library.ts +192 -0
  49. package/src/refine/pi.test.ts +90 -0
  50. package/src/refine/pi.ts +218 -0
  51. package/src/refine/prompts.ts +430 -0
  52. package/src/refine/recursion.ts +353 -0
  53. package/src/refine/refine.test.ts +140 -0
  54. package/src/refine/runner.ts +205 -0
  55. package/src/refine/selection.ts +291 -0
  56. package/src/refine/service.ts +194 -0
  57. package/src/refine/trajectory.ts +71 -0
  58. package/src/refine/types.ts +91 -0
  59. package/src/server/methods.ts +137 -123
  60. package/src/server/server.test.ts +37 -30
  61. package/src/sessions/contracts.ts +7 -2
  62. package/src/sessions/conversation.test.ts +2 -27
  63. package/src/sessions/planMode.test.ts +8 -1
  64. package/src/sessions/planMode.ts +17 -9
  65. package/src/sessions/policy.ts +1 -1
  66. package/src/sessions/pricing.ts +3 -1
  67. package/src/sessions/questionBatches.test.ts +15 -3
  68. package/src/sessions/registry.ts +13 -1
  69. package/src/sessions/runStore.ts +3 -1
  70. package/src/sessions/runner.ts +10 -5
  71. package/src/sessions/sessions.test.ts +60 -13
  72. package/src/sessions/sessions.ts +43 -18
  73. package/src/sessions/subagentService.test.ts +22 -1
  74. package/src/sessions/subagentService.ts +14 -1
  75. package/src/sessions/subagents.ts +2 -0
  76. package/src/terminal/cli.ts +24 -48
  77. package/src/terminal/logs.test.ts +26 -0
  78. package/src/terminal/logs.ts +67 -0
  79. package/src/terminal/piExtension.lifecycle.test.ts +1 -1
  80. package/src/terminal/piExtension.ts +3 -3
  81. package/src/terminal/pluginControl.test.ts +7 -8
  82. package/src/terminal/socket.ts +3 -3
  83. package/src/terminal/terminal.test.ts +41 -39
  84. package/src/terminal/terminals.ts +31 -13
  85. package/src/terminal/tui/application.test.ts +58 -0
  86. package/src/terminal/tui/application.ts +15 -4
  87. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  88. package/src/terminal/tui/clipboardFiles.ts +86 -0
  89. package/src/terminal/tui/commands.test.ts +13 -1
  90. package/src/terminal/tui/commands.ts +16 -0
  91. package/src/terminal/tui/logPanel.test.ts +61 -0
  92. package/src/terminal/tui/logPanel.ts +175 -0
  93. package/src/terminal/tui/piAdapter.ts +7 -4
  94. package/src/terminal/tui/questionPicker.ts +168 -0
  95. package/src/terminal/tui/questions.test.ts +86 -19
  96. package/src/terminal/tui/questions.ts +27 -22
  97. package/src/terminal/tui/runtime.ts +2 -0
  98. package/src/tools/askUser.test.ts +23 -2
  99. package/src/tools/askUser.ts +15 -5
  100. package/src/tools/definitions.ts +49 -132
  101. package/src/tools/dispatch.ts +78 -0
  102. package/src/tools/harnessTools.ts +23 -0
  103. package/src/tools/spawnSubagent.ts +3 -3
  104. package/src/tools/types.ts +3 -1
  105. package/src/tree/components.ts +221 -0
  106. package/src/tree/materialise.ts +122 -0
  107. package/src/tree/seed.ts +123 -0
  108. package/src/tree/store.ts +193 -0
  109. package/src/tree/surface.ts +135 -0
  110. package/src/tree/tree.test.ts +126 -0
  111. package/src/tree/tree.ts +346 -0
  112. package/src/tree/trust.ts +86 -0
  113. package/src/web/tools.ts +27 -5
  114. package/src/web/web.test.ts +26 -1
  115. package/src/wiki/notes.ts +313 -0
  116. package/src/memory/api.test.ts +0 -82
  117. package/src/memory/api.ts +0 -303
  118. package/src/memory/blocks.test.ts +0 -78
  119. package/src/memory/blocks.ts +0 -408
  120. package/src/memory/caller.ts +0 -69
  121. package/src/memory/curation.test.ts +0 -206
  122. package/src/memory/curation.ts +0 -64
  123. package/src/memory/dispatch.ts +0 -64
  124. package/src/memory/facts.test.ts +0 -41
  125. package/src/memory/facts.ts +0 -248
  126. package/src/memory/graph.ts +0 -103
  127. package/src/memory/health.test.ts +0 -130
  128. package/src/memory/health.ts +0 -74
  129. package/src/memory/maintainer.ts +0 -272
  130. package/src/memory/maintenance.test.ts +0 -192
  131. package/src/memory/maintenance.ts +0 -679
  132. package/src/memory/migrate.ts +0 -79
  133. package/src/memory/operations.ts +0 -150
  134. package/src/memory/proposals.test.ts +0 -168
  135. package/src/memory/proposals.ts +0 -584
  136. package/src/memory/references.ts +0 -77
  137. package/src/memory/runContext.ts +0 -93
  138. package/src/memory/store.test.ts +0 -66
  139. package/src/memory/store.ts +0 -385
  140. package/src/memory/wiki.test.ts +0 -47
  141. package/src/memory/wiki.ts +0 -423
  142. package/src/memory/wikiIndex.test.ts +0 -88
  143. package/src/memory/wikiIndex.ts +0 -117
  144. package/src/plugins/proposal.ts +0 -121
  145. package/src/skills/registry.test.ts +0 -104
  146. package/src/skills/registry.ts +0 -413
  147. package/src/tools/maintenance.ts +0 -29
  148. package/src/tools/memoryTools.ts +0 -22
  149. package/src/tools/pluginPropose.ts +0 -41
  150. package/src/tools/registryWrite.test.ts +0 -101
  151. package/src/tools/registryWrite.ts +0 -182
  152. /package/src/{memory → core}/secrets.ts +0 -0
@@ -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,7 +24,9 @@ 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'
29
+ import { readHarnessLogSnapshot, type HarnessLogChunk, type HarnessLogInfo } from '../logs'
28
30
  import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
29
31
 
30
32
  const exec = promisify(execFile)
@@ -113,7 +115,7 @@ export class TerminalApplication {
113
115
  private failure?: Error
114
116
  private finish!: () => void
115
117
  private done = new Promise<void>(resolve => { this.finish = resolve })
116
- private cleanup: (() => void)[] = []
118
+ private cleanup: (() => void | Promise<void>)[] = []
117
119
  private finalCleanup: (() => void)[] = []
118
120
  private draftSync = Promise.resolve()
119
121
  private lastInput = ''
@@ -190,6 +192,10 @@ export class TerminalApplication {
190
192
  plan: brief => this.enqueuePlan(brief),
191
193
  copyText: copyToClipboard, openExternal,
192
194
  changed: this.change,
195
+ logs: { read: () => readHarnessLogSnapshot(
196
+ () => controlCall<HarnessLogInfo>('terminal.logs.info'),
197
+ (snapshotSize, offset) => controlCall<HarnessLogChunk>('terminal.logs.read', { snapshotSize, offset })
198
+ ) },
193
199
  trajectory: {
194
200
  list: async () => (await controlCall<{ runs: TerminalTrajectoryRun[] }>('terminal.trajectory.list')).runs,
195
201
  read: (runId, afterSequence) => controlCall<TerminalTrajectorySnapshot>('terminal.trajectory.events', { runId, ...(afterSequence === undefined ? {} : { afterSequence }) })
@@ -643,7 +649,9 @@ export class TerminalApplication {
643
649
  const files = await adapter.readClipboardFilePaths()
644
650
  if (files?.length) {
645
651
  if (files.some(file => /[\x00-\x1f\x7f]/.test(file))) throw new Error('Clipboard file paths contain control characters.')
646
- 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'))
647
655
  return
648
656
  }
649
657
  const image = await adapter.readClipboardImage()
@@ -651,7 +659,7 @@ export class TerminalApplication {
651
659
  const directory = await mkdtemp(join(tmpdir(), 'harness-clipboard-'))
652
660
  const file = join(directory, `${randomUUID()}.${adapter.extensionForImageMimeType(image.mimeType) ?? 'png'}`)
653
661
  await writeFile(file, image.bytes, { mode: 0o600 })
654
- this.cleanup.push(() => { void rm(directory, { recursive: true, force: true }) })
662
+ this.cleanup.push(() => rm(directory, { recursive: true, force: true }))
655
663
  this.ui.pasteToEditor(file)
656
664
  return
657
665
  }
@@ -684,12 +692,15 @@ export class TerminalApplication {
684
692
  this.stopped = true
685
693
  protect(() => this.pendingAbort?.abort())
686
694
  protect(() => this.ui?.dispose())
687
- for (const cleanup of this.cleanup.splice(0)) protect(cleanup)
688
695
  // Captured while the runtime and its components still exist; written only after the alternate screen is left.
689
696
  const scrollback = this.exitScrollback()
690
697
  try { await this.runtime?.dispose() }
691
698
  catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
692
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
+ }
693
704
  await this.terminal.drainInput(200, 30).catch(() => {})
694
705
  protect(() => this.tui.stop())
695
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
+ }
@@ -33,6 +33,7 @@ function setup() {
33
33
  refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
34
34
  plan: vi.fn(),
35
35
  hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
36
+ logs: { read: vi.fn(async () => '[harness] diagnostic') },
36
37
  trajectory: {
37
38
  list: vi.fn(async () => [trajectoryRun]),
38
39
  read: vi.fn(async () => ({ run: trajectoryRun, events: [] }))
@@ -59,7 +60,7 @@ describe('owned terminal commands', () => {
59
60
  it('populates completion from builtins and loaded resources', () => {
60
61
  const { commands } = setup()
61
62
  const names = commands.completions().map(item => item.name)
62
- expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'permissions']))
63
+ expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'log', 'permissions']))
63
64
  expect(names).not.toContain('thinking')
64
65
  expect(names).not.toContain('skill:diagnose')
65
66
  expect(new Set(names).size).toBe(names.length)
@@ -172,6 +173,17 @@ describe('owned terminal commands', () => {
172
173
  expect(host.trajectory.read).toHaveBeenLastCalledWith('run-1')
173
174
  })
174
175
 
176
+ it('opens a full-screen snapshot combining diagnostics and current-conversation run events', async () => {
177
+ const { commands, host, ui } = setup()
178
+ await commands.execute('/log')
179
+ expect(host.logs.read).toHaveBeenCalledOnce()
180
+ expect(host.trajectory.list).toHaveBeenCalledOnce()
181
+ expect(host.trajectory.read).toHaveBeenCalledWith('run-1')
182
+ expect(ui.custom).toHaveBeenCalledWith(expect.any(Function), {
183
+ overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' }
184
+ })
185
+ })
186
+
175
187
  it('shows shared tool grants and confirms Full Permission before enabling it', async () => {
176
188
  const { commands, host, runtime, ui } = setup()
177
189
  vi.mocked(ui.custom).mockImplementationOnce(async () => 'tool:bash' as never)
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
6
6
  import { ModelSelectorComponent, TreeSelectorComponent, SessionManager, type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent'
7
7
  import { SettingsPanel, type TerminalSettingItem } from './settings'
8
8
  import { DialogPanel, SearchableSelect } from './dialogs'
9
+ import { LogPanel, formatRunEventLog } from './logPanel'
9
10
  import { TrajectoryPanel } from './trajectoryPanel'
10
11
  import { expandSkillInvocation, type SkillEntry } from './skillInvocation'
11
12
  import manifest from '../../../package.json'
@@ -30,6 +31,7 @@ export interface TerminalCommandHost {
30
31
  suspend?(): Promise<void>
31
32
  hotkeys?(): string
32
33
  changelog?(): Promise<string>
34
+ logs: { read(): Promise<string> }
33
35
  trajectory: {
34
36
  list(): Promise<TerminalTrajectoryRun[]>
35
37
  read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
@@ -54,6 +56,7 @@ const BUILTINS: TerminalCommandCompletion[] = [
54
56
  { name: 'name', description: 'Name the current session' },
55
57
  { name: 'session', description: 'Current session statistics' },
56
58
  { name: 'trajectory', description: 'Inspect recorded runs for this native session' },
59
+ { name: 'log', description: 'View Harness diagnostics and this conversation run events' },
57
60
  { name: 'compact', description: 'Compact the current context' },
58
61
  { name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
59
62
  { name: 'trust', description: 'Review plugin code and project trust' },
@@ -230,6 +233,18 @@ export function createTerminalCommands(host: TerminalCommandHost) {
230
233
  }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
231
234
  }
232
235
 
236
+ async function logs() {
237
+ const [diagnostics, runs] = await Promise.all([host.logs.read(), host.trajectory.list()])
238
+ const snapshots = await Promise.all(runs.map(run => host.trajectory.read(run.id)))
239
+ const sections = [
240
+ { title: 'Harness diagnostics', text: diagnostics || 'No Harness diagnostics have been captured by this host.' },
241
+ { title: 'Conversation run events', text: formatRunEventLog(snapshots) }
242
+ ]
243
+ await host.ui.custom<void>((tui, theme, _keys, done) => new LogPanel({
244
+ tui, theme, sections, close: () => done(undefined), changed: host.changed
245
+ }), { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' } })
246
+ }
247
+
233
248
  async function permissions() {
234
249
  for (;;) {
235
250
  const runtime = host.runtime()
@@ -387,6 +402,7 @@ export function createTerminalCommands(host: TerminalCommandHost) {
387
402
  }
388
403
  case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
389
404
  case 'trajectory': await trajectory(argument); break
405
+ case 'log': await logs(); break
390
406
  case 'compact': await session.compact(argument || undefined); break
391
407
  case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
392
408
  case 'trust': {
@@ -0,0 +1,61 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent'
2
+ import { stripTerminalSequences, type TUI } from '@earendil-works/pi-tui'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import type { RunEvent } from '../../sessions/contracts'
6
+ import type { TerminalTrajectorySnapshot } from '../trajectory'
7
+ import { formatRunEventLog, LogPanel } from './logPanel'
8
+ import { createHarnessTheme } from './theme'
9
+
10
+ const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
11
+ const event = (sequence: number, type: string, payload: unknown): RunEvent => ({
12
+ id: `event-${sequence}`, runId: 'run-1', sessionId: 'terminal-1', sequence,
13
+ timestamp: `2026-10-18T00:00:0${sequence}.000Z`, type: type as RunEvent['type'], payload
14
+ })
15
+
16
+ function plain(panel: LogPanel): string {
17
+ return panel.render(80).map(stripTerminalSequences).join('\n')
18
+ }
19
+
20
+ describe('terminal log viewer', () => {
21
+ it('shows diagnostic run events without prompt or successful response content', () => {
22
+ const snapshot: TerminalTrajectorySnapshot = {
23
+ run: { id: 'run-1', mode: 'code', goal: 'Private prompt', startedAt: '2026-10-18T00:00:00.000Z', endedAt: '2026-10-18T00:00:02.000Z', status: 'failed' },
24
+ events: [
25
+ event(0, 'context.snapshot', { systemPrompt: 'Private prompt' }),
26
+ event(1, 'tool.completed', { name: 'web_fetch', ok: true, output: JSON.stringify({ url: 'https://example.com/?token=private', status: 404, error: 'Page missing', text: 'Private response body' }) }),
27
+ event(2, 'failed', { code: 'network', message: 'Failed at https://example.com/path?token=private' })
28
+ ]
29
+ }
30
+ const text = formatRunEventLog([snapshot])
31
+ expect(text).toContain('HTTP 404: Page missing')
32
+ expect(text).toContain('network')
33
+ expect(text).not.toContain('Private prompt')
34
+ expect(text).not.toContain('Private response body')
35
+ expect(text).not.toContain('private')
36
+ })
37
+
38
+ it('opens at the newest lines, pages through the snapshot, switches sources, and closes on Escape', () => {
39
+ const close = vi.fn()
40
+ const changed = vi.fn()
41
+ const panel = new LogPanel({
42
+ tui: { terminal: { rows: 8 } } as unknown as TUI,
43
+ theme,
44
+ sections: [
45
+ { title: 'Harness diagnostics', text: Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join('\n') },
46
+ { title: 'Conversation run events', text: 'run event' }
47
+ ],
48
+ close, changed
49
+ })
50
+
51
+ expect(plain(panel)).toContain('line 20')
52
+ panel.handleInput('\x1b[5~')
53
+ expect(plain(panel)).toContain('line 11')
54
+ expect(plain(panel)).not.toContain('line 20')
55
+ panel.handleInput('\t')
56
+ expect(plain(panel)).toContain('Conversation run events')
57
+ panel.handleInput('\x1b')
58
+ expect(close).toHaveBeenCalledOnce()
59
+ expect(changed).toHaveBeenCalled()
60
+ })
61
+ })
@@ -0,0 +1,175 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent'
2
+ import { matchesKey, stripTerminalSequences, truncateToWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from '@earendil-works/pi-tui'
3
+
4
+ import { redactLogText } from '../../core/bus'
5
+ import type { RunEvent } from '../../sessions/contracts'
6
+ import type { TerminalTrajectorySnapshot } from '../trajectory'
7
+
8
+ export interface LogSection {
9
+ title: string
10
+ text: string
11
+ }
12
+
13
+ const payloadOf = (event: RunEvent): Record<string, unknown> =>
14
+ event.payload && typeof event.payload === 'object' ? event.payload as Record<string, unknown> : {}
15
+ const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined
16
+ const numberOf = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined
17
+
18
+ function safe(text: string): string {
19
+ return redactLogText(stripTerminalSequences(text)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ').replace(/\t/g, ' ')
20
+ }
21
+
22
+ function timeOf(value: string): string {
23
+ const parsed = Date.parse(value)
24
+ return Number.isFinite(parsed) ? new Date(parsed).toLocaleTimeString() : value
25
+ }
26
+
27
+ function webError(output: string): string | undefined {
28
+ try {
29
+ const value = JSON.parse(output) as Record<string, unknown>
30
+ if (typeof value.error !== 'string') return undefined
31
+ const status = numberOf(value.status)
32
+ return `${status === undefined ? '' : `HTTP ${status}: `}${value.error}`
33
+ } catch {
34
+ return /^(?:The page could not be read:|The search could not be completed:)/.test(output) ? output : undefined
35
+ }
36
+ }
37
+
38
+ function eventDetail(event: RunEvent): string[] {
39
+ const payload = payloadOf(event)
40
+ switch (event.type) {
41
+ case 'context.snapshot':
42
+ return ['prompt and tool definitions omitted']
43
+ case 'message.delta':
44
+ case 'message.completed':
45
+ case 'message.steered':
46
+ return ['message content omitted']
47
+ case 'tool.started':
48
+ return [`tool ${stringOf(payload.name) ?? 'unknown'} started; inputs omitted`]
49
+ case 'tool.completed': {
50
+ const name = stringOf(payload.name) ?? 'unknown'
51
+ const duration = numberOf(payload.durationMs)
52
+ const result = [`tool ${name} ${payload.ok === false ? 'failed' : 'completed'}${duration === undefined ? '' : ` · ${duration} ms`}`]
53
+ const output = stringOf(payload.output)
54
+ const webFailure = (name === 'web_fetch' || name === 'web_search') && output ? webError(output) : undefined
55
+ if (webFailure) result.push(` ${webFailure}`)
56
+ else if (payload.ok === false && output) result.push(` ${output}`)
57
+ if (payload.outputTruncated === true) result.push(' recorded output was truncated')
58
+ return result
59
+ }
60
+ case 'retry.started':
61
+ case 'retry.ended':
62
+ return [`attempt ${numberOf(payload.attempt) ?? '?'}${numberOf(payload.maxAttempts) === undefined ? '' : ` of ${numberOf(payload.maxAttempts)}`}${numberOf(payload.delayMs) === undefined ? '' : ` · retry delay ${numberOf(payload.delayMs)} ms`}`, ...(stringOf(payload.error) ? [` ${payload.error}`] : [])]
63
+ case 'compaction.ended':
64
+ return [payload.aborted === true ? 'compaction aborted' : 'compaction completed', ...(stringOf(payload.error) ? [` ${payload.error}`] : [])]
65
+ case 'extension.notice':
66
+ return payload.kind === 'error' || payload.level === 'error' ? [stringOf(payload.text) ?? 'extension error'] : ['non-error extension notice omitted']
67
+ case 'account.status': {
68
+ const message = stringOf(payload.message)
69
+ return [stringOf(payload.category) ?? 'account status', ...(message ? [message] : [])]
70
+ }
71
+ case 'status.changed': {
72
+ const reason = stringOf(payload.reason)
73
+ return [stringOf(payload.status) ?? 'status changed', ...(reason ? [reason] : [])]
74
+ }
75
+ case 'failed': {
76
+ const message = stringOf(payload.message)
77
+ return [stringOf(payload.code) ?? 'run failed', ...(message ? [message] : [])]
78
+ }
79
+ case 'usage.updated': {
80
+ const request = payload.request && typeof payload.request === 'object' ? payload.request as Record<string, unknown> : {}
81
+ return [`${stringOf(request.provider) ?? stringOf(payload.provider) ?? 'provider'}/${stringOf(request.model) ?? stringOf(payload.model) ?? 'model'} · request ${numberOf(request.index) ?? numberOf(payload.requests) ?? '?'}`]
82
+ }
83
+ case 'approval.requested':
84
+ case 'approval.resolved':
85
+ return [`tool ${stringOf(payload.target) ?? ''} · decision ${stringOf(payload.decision) ?? 'pending'}; action details omitted`]
86
+ case 'question.requested':
87
+ case 'question.answered':
88
+ return ['question content omitted']
89
+ default:
90
+ return []
91
+ }
92
+ }
93
+
94
+ export function formatRunEventLog(snapshots: TerminalTrajectorySnapshot[]): string {
95
+ if (!snapshots.length) return 'No run events recorded for this conversation.'
96
+ const sections: string[] = []
97
+ for (const snapshot of snapshots) {
98
+ sections.push(`Run ${snapshot.run.id} · ${snapshot.run.status} · ${snapshot.run.mode}`)
99
+ const events = snapshot.events.slice().sort((left, right) => left.sequence - right.sequence)
100
+ if (!events.length) sections.push(' No recorded events')
101
+ for (const event of events) {
102
+ sections.push(`${timeOf(event.timestamp)} #${event.sequence} ${event.type}`)
103
+ sections.push(...eventDetail(event).map(detail => ` ${detail}`))
104
+ }
105
+ sections.push('')
106
+ }
107
+ return safe(sections.join('\n').trimEnd())
108
+ }
109
+
110
+ export interface LogPanelOptions {
111
+ tui: TUI
112
+ theme: Theme
113
+ sections: LogSection[]
114
+ close(): void
115
+ changed(): void
116
+ }
117
+
118
+ /** A static, full-screen view of the diagnostic snapshot taken when /log was opened. */
119
+ export class LogPanel implements Component, Focusable {
120
+ private source = 0
121
+ private offset = 0
122
+ private atEnd = true
123
+ private maxOffset = 0
124
+ private focusedValue = false
125
+
126
+ constructor(private readonly options: LogPanelOptions) {}
127
+
128
+ get focused(): boolean { return this.focusedValue }
129
+ set focused(value: boolean) { this.focusedValue = value }
130
+ invalidate(): void {}
131
+
132
+ private changeSource(direction: number): void {
133
+ const count = this.options.sections.length
134
+ if (!count) return
135
+ this.source = (this.source + direction + count) % count
136
+ this.offset = 0
137
+ this.atEnd = true
138
+ }
139
+
140
+ handleInput(data: string): void {
141
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { this.options.close(); return }
142
+ if (matchesKey(data, 'left') || matchesKey(data, 'shift+tab')) this.changeSource(-1)
143
+ else if (matchesKey(data, 'right') || matchesKey(data, 'tab')) this.changeSource(1)
144
+ else if (matchesKey(data, 'home')) { this.offset = 0; this.atEnd = false }
145
+ else if (matchesKey(data, 'end')) this.atEnd = true
146
+ else if (matchesKey(data, 'up')) { this.offset = Math.max(0, this.currentOffset() - 1); this.atEnd = false }
147
+ else if (matchesKey(data, 'down')) { this.offset = this.currentOffset() + 1; this.atEnd = false }
148
+ else if (matchesKey(data, 'pageUp')) { this.offset = Math.max(0, this.currentOffset() - this.pageSize()); this.atEnd = false }
149
+ else if (matchesKey(data, 'pageDown')) { this.offset = this.currentOffset() + this.pageSize(); this.atEnd = false }
150
+ this.options.changed()
151
+ }
152
+
153
+ private pageSize(): number { return Math.max(1, this.options.tui.terminal.rows - 4) }
154
+ private currentOffset(): number { return this.atEnd ? this.maxOffset : this.offset }
155
+
156
+ render(width: number): string[] {
157
+ width = Math.max(1, Math.trunc(width))
158
+ const rows = Math.max(1, Math.trunc(this.options.tui.terminal.rows))
159
+ const section = this.options.sections[this.source] ?? { title: 'Logs', text: 'No log sources are available.' }
160
+ const bodyRows = Math.max(0, rows - 2)
161
+ const contentWidth = Math.max(1, width)
162
+ const raw = safe(section.text).split('\n')
163
+ const lines = raw.flatMap(line => line ? wrapTextWithAnsi(line, contentWidth) : [''])
164
+ const maxOffset = Math.max(0, lines.length - bodyRows)
165
+ this.maxOffset = maxOffset
166
+ if (this.atEnd) this.offset = maxOffset
167
+ else this.offset = Math.max(0, Math.min(maxOffset, this.offset))
168
+ const first = this.offset
169
+ const last = Math.min(lines.length, first + bodyRows)
170
+ const position = `${this.source + 1}/${Math.max(1, this.options.sections.length)} · lines ${lines.length ? first + 1 : 0}-${last}/${lines.length}`
171
+ const title = this.options.theme.bold(this.options.theme.fg('accent', `${section.title} · ${position}`))
172
+ const footer = this.options.theme.fg('dim', '↑/↓ scroll · PgUp/PgDn page · ←/→ source · Esc close')
173
+ return [title, ...lines.slice(first, last), footer].slice(0, rows).map(line => truncateToWidth(line, width, ''))
174
+ }
175
+ }