@xingular/harness 0.1.6 → 2.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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,225 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, realpathSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import type { ExtensionAPI, ExtensionUIContext } from '@earendil-works/pi-coding-agent'
6
+
7
+ const transport = vi.hoisted(() => ({ claim: vi.fn(async () => 'private-control'), call: vi.fn(async (_call: string) => ({})) }))
8
+ vi.mock('../control', () => ({ claimTerminalControl: transport.claim, controlCall: transport.call }))
9
+ vi.mock('../piExtension', () => ({ default: (pi: ExtensionAPI) => {
10
+ pi.on('session_start', (_event, ctx) => { pi.appendEntry('bridge-start', { mode: ctx.mode }) })
11
+ } }))
12
+
13
+ import { assertTerminalSessionScope, createTerminalRuntime, parseTerminalArguments, type TerminalRuntime } from './runtime'
14
+ import { assertPiAdapterVersion, loadPiAdapter, withBuiltInRenderers } from './piAdapter'
15
+
16
+ const folders: string[] = []
17
+ const runtimes: TerminalRuntime[] = []
18
+ afterEach(async () => {
19
+ for (const runtime of runtimes.splice(0)) await runtime.dispose()
20
+ vi.restoreAllMocks()
21
+ vi.unstubAllEnvs()
22
+ transport.call.mockReset().mockResolvedValue({})
23
+ transport.claim.mockClear()
24
+ for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true })
25
+ })
26
+
27
+ function fixture() {
28
+ const root = mkdtempSync(join(tmpdir(), 'harness-terminal-runtime-'))
29
+ folders.push(root)
30
+ const cwd = join(root, 'project')
31
+ const account = join(root, 'account')
32
+ const history = join(root, 'history')
33
+ mkdirSync(cwd)
34
+ mkdirSync(account)
35
+ mkdirSync(history)
36
+ vi.spyOn(process, 'cwd').mockReturnValue(cwd)
37
+ vi.stubEnv('PI_CODING_AGENT_DIR', account)
38
+ const plugin = join(root, 'fixture.mjs')
39
+ writeFileSync(plugin, `export default function (pi) {
40
+ pi.on('session_start', (_event, ctx) => pi.appendEntry('fixture-start', { mode: ctx.mode }))
41
+ pi.registerCommand('marker', { description: 'A local fixture command', handler: async (args) => { pi.appendEntry('marker', { text: args }) } })
42
+ }`)
43
+ const args = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', '--extension', plugin, '--extension', join(root, 'terminal-pi.js'), '--session-dir', history, '--append-system-prompt', 'Fixed Harness context.']
44
+ const changed = vi.fn()
45
+ const ui = { getEditorText: () => '', select: vi.fn(async () => undefined), notify: vi.fn() } as unknown as ExtensionUIContext
46
+ return { root, cwd, account, history, plugin, args, host: { ui, changed, shutdown: vi.fn(), notify: vi.fn() } }
47
+ }
48
+
49
+ async function openFixture() {
50
+ const f = fixture()
51
+ const runtime = await createTerminalRuntime(f.args, f.host)
52
+ runtimes.push(runtime)
53
+ return { ...f, runtime }
54
+ }
55
+
56
+ describe('the persistent terminal SDK adapter', () => {
57
+ it('parses only the prepared resource flags without discovering extra resources', () => {
58
+ const parsed = parseTerminalArguments(['--no-extensions', '--extension', 'builtin:mcp', '--extension', '/bridge.js', '--skill', '/skill', '--prompt-template', '/prompt', '--theme', '/theme', '--session-dir', '/history', '--session', '/history/old.jsonl', '--model', 'provider/model', '--append-system-prompt', 'context'])
59
+ expect(parsed).toEqual({ appendSystemPrompt: ['context'], extensions: ['builtin:mcp', '/bridge.js'], skills: ['/skill'], prompts: ['/prompt'], themes: ['/theme'], sessionDir: '/history', session: '/history/old.jsonl', model: 'provider/model' })
60
+ expect(() => parseTerminalArguments(['--serve'])).toThrow('Unsupported')
61
+ expect(() => parseTerminalArguments(['--session-dir'])).toThrow('needs a value')
62
+ expect(() => parseTerminalArguments(['--session-dir', '/history'])).toThrow('bridge extension')
63
+ })
64
+
65
+ it('loads only explicit plugins and keeps events and commands alive across new, resume, fork and reload', async () => {
66
+ const { runtime, args, root, cwd } = await openFixture()
67
+ const first = runtime.session
68
+ const firstFile = first.sessionFile!
69
+ expect(first.systemPrompt).toContain('Fixed Harness context.')
70
+ expect(first.extensionRunner.getExtensionPaths()).toEqual([args[5], '<inline:harness-terminal-bridge>'])
71
+ expect(first.sessionManager.getEntries().filter((entry) => entry.type === 'custom').map((entry) => entry.data)).toEqual([{ mode: 'tui' }, { mode: 'tui' }])
72
+ const events: string[] = []
73
+ runtime.subscribe((event) => events.push(event.type))
74
+ const replacements: string[] = []
75
+ runtime.onSessionChange((session) => { replacements.push(session.sessionId) })
76
+ const before = vi.fn()
77
+ runtime.onBeforeSessionChange(before)
78
+ await runtime.session.prompt('/marker first')
79
+ // Native Pi keeps custom-entry-only sessions in memory until an assistant response. This
80
+ // fixture persists its actual native entries without making a provider request.
81
+ writeFileSync(firstFile, [first.sessionManager.getHeader(), ...first.sessionManager.getEntries()].map((entry) => JSON.stringify(entry)).join('\n') + '\n')
82
+ expect(events).toContain('entry_appended')
83
+ await runtime.newSession()
84
+ expect(runtime.session).not.toBe(first)
85
+ await runtime.session.prompt('/marker second')
86
+ await runtime.switchSession(firstFile)
87
+ expect(runtime.session.sessionFile).toBe(firstFile)
88
+ const marker = runtime.session.sessionManager.getEntries().find((entry) => entry.type === 'custom' && entry.customType === 'marker')!
89
+ await runtime.fork(marker.id, { position: 'at' })
90
+ expect(runtime.session.sessionFile).not.toBe(firstFile)
91
+ await runtime.reload()
92
+ await runtime.session.prompt('/marker reloaded')
93
+ expect(runtime.session.sessionManager.getEntries().some((entry) => entry.type === 'custom' && entry.customType === 'marker' && (entry.data as { text: string }).text === 'reloaded')).toBe(true)
94
+ expect(replacements).toHaveLength(4)
95
+ expect(before).toHaveBeenCalledTimes(4)
96
+ expect(transport.claim).toHaveBeenCalledTimes(1)
97
+ expect(transport.call.mock.calls.length).toBeGreaterThanOrEqual(6)
98
+ expect(transport.call.mock.calls.every(([call]) => ['terminal.resources', 'terminal.interrupt', 'accounts.session'].includes(call))).toBe(true)
99
+ expect(readFileSync(firstFile, 'utf8')).toContain(cwd)
100
+ expect(readdirSync(root)).not.toContain('.pi')
101
+ })
102
+
103
+ it('ignores retained plugin UI callbacks after new, reload and disposal', async () => {
104
+ const { runtime, host } = await openFixture()
105
+ const first = runtime.session.extensionRunner.createContext().ui
106
+ const retainedNotify = first.notify
107
+ first.notify('current')
108
+ expect(host.ui.notify).toHaveBeenCalledWith('current')
109
+ await runtime.newSession()
110
+ retainedNotify('late from previous session')
111
+ expect(host.ui.notify).toHaveBeenCalledTimes(1)
112
+ expect(await first.select('Stale selector', ['One'])).toBeUndefined()
113
+ const second = runtime.session.extensionRunner.createContext().ui
114
+ second.notify('replacement')
115
+ expect(host.ui.notify).toHaveBeenCalledTimes(2)
116
+ await runtime.reload()
117
+ second.notify('late from old resources')
118
+ expect(host.ui.notify).toHaveBeenCalledTimes(2)
119
+ const reloaded = runtime.session.extensionRunner.createContext().ui
120
+ reloaded.notify('reloaded')
121
+ expect(host.ui.notify).toHaveBeenCalledTimes(3)
122
+ await runtime.dispose()
123
+ reloaded.notify('late after exit')
124
+ expect(host.ui.notify).toHaveBeenCalledTimes(3)
125
+ })
126
+
127
+ it('returns immutable presentation state and routes submissions through the persistent session', async () => {
128
+ const { runtime } = await openFixture()
129
+ const first = runtime.snapshot()
130
+ expect(Object.isFrozen(first)).toBe(true)
131
+ expect(Object.isFrozen(first.queue.steering)).toBe(true)
132
+ expect(Object.isFrozen(first.context)).toBe(true)
133
+ expect(first.account).toBeNull()
134
+ await runtime.submit(' /marker from-runtime')
135
+ runtime.session.setSessionName('Updated title')
136
+ expect(first.name).toBeUndefined()
137
+ expect(runtime.snapshot().name).toBe('Updated title')
138
+ expect(runtime.session.sessionManager.getEntries().some(entry => entry.type === 'custom' && entry.customType === 'marker' && (entry.data as { text: string }).text === 'from-runtime')).toBe(true)
139
+ await runtime.dispose()
140
+ await expect(runtime.submit('late')).rejects.toThrow('closed')
141
+ })
142
+
143
+ it('refuses changed resource trust before invalidating the existing session or loading new code', async () => {
144
+ const { runtime, plugin, root } = await openFixture()
145
+ const existing = runtime.session
146
+ const marker = join(root, 'untrusted-code-ran')
147
+ writeFileSync(plugin, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'yes'); export default function () {}`)
148
+ transport.call.mockRejectedValue(new Error('Plugin code changed; review and restart.'))
149
+ await expect(runtime.reload()).rejects.toThrow('Plugin code changed')
150
+ await expect(runtime.newSession()).rejects.toThrow('Plugin code changed')
151
+ expect(runtime.session).toBe(existing)
152
+ await runtime.session.prompt('/marker still-active')
153
+ expect(readdirSync(root)).not.toContain('untrusted-code-ran')
154
+ expect(runtime.session.extensionRunner.getCommand('marker')).toBeDefined()
155
+ await expect(runtime.session.resourceLoader.reload()).rejects.toThrow('Plugin code changed')
156
+ expect(readdirSync(root)).not.toContain('untrusted-code-ran')
157
+ })
158
+
159
+ it('rejects cross-project resume/import before copying files or replacing the pinned session', async () => {
160
+ const { runtime, root, cwd, history } = await openFixture()
161
+ const other = join(root, 'other-project')
162
+ mkdirSync(other)
163
+ const imported = join(history, 'other.jsonl')
164
+ writeFileSync(imported, JSON.stringify({ type: 'session', version: 3, id: 'foreign', cwd: other, timestamp: new Date().toISOString() }) + '\n')
165
+ const current = runtime.session
166
+ const files = readdirSync(history)
167
+ await expect(runtime.switchSession(imported)).rejects.toThrow('another working folder')
168
+ await expect(runtime.importFromJsonl(imported, cwd)).rejects.toThrow('another working folder')
169
+ expect(runtime.session).toBe(current)
170
+ expect(readdirSync(history)).toEqual(files)
171
+ expect(() => assertTerminalSessionScope(imported, cwd, other)).toThrow('own working folder')
172
+ })
173
+
174
+ it('rejects symlink escapes and same-project history outside the prepared history folder', async () => {
175
+ const { runtime, root, cwd, history, args, host } = await openFixture()
176
+ const external = join(root, 'external.jsonl')
177
+ writeFileSync(external, JSON.stringify({ type: 'session', version: 3, id: 'external', cwd, timestamp: new Date().toISOString() }) + '\n')
178
+ const symlink = join(history, 'escaped.jsonl')
179
+ symlinkSync(external, symlink)
180
+ const original = runtime.session
181
+ await expect(runtime.switchSession(external)).rejects.toThrow('terminal history folder')
182
+ await expect(runtime.switchSession(symlink)).rejects.toThrow('terminal history folder')
183
+ await expect(createTerminalRuntime([...args, '--session', symlink], host)).rejects.toThrow('terminal history folder')
184
+ expect(runtime.session).toBe(original)
185
+ await runtime.importFromJsonl(external)
186
+ expect(runtime.session.sessionManager.getHeader()?.id).toBe('external')
187
+ expect(runtime.session.sessionFile).toBe(join(realpathSync(history), 'external.jsonl'))
188
+ })
189
+
190
+ it('starts a new conversation when the recorded history file was never written', async () => {
191
+ // Pi writes a session file only after its first message, so a restart before that has nothing to resume.
192
+ const f = fixture()
193
+ const unwritten = join(f.history, 'not-written-yet.jsonl')
194
+ const runtime = await createTerminalRuntime([...f.args, '--session', unwritten], f.host)
195
+ runtimes.push(runtime)
196
+ expect(runtime.session.sessionFile?.startsWith(realpathSync(f.history))).toBe(true)
197
+ expect(runtime.session.sessionFile).not.toBe(unwritten)
198
+ })
199
+
200
+ it('requires the prepared isolated account and refuses malformed session headers', async () => {
201
+ const f = fixture()
202
+ vi.stubEnv('PI_CODING_AGENT_DIR', '')
203
+ await expect(createTerminalRuntime(f.args, f.host)).rejects.toThrow('isolated account')
204
+ const invalid = join(f.root, 'invalid.jsonl')
205
+ writeFileSync(invalid, 'not a session\n')
206
+ expect(() => assertTerminalSessionScope(invalid, f.cwd)).toThrow('readable native session header')
207
+ writeFileSync(invalid, JSON.stringify({ type: 'session', cwd: 'relative' }))
208
+ expect(() => assertTerminalSessionScope(invalid, f.cwd)).toThrow('absolute working folder')
209
+ })
210
+
211
+ it('loads the pinned rich renderers without opening a model or reading the clipboard', async () => {
212
+ expect(() => assertPiAdapterVersion('1.3.0')).toThrow('requires Pi 1.2.0')
213
+ const adapter = await loadPiAdapter()
214
+ expect(adapter.builtins.map((item) => typeof item === 'function' ? '' : item.name)).toEqual(['llama.cpp', 'codemode', 'tool-search', 'mcp'])
215
+ for (const name of ['read', 'bash', 'edit', 'write']) {
216
+ const renderers = withBuiltInRenderers(name, undefined)
217
+ expect(renderers?.renderCall).toBeTypeOf('function')
218
+ expect(renderers?.renderResult).toBeTypeOf('function')
219
+ }
220
+ const custom = { renderCall: vi.fn() }
221
+ expect(withBuiltInRenderers('read', custom)?.renderCall).toBe(custom.renderCall)
222
+ expect(adapter.readClipboardText).toBeTypeOf('function')
223
+ expect(adapter.readClipboardImage).toBeTypeOf('function')
224
+ })
225
+ })
@@ -0,0 +1,403 @@
1
+ import {
2
+ createAgentSession, createAgentSessionRuntime, DefaultResourceLoader, ModelRuntime, resolveCliModel,
3
+ SessionManager, SettingsManager,
4
+ type AgentSession, type AgentSessionEventListener, type AgentSessionRuntime, type AgentSessionServices,
5
+ type CreateAgentSessionRuntimeFactory, type ExtensionUIContext
6
+ } from '@earendil-works/pi-coding-agent'
7
+ import { closeSync, existsSync, openSync, readSync, realpathSync } from 'node:fs'
8
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
9
+
10
+ import terminalExtension from '../piExtension'
11
+ import { claimTerminalControl, controlCall } from '../control'
12
+ import { loadPiAdapter } from './piAdapter'
13
+ import type { SessionAccount } from '../accountsMenu'
14
+ import type { QuestionAnswer } from '../../sessions/registry'
15
+ import type { TerminalQuestion } from './questions'
16
+ import { sessionUiContext } from './sessionUi'
17
+
18
+ export interface TerminalArguments {
19
+ appendSystemPrompt: string[]
20
+ extensions: string[]
21
+ skills: string[]
22
+ prompts: string[]
23
+ themes: string[]
24
+ sessionDir: string
25
+ session?: string
26
+ model?: string
27
+ }
28
+
29
+ /** The launch specification comes from Terminals.prepare(), never from Pi's own discovery. */
30
+ export function parseTerminalArguments(args: string[]): TerminalArguments {
31
+ const result: TerminalArguments = { appendSystemPrompt: [], extensions: [], skills: [], prompts: [], themes: [], sessionDir: '' }
32
+ const lists: Record<string, string[]> = {
33
+ '--append-system-prompt': result.appendSystemPrompt, '--extension': result.extensions,
34
+ '--skill': result.skills, '--prompt-template': result.prompts, '--theme': result.themes
35
+ }
36
+ const flags = new Set(['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'])
37
+ for (let index = 0; index < args.length; index++) {
38
+ const argument = args[index]
39
+ if (flags.has(argument)) continue
40
+ if (!(argument in lists) && !['--session-dir', '--session', '--model'].includes(argument)) {
41
+ throw new Error(`Unsupported prepared terminal argument: ${argument}`)
42
+ }
43
+ const value = args[++index]
44
+ if (!value || value.startsWith('--')) throw new Error(`${argument} needs a value.`)
45
+ if (argument in lists) lists[argument].push(value)
46
+ else if (argument === '--session-dir') result.sessionDir = value
47
+ else if (argument === '--session') result.session = value
48
+ else result.model = value
49
+ }
50
+ if (!result.sessionDir) throw new Error('The Harness terminal requires its prepared --session-dir.')
51
+ if (!result.extensions.length) throw new Error('The Harness terminal requires its prepared bridge extension.')
52
+ return result
53
+ }
54
+
55
+ export interface TerminalRuntimeHost {
56
+ ui: ExtensionUIContext
57
+ shutdown(): void
58
+ notify(message: string): void
59
+ changed(): void
60
+ resources?(services: AgentSessionServices): void
61
+ }
62
+
63
+ type SessionChangeListener = (session: AgentSession) => void | Promise<void>
64
+ type NewOptions = Parameters<AgentSessionRuntime['newSession']>[0]
65
+ type SwitchOptions = Parameters<AgentSessionRuntime['switchSession']>[1]
66
+ type ForkOptions = Parameters<AgentSessionRuntime['fork']>[1]
67
+ type TreeOptions = Parameters<AgentSession['navigateTree']>[1]
68
+
69
+ export interface TerminalSnapshot {
70
+ readonly sessionId: string
71
+ readonly sessionFile?: string
72
+ readonly name?: string
73
+ readonly cwd: string
74
+ readonly account: Readonly<SessionAccount> | null
75
+ readonly model?: Readonly<{ id: string; provider: string; name: string; reasoning: boolean; contextWindow: number }>
76
+ readonly thinkingLevel: AgentSession['thinkingLevel']
77
+ readonly isStreaming: boolean
78
+ readonly idle: boolean
79
+ readonly hasUserMessages: boolean
80
+ readonly hideThinking: boolean
81
+ readonly context: Readonly<{ percent: number | null; window: number }>
82
+ readonly costUsd: number
83
+ readonly subscription: boolean
84
+ readonly queue: Readonly<{ steering: readonly string[]; followUp: readonly string[] }>
85
+ }
86
+
87
+ export interface TerminalDecision extends TerminalQuestion { type: 'approval' | 'question'; target?: string; action?: string; rememberable?: boolean }
88
+
89
+ export interface TerminalPermissionState {
90
+ readonly full: boolean
91
+ readonly globalTools: readonly string[]
92
+ }
93
+
94
+ export interface TerminalRuntime {
95
+ pending(): Promise<TerminalDecision[]>
96
+ approve(requestId: string, decision: 'approved' | 'denied', alwaysAllowTool?: boolean): Promise<void>
97
+ permissions(): Promise<TerminalPermissionState>
98
+ setFullPermission(enabled: boolean): Promise<void>
99
+ setGlobalTool(tool: string, allowed: boolean): Promise<void>
100
+ answer(requestId: string, answer: QuestionAnswer): Promise<void>
101
+ answerBatch(batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): Promise<void>
102
+ snapshot(): TerminalSnapshot
103
+ submit(text: string, followUp?: boolean): Promise<void>
104
+ readonly session: AgentSession
105
+ readonly services: AgentSessionServices
106
+ readonly cwd: string
107
+ readonly sessionDirectory: string
108
+ subscribe(listener: AgentSessionEventListener): () => void
109
+ onSessionChange(listener: SessionChangeListener): () => void
110
+ onBeforeSessionChange(listener: () => void): () => void
111
+ newSession(options?: NewOptions): ReturnType<AgentSessionRuntime['newSession']>
112
+ switchSession(path: string, options?: SwitchOptions): ReturnType<AgentSessionRuntime['switchSession']>
113
+ fork(entryId: string, options?: ForkOptions): ReturnType<AgentSessionRuntime['fork']>
114
+ navigateTree(targetId: string, options?: TreeOptions): ReturnType<AgentSession['navigateTree']>
115
+ importFromJsonl(path: string, cwdOverride?: string): ReturnType<AgentSessionRuntime['importFromJsonl']>
116
+ installationDeviceId(): Promise<string>
117
+ reload(): Promise<void>
118
+ interrupt(): Promise<void>
119
+ dispose(): Promise<void>
120
+ }
121
+
122
+ /** Saved native history stays in the prepared project folder, including symlink targets. */
123
+ export function assertTerminalHistoryPath(path: string, sessionDirectory: string): void {
124
+ const within = relative(realpathSync(sessionDirectory), realpathSync(path))
125
+ if (!within || within === '..' || within.startsWith(`..${sep}`) || isAbsolute(within)) {
126
+ throw new Error('Resume only from this terminal history folder. Use /import for another native session file in this working folder.')
127
+ }
128
+ }
129
+
130
+ /** Inspect the header before any native switch/import can invalidate a session or copy a file. */
131
+ export function assertTerminalSessionScope(path: string, cwd: string, cwdOverride?: string): void {
132
+ if (cwdOverride !== undefined && realpathSync(resolve(cwdOverride)) !== realpathSync(cwd)) {
133
+ throw new Error('Resume this conversation in a terminal opened for its own working folder.')
134
+ }
135
+ const descriptor = openSync(path, 'r')
136
+ let first: string
137
+ try {
138
+ const buffer = Buffer.alloc(65_536)
139
+ const size = readSync(descriptor, buffer, 0, buffer.length, 0)
140
+ const text = buffer.subarray(0, size).toString('utf8').replace(/^\uFEFF/, '')
141
+ const newline = text.indexOf('\n')
142
+ if (newline === -1 && size === buffer.length) throw new Error('The native session header is too large.')
143
+ first = newline === -1 ? text : text.slice(0, newline)
144
+ } finally {
145
+ closeSync(descriptor)
146
+ }
147
+ let header: { type?: string; cwd?: string }
148
+ try {
149
+ header = JSON.parse(first) as typeof header
150
+ } catch {
151
+ throw new Error('This file does not have a readable native session header.')
152
+ }
153
+ if (header.type !== 'session' || !header.cwd || !isAbsolute(header.cwd)) {
154
+ throw new Error('The native session must record its original absolute working folder.')
155
+ }
156
+ if (realpathSync(header.cwd) !== realpathSync(cwd)) {
157
+ throw new Error('This conversation belongs to another working folder. Open its terminal to keep project permissions and context isolated.')
158
+ }
159
+ }
160
+
161
+ export async function createTerminalRuntime(args: string[], host: TerminalRuntimeHost): Promise<TerminalRuntime> {
162
+ const prepared = parseTerminalArguments(args)
163
+ const cwd = realpathSync(process.cwd())
164
+ const agentDirValue = process.env.PI_CODING_AGENT_DIR
165
+ if (!agentDirValue || !isAbsolute(agentDirValue)) throw new Error('The Harness terminal requires its isolated account directory.')
166
+ const agentDir = realpathSync(agentDirValue)
167
+ const sessionDirectory = realpathSync(resolve(cwd, prepared.sessionDir))
168
+ // Pi writes a session file only after its first message, so a restart before that has nothing to resume.
169
+ const resume = prepared.session && existsSync(resolve(cwd, prepared.session)) ? resolve(cwd, prepared.session) : null
170
+ if (resume) {
171
+ assertTerminalHistoryPath(resume, sessionDirectory)
172
+ assertTerminalSessionScope(resume, cwd)
173
+ }
174
+
175
+ // Claim before loading any plugin code and retain it in the trusted process across reloads.
176
+ await claimTerminalControl()
177
+ const identity = await controlCall<SessionAccount>('accounts.session').catch(() => null)
178
+ const account = identity && typeof identity.accountId === 'string' && typeof identity.label === 'string'
179
+ ? Object.freeze({ accountId: identity.accountId, label: identity.label }) : null
180
+ const adapter = await loadPiAdapter()
181
+ const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, 'auth.json'), modelsPath: join(agentDir, 'models.json') })
182
+ const listeners = new Set<AgentSessionEventListener>()
183
+ const changes = new Set<SessionChangeListener>()
184
+ const beforeChanges = new Set<() => void>()
185
+ let unsubscribe: (() => void) | undefined
186
+ let changing = false
187
+ let disposed = false
188
+ let uiGeneration = 0
189
+ let deviceIdPromise: Promise<string> | null = null
190
+ const installationDeviceId = (): Promise<string> => {
191
+ if (!deviceIdPromise) {
192
+ deviceIdPromise = controlCall<string>('accounts.deviceId').catch((error: unknown) => {
193
+ deviceIdPromise = null
194
+ throw error
195
+ })
196
+ }
197
+ return deviceIdPromise
198
+ }
199
+ const currentUi = () => {
200
+ const generation = uiGeneration
201
+ return sessionUiContext(host.ui, () => !disposed && generation === uiGeneration)
202
+ }
203
+
204
+ const factory: CreateAgentSessionRuntimeFactory = async ({ cwd: targetCwd, sessionManager, sessionStartEvent }) => {
205
+ if (realpathSync(targetCwd) !== cwd) throw new Error('A native session cannot change this terminal working folder.')
206
+ const settingsManager = SettingsManager.create(cwd, agentDir)
207
+ const resourceLoader = new DefaultResourceLoader({
208
+ cwd, agentDir,
209
+ // The real settings still control models and tools; empty discovery settings prevent a
210
+ // second resource decision outside the Harness's trusted prepare() result.
211
+ settingsManager: SettingsManager.inMemory({}),
212
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true,
213
+ additionalExtensionPaths: prepared.extensions.slice(0, -1),
214
+ additionalSkillPaths: prepared.skills, additionalPromptTemplatePaths: prepared.prompts,
215
+ additionalThemePaths: prepared.themes,
216
+ extensionFactories: [...adapter.builtins, { name: 'harness-terminal-bridge', factory: terminalExtension, hidden: true }],
217
+ appendSystemPromptOverride: (base) => [...base, ...prepared.appendSystemPrompt]
218
+ })
219
+ const reloadResources = resourceLoader.reload.bind(resourceLoader)
220
+ resourceLoader.reload = async (options) => {
221
+ // Recheck the host's exact package trust and attachment identities before every load,
222
+ // including the reload performed internally by AgentSession.reload().
223
+ await controlCall('terminal.resources', {}, 30_000)
224
+ await reloadResources(options)
225
+ }
226
+ await resourceLoader.reload()
227
+ const errors = resourceLoader.getExtensions().errors
228
+ if (errors.length) throw new Error(errors.map((error) => `${error.path}: ${error.error}`).join('\n'))
229
+ let selected: ReturnType<typeof resolveCliModel> | undefined
230
+ if (prepared.model && sessionStartEvent?.reason === 'startup') {
231
+ selected = resolveCliModel({ cliModel: prepared.model, modelRuntime })
232
+ if (selected.error || !selected.model) throw new Error(selected.error ?? `Model ${prepared.model} is unavailable.`)
233
+ }
234
+ const created = await createAgentSession({
235
+ cwd, agentDir, modelRuntime, settingsManager, resourceLoader, sessionManager, sessionStartEvent,
236
+ ...(selected?.model ? { model: selected.model } : {}),
237
+ ...(selected?.thinkingLevel ? { thinkingLevel: selected.thinkingLevel } : {})
238
+ })
239
+ const services: AgentSessionServices = { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics: [] }
240
+ host.resources?.(services)
241
+ return { ...created, services, diagnostics: services.diagnostics }
242
+ }
243
+ const native = await createAgentSessionRuntime(factory, {
244
+ cwd, agentDir,
245
+ sessionManager: resume ? SessionManager.open(resume, sessionDirectory) : SessionManager.create(cwd, sessionDirectory),
246
+ sessionStartEvent: { type: 'session_start', reason: 'startup' }
247
+ })
248
+
249
+ const before = () => {
250
+ uiGeneration += 1
251
+ unsubscribe?.()
252
+ unsubscribe = undefined
253
+ for (const listener of beforeChanges) listener()
254
+ }
255
+ const announce = async () => {
256
+ for (const listener of changes) await listener(native.session)
257
+ host.changed()
258
+ }
259
+ const exclusive = async <T>(action: () => Promise<T>): Promise<T> => {
260
+ if (disposed) throw new Error('The terminal runtime has closed.')
261
+ if (changing) throw new Error('A native session change is already in progress.')
262
+ if (!native.session.isIdle) throw new Error('Stop the current response before changing or reloading this session.')
263
+ changing = true
264
+ try {
265
+ await controlCall('terminal.resources', {}, 30_000)
266
+ return await action()
267
+ } finally {
268
+ changing = false
269
+ }
270
+ }
271
+ const decide = async (call: string, params: Record<string, unknown>): Promise<void> => {
272
+ if (disposed) throw new Error('The terminal runtime has closed.')
273
+ await controlCall(call, params)
274
+ }
275
+ const runtime: TerminalRuntime = {
276
+ async pending() {
277
+ if (disposed) return []
278
+ return (await controlCall<{ requests: TerminalDecision[] }>('terminal.pending')).requests
279
+ },
280
+ approve: (requestId, decision, alwaysAllowTool = false) => decide('terminal.approve', { requestId, decision, alwaysAllow: alwaysAllowTool }),
281
+ async permissions() {
282
+ if (disposed) return { full: false, globalTools: [] }
283
+ return controlCall<TerminalPermissionState>('terminal.permissions.get')
284
+ },
285
+ setFullPermission: async enabled => { await decide('terminal.permissions.setFull', { enabled }) },
286
+ setGlobalTool: async (tool, allowed) => { await decide('terminal.permissions.setTool', { tool, allowed }) },
287
+ answer: (requestId, answer) => decide('terminal.answer', { requestId, answer }),
288
+ answerBatch: (batchId, answers) => decide('terminal.answers', { batchId, answers }),
289
+ snapshot() {
290
+ const session = native.session
291
+ const usage = session.getContextUsage()
292
+ const model = session.model?.id === 'unknown' ? undefined : session.model
293
+ return Object.freeze({
294
+ sessionId: session.sessionId, sessionFile: session.sessionFile, name: session.sessionName,
295
+ cwd, account,
296
+ model: model ? Object.freeze({ id: model.id, provider: model.provider, name: model.name, reasoning: model.reasoning, contextWindow: model.contextWindow }) : undefined,
297
+ thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, idle: session.isIdle,
298
+ hasUserMessages: session.state.messages.some(message => message.role === 'user'),
299
+ hideThinking: session.settingsManager.getHideThinkingBlock(),
300
+ context: Object.freeze({ percent: usage?.percent ?? null, window: usage?.contextWindow ?? model?.contextWindow ?? 0 }),
301
+ costUsd: session.getSessionStats().cost,
302
+ subscription: model ? session.modelRuntime.isUsingSubscription(model.provider) : false,
303
+ queue: Object.freeze({ steering: Object.freeze([...session.getSteeringMessages()]), followUp: Object.freeze([...session.getFollowUpMessages()]) })
304
+ })
305
+ },
306
+ async submit(text, followUp = false) {
307
+ if (disposed) throw new Error('The terminal runtime has closed.')
308
+ if (changing) throw new Error('Wait for the native session change to finish before submitting.')
309
+ const input = text.trimStart().startsWith('/') ? text.trimStart() : text
310
+ await native.session.prompt(input, native.session.isStreaming ? { streamingBehavior: followUp ? 'followUp' : 'steer' } : {})
311
+ },
312
+ get session() { return native.session },
313
+ get services() { return native.services },
314
+ cwd, sessionDirectory,
315
+ subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener) } },
316
+ onSessionChange(listener) { changes.add(listener); return () => { changes.delete(listener) } },
317
+ onBeforeSessionChange(listener) { beforeChanges.add(listener); return () => { beforeChanges.delete(listener) } },
318
+ newSession: (options) => exclusive(() => native.newSession(options)),
319
+ switchSession: (path, options) => exclusive(async () => {
320
+ const target = resolve(cwd, path)
321
+ assertTerminalHistoryPath(target, sessionDirectory)
322
+ assertTerminalSessionScope(target, cwd, options?.cwdOverride)
323
+ return native.switchSession(target, options)
324
+ }),
325
+ fork: (entryId, options) => exclusive(() => native.fork(entryId, options)),
326
+ navigateTree: (targetId, options) => exclusive(async () => {
327
+ const result = await native.session.navigateTree(targetId, options)
328
+ if (!result.cancelled) await announce()
329
+ return result
330
+ }),
331
+ importFromJsonl: (path, cwdOverride) => exclusive(async () => {
332
+ const target = resolve(cwd, path)
333
+ assertTerminalSessionScope(target, cwd, cwdOverride)
334
+ return native.importFromJsonl(target, cwdOverride)
335
+ }),
336
+ installationDeviceId,
337
+ reload: () => exclusive(async () => {
338
+ // Refuse a revoked or changed resource set before tearing down the currently usable UI.
339
+ await controlCall('terminal.resources', {}, 30_000)
340
+ await runtime.interrupt()
341
+ before()
342
+ await native.session.reload({ beforeSessionStart: async () => {
343
+ native.session.extensionRunner.setUIContext(currentUi(), 'tui')
344
+ observeSession()
345
+ await announce()
346
+ } })
347
+ host.changed()
348
+ }),
349
+ async interrupt() {
350
+ // Socket-backed questions and approvals must settle before Pi can finish aborting tools.
351
+ await controlCall('terminal.interrupt', {}, 15_000).catch(() => {})
352
+ await native.session.abort()
353
+ },
354
+ async dispose() {
355
+ if (disposed) return
356
+ disposed = true
357
+ try {
358
+ await runtime.interrupt()
359
+ await native.dispose()
360
+ } finally {
361
+ unsubscribe?.()
362
+ listeners.clear()
363
+ changes.clear()
364
+ beforeChanges.clear()
365
+ }
366
+ }
367
+ }
368
+ const observeSession = () => {
369
+ unsubscribe?.()
370
+ unsubscribe = native.session.subscribe(event => {
371
+ for (const listener of listeners) listener(event)
372
+ host.changed()
373
+ })
374
+ }
375
+ const bind = async () => {
376
+ const session = native.session
377
+ observeSession()
378
+ await session.bindExtensions({
379
+ uiContext: currentUi(), mode: 'tui', shutdownHandler: () => host.shutdown(),
380
+ abortHandler: () => { void runtime.interrupt().catch((error) => host.notify(error instanceof Error ? error.message : String(error))) },
381
+ onError: (error) => host.notify(`${error.event}: ${error.error}`),
382
+ commandContextActions: {
383
+ waitForIdle: () => native.session.waitForIdle(),
384
+ newSession: (options) => runtime.newSession(options),
385
+ switchSession: (path, options) => runtime.switchSession(path, options),
386
+ fork: (entryId, options) => runtime.fork(entryId, options),
387
+ navigateTree: (targetId, options) => runtime.navigateTree(targetId, options),
388
+ reload: () => runtime.reload()
389
+ }
390
+ })
391
+ await announce()
392
+ if (native.modelFallbackMessage) host.notify(native.modelFallbackMessage)
393
+ }
394
+ native.setBeforeSessionInvalidate(before)
395
+ native.setRebindSession(bind)
396
+ try {
397
+ await bind()
398
+ } catch (error) {
399
+ await runtime.dispose()
400
+ throw error
401
+ }
402
+ return runtime
403
+ }