@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.
- package/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- package/src/tools/types.ts +2 -0
|
@@ -0,0 +1,443 @@
|
|
|
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 { DialogPanel, SearchableSelect } from './dialogs'
|
|
9
|
+
import { TrajectoryPanel } from './trajectoryPanel'
|
|
10
|
+
import { expandSkillInvocation, type SkillEntry } from './skillInvocation'
|
|
11
|
+
import manifest from '../../../package.json'
|
|
12
|
+
import type { TerminalRuntime } from './runtime'
|
|
13
|
+
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
14
|
+
|
|
15
|
+
export interface TerminalCommandHost {
|
|
16
|
+
session(): AgentSession
|
|
17
|
+
/** Navigation is scope checked by the Harness runtime adapter. */
|
|
18
|
+
runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId' | 'permissions' | 'setFullPermission' | 'setGlobalTool'>
|
|
19
|
+
ui: ExtensionUIContext
|
|
20
|
+
cwd(): string
|
|
21
|
+
sessionDirectory(): string
|
|
22
|
+
refresh(): void
|
|
23
|
+
changed(): void
|
|
24
|
+
shutdown(): void
|
|
25
|
+
setEditorText(text: string): void
|
|
26
|
+
copyText(text: string): Promise<void>
|
|
27
|
+
secretInput?(title: string, placeholder?: string, options?: { signal?: AbortSignal }): Promise<string | undefined>
|
|
28
|
+
openExternal?(url: string): Promise<void>
|
|
29
|
+
suspend?(): Promise<void>
|
|
30
|
+
hotkeys?(): string
|
|
31
|
+
changelog?(): Promise<string>
|
|
32
|
+
trajectory: {
|
|
33
|
+
list(): Promise<TerminalTrajectoryRun[]>
|
|
34
|
+
read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TerminalCommandCompletion { name: string; description?: string }
|
|
39
|
+
|
|
40
|
+
const BUILTINS: TerminalCommandCompletion[] = [
|
|
41
|
+
{ name: 'help', description: 'Available terminal commands' },
|
|
42
|
+
{ name: 'new', description: 'Start a new session' },
|
|
43
|
+
{ name: 'resume', description: 'Resume a session in this account and folder' },
|
|
44
|
+
{ name: 'tree', description: 'Navigate the session tree' },
|
|
45
|
+
{ name: 'fork', description: 'Fork from a user message' },
|
|
46
|
+
{ name: 'model', description: 'Choose a model' },
|
|
47
|
+
{ name: 'effort', description: 'Choose thinking effort' },
|
|
48
|
+
{ name: 'clone', description: 'Clone the current branch to a new session' },
|
|
49
|
+
{ name: 'scoped-models', description: 'Choose models for cycling' },
|
|
50
|
+
{ name: 'settings', description: 'Session and terminal settings' },
|
|
51
|
+
{ name: 'permissions', description: 'Review and change terminal approval permissions' },
|
|
52
|
+
{ name: 'name', description: 'Name the current session' },
|
|
53
|
+
{ name: 'session', description: 'Current session statistics' },
|
|
54
|
+
{ name: 'trajectory', description: 'Inspect recorded runs for this native session' },
|
|
55
|
+
{ name: 'compact', description: 'Compact the current context' },
|
|
56
|
+
{ name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
|
|
57
|
+
{ name: 'trust', description: 'Review plugin code and project trust' },
|
|
58
|
+
{ name: 'bug', description: 'Open a Harness issue draft' },
|
|
59
|
+
{ name: 'login', description: 'Sign in to a provider in this isolated account' },
|
|
60
|
+
{ name: 'logout', description: 'Remove a stored provider credential' },
|
|
61
|
+
{ name: 'export', description: 'Export this branch to HTML or JSONL' },
|
|
62
|
+
{ name: 'import', description: 'Import a session JSONL file' },
|
|
63
|
+
{ name: 'share', description: 'Share an HTML export as a secret GitHub gist' },
|
|
64
|
+
{ name: 'copy', description: 'Copy the last assistant response' },
|
|
65
|
+
{ name: 'changelog', description: 'Harness release notes' },
|
|
66
|
+
{ name: 'hotkeys', description: 'Keyboard controls' },
|
|
67
|
+
{ name: 'quit', description: 'Restore the terminal and exit' }
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
export function createTerminalCommands(host: TerminalCommandHost) {
|
|
71
|
+
const notify = (text: string) => host.ui.notify(text, 'info')
|
|
72
|
+
const choose = async <T>(title: string, items: T[], label: (item: T, index: number) => string) => {
|
|
73
|
+
const labels = items.map(label)
|
|
74
|
+
const selected = await host.ui.select(title, labels)
|
|
75
|
+
return selected === undefined ? undefined : items[labels.indexOf(selected)]
|
|
76
|
+
}
|
|
77
|
+
const completions = (): TerminalCommandCompletion[] => {
|
|
78
|
+
const session = host.session()
|
|
79
|
+
const commands = new Map(BUILTINS.map(command => [command.name, command]))
|
|
80
|
+
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
|
81
|
+
if (!commands.has(command.name)) commands.set(command.name, { name: command.name, description: command.description })
|
|
82
|
+
}
|
|
83
|
+
for (const prompt of session.resourceLoader.getPrompts().prompts) {
|
|
84
|
+
if (!commands.has(prompt.name)) commands.set(prompt.name, { name: prompt.name, description: prompt.description })
|
|
85
|
+
}
|
|
86
|
+
return [...commands.values()]
|
|
87
|
+
}
|
|
88
|
+
/** The skills this session may be asked to use, each invoked as `$name`. Off with the "Skill commands" setting. */
|
|
89
|
+
const skills = (): SkillEntry[] => {
|
|
90
|
+
const session = host.session()
|
|
91
|
+
if (!session.settingsManager.getEnableSkillCommands()) return []
|
|
92
|
+
return session.resourceLoader.getSkills().skills.map((skill) => ({ name: skill.name, description: skill.description }))
|
|
93
|
+
}
|
|
94
|
+
/** The text to send: a leading `$name` of a loaded skill becomes the `/skill:name` form Pi expands. */
|
|
95
|
+
const expandSkill = (text: string): string => expandSkillInvocation(text, new Set(skills().map((skill) => skill.name)))
|
|
96
|
+
|
|
97
|
+
async function settings() {
|
|
98
|
+
const session = host.session()
|
|
99
|
+
const settings = session.settingsManager
|
|
100
|
+
const booleanSettings = [
|
|
101
|
+
['Auto compaction', () => session.autoCompactionEnabled, (value: boolean) => session.setAutoCompactionEnabled(value)],
|
|
102
|
+
['Auto retry', () => session.autoRetryEnabled, (value: boolean) => session.setAutoRetryEnabled(value)],
|
|
103
|
+
['Show images', () => settings.getShowImages(), (value: boolean) => settings.setShowImages(value)],
|
|
104
|
+
['Resize images', () => settings.getImageAutoResize(), (value: boolean) => settings.setImageAutoResize(value)],
|
|
105
|
+
['Block images', () => settings.getBlockImages(), (value: boolean) => settings.setBlockImages(value)],
|
|
106
|
+
['Skill commands', () => settings.getEnableSkillCommands(), (value: boolean) => settings.setEnableSkillCommands(value)],
|
|
107
|
+
['Hide thinking', () => settings.getHideThinkingBlock(), (value: boolean) => settings.setHideThinkingBlock(value)],
|
|
108
|
+
['Hardware cursor', () => settings.getShowHardwareCursor(), (value: boolean) => settings.setShowHardwareCursor(value)],
|
|
109
|
+
['Terminal progress', () => settings.getShowTerminalProgress(), (value: boolean) => settings.setShowTerminalProgress(value)]
|
|
110
|
+
] as const
|
|
111
|
+
let selectedId: string | undefined
|
|
112
|
+
const locations = [
|
|
113
|
+
['Context', 'Compaction'], ['Model', 'Recovery'], ['Appearance', 'Images'], ['Appearance', 'Images'],
|
|
114
|
+
['Context', 'Images'], ['Interaction', 'Commands'], ['Appearance', 'Display'], ['Interaction', 'Editor'],
|
|
115
|
+
['Interaction', 'Terminal'], ['Model', 'Thinking'], ['Interaction', 'Queues'], ['Interaction', 'Queues'],
|
|
116
|
+
['Appearance', 'Theme'], ['Interaction', 'Keyboard']
|
|
117
|
+
]
|
|
118
|
+
for (;;) {
|
|
119
|
+
const options = [...booleanSettings.map(([label, get]) => `${label}: ${get() ? 'on' : 'off'}`),
|
|
120
|
+
`Thinking: ${session.thinkingLevel}`, `Steering: ${session.steeringMode}`, `Follow-up: ${session.followUpMode}`,
|
|
121
|
+
`Theme: ${host.ui.theme.name}`, 'Keyboard controls', 'Done']
|
|
122
|
+
const descriptions = [
|
|
123
|
+
'Compact the context automatically when the session approaches its limit.', 'Retry eligible provider failures automatically.',
|
|
124
|
+
'Display actual image content returned by tools.', 'Resize image attachments before provider requests.',
|
|
125
|
+
'Block image attachments in provider requests.', 'Expose commands for the skills loaded in this session.',
|
|
126
|
+
'Collapse assistant thinking blocks in the transcript.', 'Display the terminal hardware cursor for editing and input methods.',
|
|
127
|
+
'Report active work through supported terminal progress controls.', 'Reasoning effort for the current model.',
|
|
128
|
+
'Delivery mode for messages that steer an active run.', 'Delivery mode for messages queued after a run.',
|
|
129
|
+
'Theme loaded from the trusted resource catalog.', 'View the current application keyboard bindings.'
|
|
130
|
+
]
|
|
131
|
+
const items: TerminalSettingItem[] = options.slice(0, -1).map((option, index) => {
|
|
132
|
+
const split = option.indexOf(':')
|
|
133
|
+
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] }
|
|
134
|
+
})
|
|
135
|
+
const groups = ['Theme', 'Display', 'Images', 'Thinking', 'Recovery', 'Editor', 'Queues', 'Commands', 'Terminal', 'Keyboard', 'Compaction']
|
|
136
|
+
items.sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group))
|
|
137
|
+
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' } })
|
|
138
|
+
if (id === undefined) return
|
|
139
|
+
selectedId = id
|
|
140
|
+
const index = Number(id)
|
|
141
|
+
const selected = options[index]
|
|
142
|
+
if (index < booleanSettings.length) {
|
|
143
|
+
const [, get, set] = booleanSettings[index]!
|
|
144
|
+
set(!get())
|
|
145
|
+
} else if (selected.startsWith('Thinking:')) {
|
|
146
|
+
const level = await host.ui.select('Thinking effort', session.getAvailableThinkingLevels())
|
|
147
|
+
if (level) session.setThinkingLevel(level as AgentSession['thinkingLevel'], { persist: true })
|
|
148
|
+
} else if (selected.startsWith('Steering:') || selected.startsWith('Follow-up:')) {
|
|
149
|
+
const mode = await host.ui.select('Queue delivery', ['all', 'one-at-a-time'])
|
|
150
|
+
if (mode === 'all' || mode === 'one-at-a-time') {
|
|
151
|
+
if (selected.startsWith('Steering:')) session.setSteeringMode(mode)
|
|
152
|
+
else session.setFollowUpMode(mode)
|
|
153
|
+
}
|
|
154
|
+
} else if (selected.startsWith('Theme:')) {
|
|
155
|
+
const theme = await host.ui.select('Theme', host.ui.getAllThemes().map(item => item.name))
|
|
156
|
+
if (theme) {
|
|
157
|
+
const result = host.ui.setTheme(theme)
|
|
158
|
+
if (!result.success) throw new Error(result.error)
|
|
159
|
+
settings.setTheme(theme)
|
|
160
|
+
}
|
|
161
|
+
} else notify(host.hotkeys?.() ?? 'Use /help for commands. Keyboard bindings are managed by the terminal application.')
|
|
162
|
+
host.refresh()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function login(providerRef: string) {
|
|
167
|
+
const runtime = host.session().modelRuntime
|
|
168
|
+
const providers = runtime.getProviders().filter(provider => provider.auth.oauth || provider.auth.apiKey?.login)
|
|
169
|
+
const provider = providerRef
|
|
170
|
+
? providers.find(item => item.id === providerRef || item.name.toLowerCase() === providerRef.toLowerCase())
|
|
171
|
+
: await choose('Sign in to provider', providers, item => `${item.name} (${item.id})`)
|
|
172
|
+
if (!provider) { if (providerRef) throw new Error(`No sign-in provider matches ${providerRef}`); return }
|
|
173
|
+
const types = [...(provider.auth.oauth ? ['oauth' as const] : []), ...(provider.auth.apiKey?.login ? ['api_key' as const] : [])]
|
|
174
|
+
const type = types.length === 1 ? types[0] : await host.ui.select('Authentication method', types)
|
|
175
|
+
if (type !== 'oauth' && type !== 'api_key') return
|
|
176
|
+
const deviceId = type === 'oauth' ? await host.runtime().installationDeviceId() : undefined
|
|
177
|
+
await runtime.login(provider.id, type, {
|
|
178
|
+
prompt: async prompt => {
|
|
179
|
+
let result: string | undefined
|
|
180
|
+
if (prompt.type === 'select') {
|
|
181
|
+
const option = await choose(prompt.message, [...prompt.options], item => item.label)
|
|
182
|
+
result = option?.id
|
|
183
|
+
} else if (prompt.type === 'secret') {
|
|
184
|
+
if (!host.secretInput) throw new Error('Secure credential entry is unavailable')
|
|
185
|
+
result = await host.secretInput(prompt.message, prompt.placeholder, { signal: prompt.signal })
|
|
186
|
+
} else result = await host.ui.input(prompt.message, prompt.placeholder, { signal: prompt.signal })
|
|
187
|
+
if (result === undefined) throw new Error('Sign-in cancelled')
|
|
188
|
+
return result
|
|
189
|
+
},
|
|
190
|
+
notify: event => {
|
|
191
|
+
if (event.type === 'auth_url') {
|
|
192
|
+
notify(`${event.instructions ?? 'Open this sign-in URL:'}\n${event.url}`)
|
|
193
|
+
void host.openExternal?.(event.url).catch(error => host.ui.notify(String(error), 'warning'))
|
|
194
|
+
} else if (event.type === 'device_code') notify(`${event.verificationUri}\nCode: ${event.userCode}`)
|
|
195
|
+
else notify(event.message)
|
|
196
|
+
}
|
|
197
|
+
}, deviceId ? { getDeviceId: () => deviceId } : undefined)
|
|
198
|
+
notify(`Signed in to ${provider.name}`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function share() {
|
|
202
|
+
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
|
|
203
|
+
const directory = await mkdtemp(join(tmpdir(), 'harness-share-'))
|
|
204
|
+
try {
|
|
205
|
+
const path = await host.session().exportToHtml(join(directory, 'session.html'), { themeName: host.ui.theme.name })
|
|
206
|
+
const result = await promisify(execFile)('gh', ['gist', 'create', '--public=false', path], { timeout: 60_000, maxBuffer: 1024 * 1024 })
|
|
207
|
+
notify(`Session shared: ${result.stdout.trim()}`)
|
|
208
|
+
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function trajectory(runId: string) {
|
|
212
|
+
const runs = await host.trajectory.list()
|
|
213
|
+
if (!runs.length) { notify('No runs recorded for this native session yet'); return }
|
|
214
|
+
const initialIndex = runId ? runs.findIndex(run => run.id === runId) : 0
|
|
215
|
+
if (initialIndex < 0) throw new Error('Run not found in this native session')
|
|
216
|
+
const initial = await host.trajectory.read(runs[initialIndex]!.id)
|
|
217
|
+
await host.ui.custom<void>((tui, theme, _keys, done) => {
|
|
218
|
+
const content = new TrajectoryPanel({
|
|
219
|
+
tui, theme, runs, initialIndex, initial,
|
|
220
|
+
read: host.trajectory.read,
|
|
221
|
+
copy: host.copyText,
|
|
222
|
+
close: () => done(undefined),
|
|
223
|
+
changed: host.changed,
|
|
224
|
+
notify: (message, type) => host.ui.notify(message, type === 'error' || type === 'warning' ? type : 'info')
|
|
225
|
+
})
|
|
226
|
+
const dialog = new DialogPanel('Trajectory', content, () => theme, '←/→ runs · ↑/↓ events · PgUp/PgDn details · C copy · Esc close')
|
|
227
|
+
return Object.assign(dialog, { dispose: () => content.dispose() })
|
|
228
|
+
}, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function permissions() {
|
|
232
|
+
for (;;) {
|
|
233
|
+
const runtime = host.runtime()
|
|
234
|
+
const state = await runtime.permissions()
|
|
235
|
+
const activeTools = host.session().getActiveToolNames()
|
|
236
|
+
const granted = new Set(state.globalTools)
|
|
237
|
+
const toolNames = [...new Set([...activeTools, ...granted])].sort((a, b) => a.localeCompare(b))
|
|
238
|
+
const entries = [
|
|
239
|
+
{ id: 'full', label: `Full Permission: ${state.full ? 'on' : 'off'}` },
|
|
240
|
+
...toolNames.map(tool => ({ id: `tool:${tool}`, label: `${tool}: allow everywhere ${granted.has(tool) ? 'on' : 'off'}` })),
|
|
241
|
+
{ id: 'done', label: 'Done' }
|
|
242
|
+
]
|
|
243
|
+
const description = [
|
|
244
|
+
'Permissions apply to future Pi terminal approvals using this local Harness state.',
|
|
245
|
+
'A tool grant allows every call with that tool name, regardless of its arguments.',
|
|
246
|
+
'Full Permission approves every eligible future request. Hard denials and disabled tools still apply; human-only requests stay manual unless the host explicitly opts them in.',
|
|
247
|
+
'Existing pending requests are never approved by changing these settings.'
|
|
248
|
+
].join('\n')
|
|
249
|
+
const selected = await host.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
250
|
+
const list = new SearchableSelect(entries.map(entry => entry.label), tui, () => theme,
|
|
251
|
+
index => done(entries[index]?.id), () => done(undefined))
|
|
252
|
+
return new DialogPanel('Permissions', list, () => theme, 'Enter change · Type to search · Esc close', description)
|
|
253
|
+
}, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
|
|
254
|
+
if (!selected || selected === 'done') return
|
|
255
|
+
if (selected === 'full') {
|
|
256
|
+
const desired = !state.full
|
|
257
|
+
const latest = await runtime.permissions()
|
|
258
|
+
if (latest.full !== desired) {
|
|
259
|
+
if (!desired || await host.ui.confirm('Enable Full Permission?', 'Automatically approve eligible future requests in every Pi terminal session sharing this local Harness state. Hard denials and disabled tools still apply; human-only requests stay manual unless the host explicitly opts them in.')) {
|
|
260
|
+
await runtime.setFullPermission(desired)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const tool = selected.slice('tool:'.length)
|
|
266
|
+
if (!tool) continue
|
|
267
|
+
const desired = !granted.has(tool)
|
|
268
|
+
const latest = await runtime.permissions()
|
|
269
|
+
if (latest.globalTools.includes(tool) !== desired) await runtime.setGlobalTool(tool, desired)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function execute(text: string): Promise<boolean> {
|
|
274
|
+
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim())
|
|
275
|
+
if (!match) return false
|
|
276
|
+
const name = match[1]!
|
|
277
|
+
const argument = match[2]?.trim() ?? ''
|
|
278
|
+
if (!BUILTINS.some(item => item.name === name)) {
|
|
279
|
+
if (completions().some(item => item.name === name)) return false
|
|
280
|
+
// A skill is offered as $name; the typed /skill:name form still reaches the SDK, which expands it.
|
|
281
|
+
if (skills().some(skill => `skill:${skill.name}` === name)) return false
|
|
282
|
+
host.ui.notify(`Unknown command: /${name}. Use /help to list commands.`, 'error')
|
|
283
|
+
return true
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const session = host.session()
|
|
287
|
+
switch (name) {
|
|
288
|
+
case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
|
|
289
|
+
case 'quit': host.shutdown(); break
|
|
290
|
+
case 'new': await host.runtime().newSession(); break
|
|
291
|
+
case 'resume': {
|
|
292
|
+
let path = argument
|
|
293
|
+
if (!path) {
|
|
294
|
+
const sessions = await SessionManager.list(host.cwd(), host.sessionDirectory())
|
|
295
|
+
const selected = await choose('Resume session', sessions, (item, index) => `${index + 1}. ${item.name ?? item.firstMessage ?? item.id} ${item.modified.toLocaleString()}`)
|
|
296
|
+
if (!selected) break
|
|
297
|
+
path = selected.path
|
|
298
|
+
}
|
|
299
|
+
const target = resolve(host.cwd(), path)
|
|
300
|
+
const within = relative(host.sessionDirectory(), target)
|
|
301
|
+
if (within === '..' || within.startsWith('../') || within.startsWith('..\\') || isAbsolute(within)) {
|
|
302
|
+
throw new Error('Resume sessions from this account history folder. Use /import to explicitly import a session.')
|
|
303
|
+
}
|
|
304
|
+
await host.runtime().switchSession(target)
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
case 'fork': {
|
|
308
|
+
const message = await choose('Fork before message', session.getUserMessagesForForking(), (item, index) => `${index + 1}. ${item.text.replace(/\s+/g, ' ').slice(0, 160)}`)
|
|
309
|
+
if (message) {
|
|
310
|
+
const result = await host.runtime().fork(message.entryId)
|
|
311
|
+
if (!result.cancelled && result.selectedText) host.setEditorText(result.selectedText)
|
|
312
|
+
}
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
case 'clone': {
|
|
316
|
+
const leaf = session.sessionManager.getLeafId()
|
|
317
|
+
if (!leaf) throw new Error('Nothing to clone yet')
|
|
318
|
+
const result = await host.runtime().fork(leaf, { position: 'at' })
|
|
319
|
+
if (!result.cancelled) { host.setEditorText(''); notify('Cloned to new session') }
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
case 'tree': {
|
|
323
|
+
const tree = session.sessionManager.getTree()
|
|
324
|
+
if (!tree.length) throw new Error('No entries in this session')
|
|
325
|
+
const selected = await host.ui.custom<string | undefined>((tui, _theme, _keys, done) => {
|
|
326
|
+
const selector = new TreeSelectorComponent(tree, session.sessionManager.getLeafId(), tui.terminal.rows,
|
|
327
|
+
done, () => done(undefined), (entryId, label) => session.sessionManager.appendLabelChange(entryId, label),
|
|
328
|
+
undefined, session.settingsManager.getTreeFilterMode())
|
|
329
|
+
selector.onCopy = text => { if (text) void host.copyText(text).catch(error => host.ui.notify(String(error), 'error')) }
|
|
330
|
+
return selector
|
|
331
|
+
})
|
|
332
|
+
if (selected && selected !== session.sessionManager.getLeafId()) {
|
|
333
|
+
const summarize = await host.ui.confirm('Navigate session', 'Summarize the abandoned branch before navigating?')
|
|
334
|
+
const result = await host.runtime().navigateTree(selected, { summarize })
|
|
335
|
+
if (!result.cancelled && result.editorText) host.setEditorText(result.editorText)
|
|
336
|
+
}
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
case 'effort': {
|
|
340
|
+
const levels = session.getAvailableThinkingLevels()
|
|
341
|
+
const level = argument || await host.ui.select('Thinking effort', levels)
|
|
342
|
+
if (!level) break
|
|
343
|
+
if (!levels.includes(level as AgentSession['thinkingLevel'])) throw new Error(`Available thinking levels: ${levels.join(', ')}`)
|
|
344
|
+
session.setThinkingLevel(level as AgentSession['thinkingLevel'], { persist: true })
|
|
345
|
+
break
|
|
346
|
+
}
|
|
347
|
+
case 'model': {
|
|
348
|
+
const provider = session.settingsManager.getDefaultProvider()
|
|
349
|
+
const id = session.settingsManager.getDefaultModel()
|
|
350
|
+
const selected = await host.ui.custom<{ model: NonNullable<AgentSession['model']>; persist: boolean } | undefined>((tui, _theme, _keys, done) =>
|
|
351
|
+
new ModelSelectorComponent(tui, session.model, session.modelRuntime, session.scopedModels,
|
|
352
|
+
model => done({ model, persist: false }), () => done(undefined), argument || undefined,
|
|
353
|
+
model => done({ model, persist: true }), provider && id ? { provider, id } : undefined))
|
|
354
|
+
if (selected) await session.setModel(selected.model, { persist: selected.persist })
|
|
355
|
+
break
|
|
356
|
+
}
|
|
357
|
+
case 'scoped-models': {
|
|
358
|
+
const models = await session.modelRuntime.getAvailable()
|
|
359
|
+
const selected = new Set(session.scopedModels.map(item => `${item.model.provider}/${item.model.id}`))
|
|
360
|
+
for (;;) {
|
|
361
|
+
const labels = models.map(model => `${selected.has(`${model.provider}/${model.id}`) ? '☑' : '☐'} ${model.provider}/${model.id}`)
|
|
362
|
+
const choice = await host.ui.select('Models for cycling', [...labels, 'Save', 'Cancel'])
|
|
363
|
+
if (!choice || choice === 'Cancel') break
|
|
364
|
+
if (choice === 'Save') {
|
|
365
|
+
session.setScopedModels(models.filter(model => selected.has(`${model.provider}/${model.id}`)).map(model => ({ model })))
|
|
366
|
+
session.settingsManager.setEnabledModels([...selected])
|
|
367
|
+
break
|
|
368
|
+
}
|
|
369
|
+
const model = models[labels.indexOf(choice)]
|
|
370
|
+
if (model) { const key = `${model.provider}/${model.id}`; if (selected.has(key)) selected.delete(key); else selected.add(key) }
|
|
371
|
+
}
|
|
372
|
+
break
|
|
373
|
+
}
|
|
374
|
+
case 'settings': await settings(); break
|
|
375
|
+
case 'permissions': await permissions(); break
|
|
376
|
+
case 'name': {
|
|
377
|
+
const name = argument || await host.ui.input('Session name', session.sessionName)
|
|
378
|
+
if (name !== undefined) session.setSessionName(name)
|
|
379
|
+
break
|
|
380
|
+
}
|
|
381
|
+
case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
|
|
382
|
+
case 'trajectory': await trajectory(argument); break
|
|
383
|
+
case 'compact': await session.compact(argument || undefined); break
|
|
384
|
+
case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
|
|
385
|
+
case 'trust': {
|
|
386
|
+
if (!session.extensionRunner.getRegisteredCommands().some(command => command.name === 'plugins')) throw new Error('Plugin trust controls are unavailable in this session.')
|
|
387
|
+
await session.prompt('/plugins')
|
|
388
|
+
break
|
|
389
|
+
}
|
|
390
|
+
case 'bug': {
|
|
391
|
+
const description = argument || await host.ui.input('Describe the Harness issue')
|
|
392
|
+
if (!description) break
|
|
393
|
+
const repository = manifest.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')
|
|
394
|
+
const url = new URL(`${repository}/issues/new`)
|
|
395
|
+
url.searchParams.set('title', description.split('\n')[0]!.slice(0, 120))
|
|
396
|
+
url.searchParams.set('body', `${description}\n\nHarness ${manifest.version} · ${process.platform} ${process.arch} · Node ${process.version}`)
|
|
397
|
+
if (!host.openExternal) throw new Error(`Open the issue draft: ${url}`)
|
|
398
|
+
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)
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
case 'login': await login(argument); break
|
|
402
|
+
case 'logout': {
|
|
403
|
+
const credentials = await session.modelRuntime.listCredentials()
|
|
404
|
+
const provider = argument || await host.ui.select('Sign out of provider', credentials.map(item => item.providerId))
|
|
405
|
+
if (provider && await host.ui.confirm('Sign out', `Remove the stored credential for ${provider} from this account?`)) {
|
|
406
|
+
await session.modelRuntime.logout(provider)
|
|
407
|
+
notify(`Signed out of ${provider}`)
|
|
408
|
+
}
|
|
409
|
+
break
|
|
410
|
+
}
|
|
411
|
+
case 'copy': {
|
|
412
|
+
const response = session.getLastAssistantText()
|
|
413
|
+
if (!response) throw new Error('No assistant response to copy yet')
|
|
414
|
+
await host.copyText(response)
|
|
415
|
+
notify('Copied last assistant response')
|
|
416
|
+
break
|
|
417
|
+
}
|
|
418
|
+
case 'export': {
|
|
419
|
+
const output = argument || await host.ui.input('Export path (.html or .jsonl)', 'session.html')
|
|
420
|
+
if (!output) break
|
|
421
|
+
const path = resolve(host.cwd(), output)
|
|
422
|
+
notify(`Exported to ${path.endsWith('.jsonl') ? session.exportToJsonl(path) : await session.exportToHtml(path, { themeName: host.ui.theme.name })}`)
|
|
423
|
+
break
|
|
424
|
+
}
|
|
425
|
+
case 'import': {
|
|
426
|
+
const input = argument || await host.ui.input('Import session JSONL path')
|
|
427
|
+
if (input) await host.runtime().importFromJsonl(resolve(host.cwd(), input))
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
case 'share': await share(); break
|
|
431
|
+
case 'hotkeys': notify(host.hotkeys?.() ?? 'Keyboard bindings are managed by the terminal application.'); break
|
|
432
|
+
case 'changelog': {
|
|
433
|
+
if (!host.changelog) throw new Error('No Harness release notes are installed')
|
|
434
|
+
notify(await host.changelog())
|
|
435
|
+
break
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
host.refresh()
|
|
439
|
+
} catch (error) { host.ui.notify(error instanceof Error ? error.message : String(error), 'error') }
|
|
440
|
+
return true
|
|
441
|
+
}
|
|
442
|
+
return { execute, completions, skills, expandSkill }
|
|
443
|
+
}
|