@xingular/harness 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -0
@@ -0,0 +1,218 @@
1
+ import type { AgentSession, ExtensionUIContext } from '@earendil-works/pi-coding-agent'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { createTerminalCommands, type TerminalCommandHost } from './commands'
4
+
5
+ function setup() {
6
+ const ui = {
7
+ notify: vi.fn(), select: vi.fn(), confirm: vi.fn(), input: vi.fn(),
8
+ theme: { name: 'harness-titanium' }
9
+ } as unknown as ExtensionUIContext
10
+ const session = {
11
+ extensionRunner: { getRegisteredCommands: () => [{ name: 'accounts', description: 'Manage accounts' }] },
12
+ resourceLoader: {
13
+ getPrompts: () => ({ prompts: [{ name: 'review', description: 'Review code' }] }),
14
+ getSkills: () => ({ skills: [{ name: 'diagnose', description: 'Diagnose a problem' }] })
15
+ },
16
+ settingsManager: { getEnableSkillCommands: () => true },
17
+ compact: vi.fn(), reload: vi.fn(), setSessionName: vi.fn(),
18
+ getLastAssistantText: vi.fn(() => 'Actual assistant response'),
19
+ getSessionStats: () => ({ sessionId: 'real-session', tokens: { input: 7 } }),
20
+ exportToHtml: vi.fn(async (path: string) => path), exportToJsonl: vi.fn((path: string) => path)
21
+ } as unknown as AgentSession
22
+ const runtime = { newSession: vi.fn(), switchSession: vi.fn(), fork: vi.fn(), importFromJsonl: vi.fn(), reload: vi.fn(), navigateTree: vi.fn(), installationDeviceId: vi.fn(async () => '12345678-1234-4234-8234-123456789abc') }
23
+ const host: TerminalCommandHost = {
24
+ session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
25
+ cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
26
+ refresh: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
27
+ hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes'
28
+ }
29
+ return { host, session, runtime, ui, commands: createTerminalCommands(host) }
30
+ }
31
+
32
+ describe('owned terminal commands', () => {
33
+ it('leaves ordinary prompts and supported extension/resource commands for the SDK', async () => {
34
+ const { commands, ui } = setup()
35
+ for (const text of ['hello', '/accounts', '/review module', '/skill:diagnose trace']) {
36
+ expect(await commands.execute(text)).toBe(false)
37
+ }
38
+ expect(ui.notify).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it('consumes unknown slash commands without sending them to the model', async () => {
42
+ const { commands, ui } = setup()
43
+ expect(await commands.execute('/typo dangerous operation')).toBe(true)
44
+ expect(ui.notify).toHaveBeenCalledWith(expect.stringContaining('Unknown command: /typo'), 'error')
45
+ })
46
+
47
+ it('populates completion from builtins and loaded resources', () => {
48
+ const { commands } = setup()
49
+ const names = commands.completions().map(item => item.name)
50
+ expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'skill:diagnose']))
51
+ expect(new Set(names).size).toBe(names.length)
52
+ })
53
+
54
+ it('respects disabled skill commands', () => {
55
+ const { commands, session } = setup()
56
+ vi.spyOn(session.settingsManager, 'getEnableSkillCommands').mockReturnValue(false)
57
+ expect(commands.completions().map(item => item.name)).not.toContain('skill:diagnose')
58
+ })
59
+
60
+ it('uses the scope checked runtime for explicit resume and import paths', async () => {
61
+ const { commands, runtime } = setup()
62
+ await commands.execute('/resume /isolated/account/history/session.jsonl')
63
+ expect(runtime.switchSession).toHaveBeenCalledWith('/isolated/account/history/session.jsonl')
64
+ await commands.execute('/import other.jsonl')
65
+ expect(runtime.importFromJsonl).toHaveBeenCalledWith('/isolated/project/other.jsonl')
66
+ await commands.execute('/new')
67
+ expect(runtime.newSession).toHaveBeenCalledOnce()
68
+ })
69
+
70
+ it('passes the Harness installation device ID to direct OAuth sign-in', async () => {
71
+ const { commands, session, runtime } = setup()
72
+ const login = vi.fn(async (_provider: string, _type: string, _interaction: unknown, options?: { getDeviceId?: () => string }) => options)
73
+ Object.assign(session, { modelRuntime: {
74
+ getProviders: () => [{ id: 'openai', name: 'OpenAI', auth: { oauth: {}, apiKey: undefined } }],
75
+ login
76
+ } })
77
+
78
+ await commands.execute('/login openai')
79
+
80
+ expect(runtime.installationDeviceId).toHaveBeenCalledOnce()
81
+ expect(login.mock.calls[0]?.[3]?.getDeviceId?.()).toBe('12345678-1234-4234-8234-123456789abc')
82
+ })
83
+
84
+ it('reports rejected navigation without claiming success', async () => {
85
+ const { commands, runtime, ui } = setup()
86
+ runtime.switchSession.mockRejectedValue(new Error('Session is outside the prepared project scope'))
87
+ expect(await commands.execute('/resume /isolated/account/history/session.jsonl')).toBe(true)
88
+ expect(ui.notify).toHaveBeenCalledWith('Session is outside the prepared project scope', 'error')
89
+ })
90
+
91
+ it('copies actual assistant text and handles missing responses', async () => {
92
+ const { commands, host, session, ui } = setup()
93
+ await commands.execute('/copy')
94
+ expect(host.copyText).toHaveBeenCalledWith('Actual assistant response')
95
+ vi.mocked(session.getLastAssistantText).mockReturnValue(undefined)
96
+ await commands.execute('/copy')
97
+ expect(ui.notify).toHaveBeenCalledWith('No assistant response to copy yet', 'error')
98
+ })
99
+
100
+ it('keeps multiline compaction instructions and session names', async () => {
101
+ const { commands, session } = setup()
102
+ await commands.execute('/compact Keep the API\nand authentication details')
103
+ expect(session.compact).toHaveBeenCalledWith('Keep the API\nand authentication details')
104
+ await commands.execute('/name Unicode 知識 🧠')
105
+ expect(session.setSessionName).toHaveBeenCalledWith('Unicode 知識 🧠')
106
+ })
107
+
108
+ it('exports supported formats through native session APIs', async () => {
109
+ const { commands, session } = setup()
110
+ await commands.execute('/export notes.jsonl')
111
+ expect(session.exportToJsonl).toHaveBeenCalledWith('/isolated/project/notes.jsonl')
112
+ await commands.execute('/export notes.html')
113
+ expect(session.exportToHtml).toHaveBeenCalledWith('/isolated/project/notes.html', { themeName: 'harness-titanium' })
114
+ })
115
+
116
+ it('does not upload anything after a declined share confirmation', async () => {
117
+ const { commands, ui, session } = setup()
118
+ vi.mocked(ui.confirm).mockResolvedValue(false)
119
+ await commands.execute('/share')
120
+ expect(ui.confirm).toHaveBeenCalledOnce()
121
+ expect(session.exportToHtml).not.toHaveBeenCalled()
122
+ })
123
+
124
+ it('shows actual application keyboard bindings and release notes', async () => {
125
+ const { commands, ui } = setup()
126
+ await commands.execute('/hotkeys')
127
+ expect(ui.notify).toHaveBeenCalledWith('Escape: interrupt', 'info')
128
+ await commands.execute('/changelog')
129
+ expect(ui.notify).toHaveBeenCalledWith('Harness release notes', 'info')
130
+ })
131
+
132
+ it('exits through the application lifecycle', async () => {
133
+ const { commands, host } = setup()
134
+ await commands.execute('/quit')
135
+ expect(host.shutdown).toHaveBeenCalledOnce()
136
+ })
137
+ it('validates thinking levels before mutating the session', async () => {
138
+ const { commands, session, ui } = setup()
139
+ Object.assign(session, { getAvailableThinkingLevels: () => ['off', 'high'], setThinkingLevel: vi.fn() })
140
+ await commands.execute('/thinking high')
141
+ expect(session.setThinkingLevel).toHaveBeenCalledWith('high', { persist: true })
142
+ await commands.execute('/thinking fabricated')
143
+ expect(session.setThinkingLevel).toHaveBeenCalledTimes(1)
144
+ expect(ui.notify).toHaveBeenCalledWith('Available thinking levels: off, high', 'error')
145
+ })
146
+
147
+ it('clones the real leaf through the runtime without inventing history', async () => {
148
+ const { commands, session, runtime, host } = setup()
149
+ Object.assign(session, { sessionManager: { getLeafId: () => 'leaf-id' } })
150
+ runtime.fork.mockResolvedValue({ cancelled: false })
151
+ await commands.execute('/clone')
152
+ expect(runtime.fork).toHaveBeenCalledWith('leaf-id', { position: 'at' })
153
+ expect(host.setEditorText).toHaveBeenCalledWith('')
154
+ })
155
+
156
+ it('uses masked input for secret provider credentials', async () => {
157
+ const { commands, session, host, ui } = setup()
158
+ const login = vi.fn(async (_provider, _type, interaction) => {
159
+ expect(await interaction.prompt({ type: 'secret', message: 'API key' })).toBe('private-key')
160
+ })
161
+ Object.assign(session, { modelRuntime: {
162
+ getProviders: () => [{ id: 'provider', name: 'Provider', auth: { apiKey: { login: true } } }], login
163
+ } })
164
+ host.secretInput = vi.fn(async () => 'private-key')
165
+ await commands.execute('/login provider')
166
+ expect(host.secretInput).toHaveBeenCalledWith('API key', undefined, { signal: undefined })
167
+ expect(ui.input).not.toHaveBeenCalled()
168
+ expect(ui.notify).not.toHaveBeenCalledWith(expect.stringContaining('private-key'), expect.anything())
169
+ })
170
+
171
+ it('fails closed when secure credential input is unavailable', async () => {
172
+ const { commands, session, ui } = setup()
173
+ Object.assign(session, { modelRuntime: {
174
+ getProviders: () => [{ id: 'provider', name: 'Provider', auth: { apiKey: { login: true } } }],
175
+ login: async (_provider: string, _type: string, interaction: { prompt(prompt: unknown): Promise<string> }) => interaction.prompt({ type: 'secret', message: 'API key' })
176
+ } })
177
+ await commands.execute('/login provider')
178
+ expect(ui.input).not.toHaveBeenCalled()
179
+ expect(ui.notify).toHaveBeenCalledWith('Secure credential entry is unavailable', 'error')
180
+ })
181
+
182
+ it('rejects resume paths outside account history before runtime navigation', async () => {
183
+ const { commands, runtime, ui } = setup()
184
+ await commands.execute('/resume /other-account/session.jsonl')
185
+ expect(runtime.switchSession).not.toHaveBeenCalled()
186
+ expect(ui.notify).toHaveBeenCalledWith(expect.stringContaining('this account history folder'), 'error')
187
+ })
188
+
189
+ it('routes trust through the existing protected plugin workflow', async () => {
190
+ const { commands, session } = setup()
191
+ Object.assign(session, { prompt: vi.fn(async () => {}) })
192
+ vi.spyOn(session.extensionRunner, 'getRegisteredCommands').mockReturnValue([{ name: 'plugins' }] as never)
193
+ await commands.execute('/trust')
194
+ expect(session.prompt).toHaveBeenCalledWith('/plugins')
195
+ })
196
+
197
+ it('opens a reviewable Harness issue draft without attaching session history', async () => {
198
+ const { commands, host, ui } = setup()
199
+ host.openExternal = vi.fn(async () => {})
200
+ vi.mocked(ui.confirm).mockResolvedValue(false)
201
+ await commands.execute('/bug Unicode issue 知識')
202
+ expect(host.openExternal).not.toHaveBeenCalled()
203
+ vi.mocked(ui.confirm).mockResolvedValue(true)
204
+ await commands.execute('/bug Unicode issue 知識')
205
+ const url = new URL(vi.mocked(host.openExternal).mock.calls[0][0])
206
+ expect(url.pathname).toBe('/tanntnny/xingular-harness/issues/new')
207
+ expect(url.searchParams.get('title')).toBe('Unicode issue 知識')
208
+ expect(url.searchParams.get('body')).toContain('Harness')
209
+ })
210
+
211
+ it('reloads through the stable runtime lifecycle', async () => {
212
+ const { commands, runtime, session } = setup()
213
+ await commands.execute('/reload')
214
+ expect(runtime.reload).toHaveBeenCalledOnce()
215
+ expect(session.reload).not.toHaveBeenCalled()
216
+ })
217
+
218
+ })
@@ -0,0 +1,361 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { isAbsolute, join, relative, resolve } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+ import { ModelSelectorComponent, TreeSelectorComponent, SessionManager, type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent'
7
+ import { SettingsPanel, type TerminalSettingItem } from './settings'
8
+ import manifest from '../../../package.json'
9
+ import type { TerminalRuntime } from './runtime'
10
+
11
+ export interface TerminalCommandHost {
12
+ session(): AgentSession
13
+ /** Navigation is scope checked by the Harness runtime adapter. */
14
+ runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId'>
15
+ ui: ExtensionUIContext
16
+ cwd(): string
17
+ sessionDirectory(): string
18
+ refresh(): void
19
+ shutdown(): void
20
+ setEditorText(text: string): void
21
+ copyText(text: string): Promise<void>
22
+ secretInput?(title: string, placeholder?: string, options?: { signal?: AbortSignal }): Promise<string | undefined>
23
+ openExternal?(url: string): Promise<void>
24
+ suspend?(): Promise<void>
25
+ hotkeys?(): string
26
+ changelog?(): Promise<string>
27
+ }
28
+
29
+ export interface TerminalCommandCompletion { name: string; description?: string }
30
+
31
+ const BUILTINS: TerminalCommandCompletion[] = [
32
+ { name: 'help', description: 'Available terminal commands' },
33
+ { name: 'new', description: 'Start a new session' },
34
+ { name: 'resume', description: 'Resume a session in this account and folder' },
35
+ { name: 'tree', description: 'Navigate the session tree' },
36
+ { name: 'fork', description: 'Fork from a user message' },
37
+ { name: 'model', description: 'Choose a model' },
38
+ { name: 'thinking', description: 'Choose thinking effort' },
39
+ { name: 'clone', description: 'Clone the current branch to a new session' },
40
+ { name: 'scoped-models', description: 'Choose models for cycling' },
41
+ { name: 'settings', description: 'Session and terminal settings' },
42
+ { name: 'name', description: 'Name the current session' },
43
+ { name: 'session', description: 'Current session statistics' },
44
+ { name: 'compact', description: 'Compact the current context' },
45
+ { name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
46
+ { name: 'trust', description: 'Review plugin code and project trust' },
47
+ { name: 'bug', description: 'Open a Harness issue draft' },
48
+ { name: 'login', description: 'Sign in to a provider in this isolated account' },
49
+ { name: 'logout', description: 'Remove a stored provider credential' },
50
+ { name: 'export', description: 'Export this branch to HTML or JSONL' },
51
+ { name: 'import', description: 'Import a session JSONL file' },
52
+ { name: 'share', description: 'Share an HTML export as a secret GitHub gist' },
53
+ { name: 'copy', description: 'Copy the last assistant response' },
54
+ { name: 'changelog', description: 'Harness release notes' },
55
+ { name: 'hotkeys', description: 'Keyboard controls' },
56
+ { name: 'quit', description: 'Restore the terminal and exit' }
57
+ ]
58
+
59
+ export function createTerminalCommands(host: TerminalCommandHost) {
60
+ const notify = (text: string) => host.ui.notify(text, 'info')
61
+ const choose = async <T>(title: string, items: T[], label: (item: T, index: number) => string) => {
62
+ const labels = items.map(label)
63
+ const selected = await host.ui.select(title, labels)
64
+ return selected === undefined ? undefined : items[labels.indexOf(selected)]
65
+ }
66
+ const completions = (): TerminalCommandCompletion[] => {
67
+ const session = host.session()
68
+ const commands = new Map(BUILTINS.map(command => [command.name, command]))
69
+ for (const command of session.extensionRunner.getRegisteredCommands()) {
70
+ if (!commands.has(command.name)) commands.set(command.name, { name: command.name, description: command.description })
71
+ }
72
+ for (const prompt of session.resourceLoader.getPrompts().prompts) {
73
+ if (!commands.has(prompt.name)) commands.set(prompt.name, { name: prompt.name, description: prompt.description })
74
+ }
75
+ if (session.settingsManager.getEnableSkillCommands()) {
76
+ for (const skill of session.resourceLoader.getSkills().skills) commands.set(`skill:${skill.name}`, { name: `skill:${skill.name}`, description: skill.description })
77
+ }
78
+ return [...commands.values()]
79
+ }
80
+
81
+ async function settings() {
82
+ const session = host.session()
83
+ const settings = session.settingsManager
84
+ const booleanSettings = [
85
+ ['Auto compaction', () => session.autoCompactionEnabled, (value: boolean) => session.setAutoCompactionEnabled(value)],
86
+ ['Auto retry', () => session.autoRetryEnabled, (value: boolean) => session.setAutoRetryEnabled(value)],
87
+ ['Show images', () => settings.getShowImages(), (value: boolean) => settings.setShowImages(value)],
88
+ ['Resize images', () => settings.getImageAutoResize(), (value: boolean) => settings.setImageAutoResize(value)],
89
+ ['Block images', () => settings.getBlockImages(), (value: boolean) => settings.setBlockImages(value)],
90
+ ['Skill commands', () => settings.getEnableSkillCommands(), (value: boolean) => settings.setEnableSkillCommands(value)],
91
+ ['Hide thinking', () => settings.getHideThinkingBlock(), (value: boolean) => settings.setHideThinkingBlock(value)],
92
+ ['Hardware cursor', () => settings.getShowHardwareCursor(), (value: boolean) => settings.setShowHardwareCursor(value)],
93
+ ['Terminal progress', () => settings.getShowTerminalProgress(), (value: boolean) => settings.setShowTerminalProgress(value)]
94
+ ] as const
95
+ let selectedId: string | undefined
96
+ const locations = [
97
+ ['Context', 'Compaction'], ['Model', 'Recovery'], ['Appearance', 'Images'], ['Appearance', 'Images'],
98
+ ['Context', 'Images'], ['Interaction', 'Commands'], ['Appearance', 'Display'], ['Interaction', 'Editor'],
99
+ ['Interaction', 'Terminal'], ['Model', 'Thinking'], ['Interaction', 'Queues'], ['Interaction', 'Queues'],
100
+ ['Appearance', 'Theme'], ['Interaction', 'Keyboard']
101
+ ]
102
+ for (;;) {
103
+ const options = [...booleanSettings.map(([label, get]) => `${label}: ${get() ? 'on' : 'off'}`),
104
+ `Thinking: ${session.thinkingLevel}`, `Steering: ${session.steeringMode}`, `Follow-up: ${session.followUpMode}`,
105
+ `Theme: ${host.ui.theme.name}`, 'Keyboard controls', 'Done']
106
+ const descriptions = [
107
+ 'Compact the context automatically when the session approaches its limit.', 'Retry eligible provider failures automatically.',
108
+ 'Display actual image content returned by tools.', 'Resize image attachments before provider requests.',
109
+ 'Block image attachments in provider requests.', 'Expose commands for the skills loaded in this session.',
110
+ 'Collapse assistant thinking blocks in the transcript.', 'Display the terminal hardware cursor for editing and input methods.',
111
+ 'Report active work through supported terminal progress controls.', 'Reasoning effort for the current model.',
112
+ 'Delivery mode for messages that steer an active run.', 'Delivery mode for messages queued after a run.',
113
+ 'Theme loaded from the trusted resource catalog.', 'View the current application keyboard bindings.'
114
+ ]
115
+ const items: TerminalSettingItem[] = options.slice(0, -1).map((option, index) => {
116
+ const split = option.indexOf(':')
117
+ return { id: String(index), tab: locations[index][0], group: locations[index][1], label: split < 0 ? option : option.slice(0, split), value: split < 0 ? '' : option.slice(split + 1).trim(), description: descriptions[index] }
118
+ })
119
+ const groups = ['Theme', 'Display', 'Images', 'Thinking', 'Recovery', 'Editor', 'Queues', 'Commands', 'Terminal', 'Keyboard', 'Compaction']
120
+ items.sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group))
121
+ const id = await host.ui.custom<string | undefined>((tui, theme, _keys, done) => new SettingsPanel(tui, () => theme, items, done, selectedId), { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' } })
122
+ if (id === undefined) return
123
+ selectedId = id
124
+ const index = Number(id)
125
+ const selected = options[index]
126
+ if (index < booleanSettings.length) {
127
+ const [, get, set] = booleanSettings[index]!
128
+ set(!get())
129
+ } else if (selected.startsWith('Thinking:')) {
130
+ const level = await host.ui.select('Thinking effort', session.getAvailableThinkingLevels())
131
+ if (level) session.setThinkingLevel(level as AgentSession['thinkingLevel'], { persist: true })
132
+ } else if (selected.startsWith('Steering:') || selected.startsWith('Follow-up:')) {
133
+ const mode = await host.ui.select('Queue delivery', ['all', 'one-at-a-time'])
134
+ if (mode === 'all' || mode === 'one-at-a-time') {
135
+ if (selected.startsWith('Steering:')) session.setSteeringMode(mode)
136
+ else session.setFollowUpMode(mode)
137
+ }
138
+ } else if (selected.startsWith('Theme:')) {
139
+ const theme = await host.ui.select('Theme', host.ui.getAllThemes().map(item => item.name))
140
+ if (theme) {
141
+ const result = host.ui.setTheme(theme)
142
+ if (!result.success) throw new Error(result.error)
143
+ settings.setTheme(theme)
144
+ }
145
+ } else notify(host.hotkeys?.() ?? 'Use /help for commands. Keyboard bindings are managed by the terminal application.')
146
+ host.refresh()
147
+ }
148
+ }
149
+
150
+ async function login(providerRef: string) {
151
+ const runtime = host.session().modelRuntime
152
+ const providers = runtime.getProviders().filter(provider => provider.auth.oauth || provider.auth.apiKey?.login)
153
+ const provider = providerRef
154
+ ? providers.find(item => item.id === providerRef || item.name.toLowerCase() === providerRef.toLowerCase())
155
+ : await choose('Sign in to provider', providers, item => `${item.name} (${item.id})`)
156
+ if (!provider) { if (providerRef) throw new Error(`No sign-in provider matches ${providerRef}`); return }
157
+ const types = [...(provider.auth.oauth ? ['oauth' as const] : []), ...(provider.auth.apiKey?.login ? ['api_key' as const] : [])]
158
+ const type = types.length === 1 ? types[0] : await host.ui.select('Authentication method', types)
159
+ if (type !== 'oauth' && type !== 'api_key') return
160
+ const deviceId = type === 'oauth' ? await host.runtime().installationDeviceId() : undefined
161
+ await runtime.login(provider.id, type, {
162
+ prompt: async prompt => {
163
+ let result: string | undefined
164
+ if (prompt.type === 'select') {
165
+ const option = await choose(prompt.message, [...prompt.options], item => item.label)
166
+ result = option?.id
167
+ } else if (prompt.type === 'secret') {
168
+ if (!host.secretInput) throw new Error('Secure credential entry is unavailable')
169
+ result = await host.secretInput(prompt.message, prompt.placeholder, { signal: prompt.signal })
170
+ } else result = await host.ui.input(prompt.message, prompt.placeholder, { signal: prompt.signal })
171
+ if (result === undefined) throw new Error('Sign-in cancelled')
172
+ return result
173
+ },
174
+ notify: event => {
175
+ if (event.type === 'auth_url') {
176
+ notify(`${event.instructions ?? 'Open this sign-in URL:'}\n${event.url}`)
177
+ void host.openExternal?.(event.url).catch(error => host.ui.notify(String(error), 'warning'))
178
+ } else if (event.type === 'device_code') notify(`${event.verificationUri}\nCode: ${event.userCode}`)
179
+ else notify(event.message)
180
+ }
181
+ }, deviceId ? { getDeviceId: () => deviceId } : undefined)
182
+ notify(`Signed in to ${provider.name}`)
183
+ }
184
+
185
+ async function share() {
186
+ if (!await host.ui.confirm('Share session', 'Upload the current session as a secret GitHub gist? Anyone with its link can read its contents.')) return
187
+ const directory = await mkdtemp(join(tmpdir(), 'harness-share-'))
188
+ try {
189
+ const path = await host.session().exportToHtml(join(directory, 'session.html'), { themeName: host.ui.theme.name })
190
+ const result = await promisify(execFile)('gh', ['gist', 'create', '--public=false', path], { timeout: 60_000, maxBuffer: 1024 * 1024 })
191
+ notify(`Session shared: ${result.stdout.trim()}`)
192
+ } finally { await rm(directory, { recursive: true, force: true }) }
193
+ }
194
+
195
+ async function execute(text: string): Promise<boolean> {
196
+ const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim())
197
+ if (!match) return false
198
+ const name = match[1]!
199
+ const argument = match[2]?.trim() ?? ''
200
+ if (!BUILTINS.some(item => item.name === name)) {
201
+ if (completions().some(item => item.name === name)) return false
202
+ host.ui.notify(`Unknown command: /${name}. Use /help to list commands.`, 'error')
203
+ return true
204
+ }
205
+ try {
206
+ const session = host.session()
207
+ switch (name) {
208
+ case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
209
+ case 'quit': host.shutdown(); break
210
+ case 'new': await host.runtime().newSession(); break
211
+ case 'resume': {
212
+ let path = argument
213
+ if (!path) {
214
+ const sessions = await SessionManager.list(host.cwd(), host.sessionDirectory())
215
+ const selected = await choose('Resume session', sessions, (item, index) => `${index + 1}. ${item.name ?? item.firstMessage ?? item.id} ${item.modified.toLocaleString()}`)
216
+ if (!selected) break
217
+ path = selected.path
218
+ }
219
+ const target = resolve(host.cwd(), path)
220
+ const within = relative(host.sessionDirectory(), target)
221
+ if (within === '..' || within.startsWith('../') || within.startsWith('..\\') || isAbsolute(within)) {
222
+ throw new Error('Resume sessions from this account history folder. Use /import to explicitly import a session.')
223
+ }
224
+ await host.runtime().switchSession(target)
225
+ break
226
+ }
227
+ case 'fork': {
228
+ const message = await choose('Fork before message', session.getUserMessagesForForking(), (item, index) => `${index + 1}. ${item.text.replace(/\s+/g, ' ').slice(0, 160)}`)
229
+ if (message) {
230
+ const result = await host.runtime().fork(message.entryId)
231
+ if (!result.cancelled && result.selectedText) host.setEditorText(result.selectedText)
232
+ }
233
+ break
234
+ }
235
+ case 'clone': {
236
+ const leaf = session.sessionManager.getLeafId()
237
+ if (!leaf) throw new Error('Nothing to clone yet')
238
+ const result = await host.runtime().fork(leaf, { position: 'at' })
239
+ if (!result.cancelled) { host.setEditorText(''); notify('Cloned to new session') }
240
+ break
241
+ }
242
+ case 'tree': {
243
+ const tree = session.sessionManager.getTree()
244
+ if (!tree.length) throw new Error('No entries in this session')
245
+ const selected = await host.ui.custom<string | undefined>((tui, _theme, _keys, done) => {
246
+ const selector = new TreeSelectorComponent(tree, session.sessionManager.getLeafId(), tui.terminal.rows,
247
+ done, () => done(undefined), (entryId, label) => session.sessionManager.appendLabelChange(entryId, label),
248
+ undefined, session.settingsManager.getTreeFilterMode())
249
+ selector.onCopy = text => { if (text) void host.copyText(text).catch(error => host.ui.notify(String(error), 'error')) }
250
+ return selector
251
+ })
252
+ if (selected && selected !== session.sessionManager.getLeafId()) {
253
+ const summarize = await host.ui.confirm('Navigate session', 'Summarize the abandoned branch before navigating?')
254
+ const result = await host.runtime().navigateTree(selected, { summarize })
255
+ if (!result.cancelled && result.editorText) host.setEditorText(result.editorText)
256
+ }
257
+ break
258
+ }
259
+ case 'thinking': {
260
+ const levels = session.getAvailableThinkingLevels()
261
+ const level = argument || await host.ui.select('Thinking effort', levels)
262
+ if (!level) break
263
+ if (!levels.includes(level as AgentSession['thinkingLevel'])) throw new Error(`Available thinking levels: ${levels.join(', ')}`)
264
+ session.setThinkingLevel(level as AgentSession['thinkingLevel'], { persist: true })
265
+ break
266
+ }
267
+ case 'model': {
268
+ const provider = session.settingsManager.getDefaultProvider()
269
+ const id = session.settingsManager.getDefaultModel()
270
+ const selected = await host.ui.custom<{ model: NonNullable<AgentSession['model']>; persist: boolean } | undefined>((tui, _theme, _keys, done) =>
271
+ new ModelSelectorComponent(tui, session.model, session.modelRuntime, session.scopedModels,
272
+ model => done({ model, persist: false }), () => done(undefined), argument || undefined,
273
+ model => done({ model, persist: true }), provider && id ? { provider, id } : undefined))
274
+ if (selected) await session.setModel(selected.model, { persist: selected.persist })
275
+ break
276
+ }
277
+ case 'scoped-models': {
278
+ const models = await session.modelRuntime.getAvailable()
279
+ const selected = new Set(session.scopedModels.map(item => `${item.model.provider}/${item.model.id}`))
280
+ for (;;) {
281
+ const labels = models.map(model => `${selected.has(`${model.provider}/${model.id}`) ? '☑' : '☐'} ${model.provider}/${model.id}`)
282
+ const choice = await host.ui.select('Models for cycling', [...labels, 'Save', 'Cancel'])
283
+ if (!choice || choice === 'Cancel') break
284
+ if (choice === 'Save') {
285
+ session.setScopedModels(models.filter(model => selected.has(`${model.provider}/${model.id}`)).map(model => ({ model })))
286
+ session.settingsManager.setEnabledModels([...selected])
287
+ break
288
+ }
289
+ const model = models[labels.indexOf(choice)]
290
+ if (model) { const key = `${model.provider}/${model.id}`; if (selected.has(key)) selected.delete(key); else selected.add(key) }
291
+ }
292
+ break
293
+ }
294
+ case 'settings': await settings(); break
295
+ case 'name': {
296
+ const name = argument || await host.ui.input('Session name', session.sessionName)
297
+ if (name !== undefined) session.setSessionName(name)
298
+ break
299
+ }
300
+ case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
301
+ case 'compact': await session.compact(argument || undefined); break
302
+ case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
303
+ case 'trust': {
304
+ if (!session.extensionRunner.getRegisteredCommands().some(command => command.name === 'plugins')) throw new Error('Plugin trust controls are unavailable in this session.')
305
+ await session.prompt('/plugins')
306
+ break
307
+ }
308
+ case 'bug': {
309
+ const description = argument || await host.ui.input('Describe the Harness issue')
310
+ if (!description) break
311
+ const repository = manifest.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')
312
+ const url = new URL(`${repository}/issues/new`)
313
+ url.searchParams.set('title', description.split('\n')[0]!.slice(0, 120))
314
+ url.searchParams.set('body', `${description}\n\nHarness ${manifest.version} · ${process.platform} ${process.arch} · Node ${process.version}`)
315
+ if (!host.openExternal) throw new Error(`Open the issue draft: ${url}`)
316
+ if (await host.ui.confirm('Open issue draft?', `${description}\n\nOpen the Harness issue form in your browser to review and submit.`)) await host.openExternal(url.href)
317
+ break
318
+ }
319
+ case 'login': await login(argument); break
320
+ case 'logout': {
321
+ const credentials = await session.modelRuntime.listCredentials()
322
+ const provider = argument || await host.ui.select('Sign out of provider', credentials.map(item => item.providerId))
323
+ if (provider && await host.ui.confirm('Sign out', `Remove the stored credential for ${provider} from this account?`)) {
324
+ await session.modelRuntime.logout(provider)
325
+ notify(`Signed out of ${provider}`)
326
+ }
327
+ break
328
+ }
329
+ case 'copy': {
330
+ const response = session.getLastAssistantText()
331
+ if (!response) throw new Error('No assistant response to copy yet')
332
+ await host.copyText(response)
333
+ notify('Copied last assistant response')
334
+ break
335
+ }
336
+ case 'export': {
337
+ const output = argument || await host.ui.input('Export path (.html or .jsonl)', 'session.html')
338
+ if (!output) break
339
+ const path = resolve(host.cwd(), output)
340
+ notify(`Exported to ${path.endsWith('.jsonl') ? session.exportToJsonl(path) : await session.exportToHtml(path, { themeName: host.ui.theme.name })}`)
341
+ break
342
+ }
343
+ case 'import': {
344
+ const input = argument || await host.ui.input('Import session JSONL path')
345
+ if (input) await host.runtime().importFromJsonl(resolve(host.cwd(), input))
346
+ break
347
+ }
348
+ case 'share': await share(); break
349
+ case 'hotkeys': notify(host.hotkeys?.() ?? 'Keyboard bindings are managed by the terminal application.'); break
350
+ case 'changelog': {
351
+ if (!host.changelog) throw new Error('No Harness release notes are installed')
352
+ notify(await host.changelog())
353
+ break
354
+ }
355
+ }
356
+ host.refresh()
357
+ } catch (error) { host.ui.notify(error instanceof Error ? error.message : String(error), 'error') }
358
+ return true
359
+ }
360
+ return { execute, completions }
361
+ }