@xingular/harness 0.1.7 → 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 +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -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 +100 -10
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/index.ts +7 -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/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- 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 +6 -0
- 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/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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 +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +2 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -5,17 +5,22 @@ import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import { ModelSelectorComponent, TreeSelectorComponent, SessionManager, type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
7
7
|
import { SettingsPanel, type TerminalSettingItem } from './settings'
|
|
8
|
+
import { DialogPanel, SearchableSelect } from './dialogs'
|
|
9
|
+
import { TrajectoryPanel } from './trajectoryPanel'
|
|
10
|
+
import { expandSkillInvocation, type SkillEntry } from './skillInvocation'
|
|
8
11
|
import manifest from '../../../package.json'
|
|
9
12
|
import type { TerminalRuntime } from './runtime'
|
|
13
|
+
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
10
14
|
|
|
11
15
|
export interface TerminalCommandHost {
|
|
12
16
|
session(): AgentSession
|
|
13
17
|
/** Navigation is scope checked by the Harness runtime adapter. */
|
|
14
|
-
runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId'>
|
|
18
|
+
runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId' | 'permissions' | 'setFullPermission' | 'setGlobalTool'>
|
|
15
19
|
ui: ExtensionUIContext
|
|
16
20
|
cwd(): string
|
|
17
21
|
sessionDirectory(): string
|
|
18
22
|
refresh(): void
|
|
23
|
+
changed(): void
|
|
19
24
|
shutdown(): void
|
|
20
25
|
setEditorText(text: string): void
|
|
21
26
|
copyText(text: string): Promise<void>
|
|
@@ -24,6 +29,10 @@ export interface TerminalCommandHost {
|
|
|
24
29
|
suspend?(): Promise<void>
|
|
25
30
|
hotkeys?(): string
|
|
26
31
|
changelog?(): Promise<string>
|
|
32
|
+
trajectory: {
|
|
33
|
+
list(): Promise<TerminalTrajectoryRun[]>
|
|
34
|
+
read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
export interface TerminalCommandCompletion { name: string; description?: string }
|
|
@@ -35,12 +44,14 @@ const BUILTINS: TerminalCommandCompletion[] = [
|
|
|
35
44
|
{ name: 'tree', description: 'Navigate the session tree' },
|
|
36
45
|
{ name: 'fork', description: 'Fork from a user message' },
|
|
37
46
|
{ name: 'model', description: 'Choose a model' },
|
|
38
|
-
{ name: '
|
|
47
|
+
{ name: 'effort', description: 'Choose thinking effort' },
|
|
39
48
|
{ name: 'clone', description: 'Clone the current branch to a new session' },
|
|
40
49
|
{ name: 'scoped-models', description: 'Choose models for cycling' },
|
|
41
50
|
{ name: 'settings', description: 'Session and terminal settings' },
|
|
51
|
+
{ name: 'permissions', description: 'Review and change terminal approval permissions' },
|
|
42
52
|
{ name: 'name', description: 'Name the current session' },
|
|
43
53
|
{ name: 'session', description: 'Current session statistics' },
|
|
54
|
+
{ name: 'trajectory', description: 'Inspect recorded runs for this native session' },
|
|
44
55
|
{ name: 'compact', description: 'Compact the current context' },
|
|
45
56
|
{ name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
|
|
46
57
|
{ name: 'trust', description: 'Review plugin code and project trust' },
|
|
@@ -72,11 +83,16 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
72
83
|
for (const prompt of session.resourceLoader.getPrompts().prompts) {
|
|
73
84
|
if (!commands.has(prompt.name)) commands.set(prompt.name, { name: prompt.name, description: prompt.description })
|
|
74
85
|
}
|
|
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
86
|
return [...commands.values()]
|
|
79
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)))
|
|
80
96
|
|
|
81
97
|
async function settings() {
|
|
82
98
|
const session = host.session()
|
|
@@ -192,6 +208,68 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
192
208
|
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
193
209
|
}
|
|
194
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
|
+
|
|
195
273
|
async function execute(text: string): Promise<boolean> {
|
|
196
274
|
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim())
|
|
197
275
|
if (!match) return false
|
|
@@ -199,6 +277,8 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
199
277
|
const argument = match[2]?.trim() ?? ''
|
|
200
278
|
if (!BUILTINS.some(item => item.name === name)) {
|
|
201
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
|
|
202
282
|
host.ui.notify(`Unknown command: /${name}. Use /help to list commands.`, 'error')
|
|
203
283
|
return true
|
|
204
284
|
}
|
|
@@ -256,7 +336,7 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
256
336
|
}
|
|
257
337
|
break
|
|
258
338
|
}
|
|
259
|
-
case '
|
|
339
|
+
case 'effort': {
|
|
260
340
|
const levels = session.getAvailableThinkingLevels()
|
|
261
341
|
const level = argument || await host.ui.select('Thinking effort', levels)
|
|
262
342
|
if (!level) break
|
|
@@ -292,12 +372,14 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
292
372
|
break
|
|
293
373
|
}
|
|
294
374
|
case 'settings': await settings(); break
|
|
375
|
+
case 'permissions': await permissions(); break
|
|
295
376
|
case 'name': {
|
|
296
377
|
const name = argument || await host.ui.input('Session name', session.sessionName)
|
|
297
378
|
if (name !== undefined) session.setSessionName(name)
|
|
298
379
|
break
|
|
299
380
|
}
|
|
300
381
|
case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
|
|
382
|
+
case 'trajectory': await trajectory(argument); break
|
|
301
383
|
case 'compact': await session.compact(argument || undefined); break
|
|
302
384
|
case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
|
|
303
385
|
case 'trust': {
|
|
@@ -357,5 +439,5 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
357
439
|
} catch (error) { host.ui.notify(error instanceof Error ? error.message : String(error), 'error') }
|
|
358
440
|
return true
|
|
359
441
|
}
|
|
360
|
-
return { execute, completions }
|
|
442
|
+
return { execute, completions, skills, expandSkill }
|
|
361
443
|
}
|
|
@@ -1,50 +1,65 @@
|
|
|
1
1
|
import { getSelectListTheme, initTheme, type KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
-
import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, stripTerminalSequences, type TUI, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
|
|
2
|
+
import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, renderFakeCursor, stripTerminalSequences, type TUI, type TuiMouseEvent, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
|
|
5
|
-
import { HarnessEditor } from './editor'
|
|
5
|
+
import { COMPOSER_SUGGESTIONS, type ComposerView, HarnessEditor } from './editor'
|
|
6
6
|
import { createHarnessTheme } from './theme'
|
|
7
7
|
|
|
8
8
|
initTheme('dark', false)
|
|
9
9
|
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
|
-
|
|
10
|
+
const bandText = (width: number) => '[band]'.padEnd(width)
|
|
11
|
+
function setup(view: Partial<ComposerView> = {}) {
|
|
11
12
|
const editor = new HarnessEditor({ terminal: { rows: 30 }, requestRender() {} } as unknown as TUI,
|
|
12
13
|
{ borderColor: (text) => text, selectList: getSelectListTheme() }, new TuiKeybindingsManager(TUI_KEYBINDINGS) as KeybindingsManager, {
|
|
13
|
-
band: (width
|
|
14
|
-
theme: () => theme,
|
|
14
|
+
band: (width) => bandText(width),
|
|
15
|
+
theme: () => theme, placeholder: () => undefined, ...view
|
|
15
16
|
})
|
|
16
17
|
editor.focused = true
|
|
17
18
|
return editor
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
describe('
|
|
21
|
-
it('
|
|
21
|
+
describe('Claude-style composer frame over the native Pi editor', () => {
|
|
22
|
+
it('frames the prompt between two full-width rules, with the band below', () => {
|
|
22
23
|
const editor = setup()
|
|
23
|
-
const rows = editor.render(
|
|
24
|
-
expect(rows).toHaveLength(
|
|
25
|
-
expect(stripTerminalSequences(rows[
|
|
24
|
+
const rows = editor.render(40)
|
|
25
|
+
expect(rows).toHaveLength(4)
|
|
26
|
+
expect(stripTerminalSequences(rows[0])).toBe('─'.repeat(40))
|
|
27
|
+
expect(rows[0]).toContain('38;2;136;136;136')
|
|
28
|
+
expect(stripTerminalSequences(rows[1])).toMatch(/^❯ /)
|
|
26
29
|
expect(rows[1]).toContain(CURSOR_MARKER)
|
|
27
|
-
expect(rows
|
|
30
|
+
expect(stripTerminalSequences(rows[2])).toBe('─'.repeat(40))
|
|
31
|
+
expect(stripTerminalSequences(rows[3]).trimEnd()).toBe('[band]')
|
|
32
|
+
expect(rows.every((row) => visibleWidth(row) <= 40)).toBe(true)
|
|
28
33
|
expect(editor.getText()).toBe('')
|
|
29
34
|
})
|
|
30
35
|
|
|
31
|
-
it('
|
|
32
|
-
const editor = setup()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
expect(
|
|
39
|
-
expect(
|
|
36
|
+
it('shows the suggestion as a fake cursor on its first character, only while focused', () => {
|
|
37
|
+
const editor = setup({ placeholder: () => COMPOSER_SUGGESTIONS[0] })
|
|
38
|
+
const rows = editor.render(60)
|
|
39
|
+
expect(stripTerminalSequences(rows[1])).toContain('Try "fix the failing test"')
|
|
40
|
+
expect(rows[1]).toContain(CURSOR_MARKER + renderFakeCursor('T'))
|
|
41
|
+
editor.focused = false
|
|
42
|
+
const idle = editor.render(60)[1]
|
|
43
|
+
expect(idle).not.toContain(CURSOR_MARKER)
|
|
44
|
+
expect(stripTerminalSequences(idle)).toContain('Try "fix the failing test"')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('drops the suggestion as soon as there is a draft', () => {
|
|
48
|
+
const editor = setup({ placeholder: () => 'fix the failing test' })
|
|
49
|
+
editor.handleInput('h')
|
|
50
|
+
const rows = editor.render(60)
|
|
51
|
+
expect(editor.getText()).toBe('h')
|
|
52
|
+
expect(stripTerminalSequences(rows[1])).toMatch(/^❯ h/)
|
|
53
|
+
expect(stripTerminalSequences(rows[1])).not.toContain('Try')
|
|
40
54
|
})
|
|
41
55
|
|
|
42
|
-
it('
|
|
56
|
+
it('does not render a shortcuts or thinking hint row', () => {
|
|
43
57
|
const editor = setup()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
expect(
|
|
47
|
-
|
|
58
|
+
const rows = editor.render(80).map(stripTerminalSequences)
|
|
59
|
+
expect(rows.join('\n')).not.toContain('? for shortcuts')
|
|
60
|
+
expect(rows.join('\n')).not.toContain('to change thinking effort')
|
|
61
|
+
editor.handleInput('x')
|
|
62
|
+
expect(editor.render(80).map(stripTerminalSequences).join('\n')).not.toContain('to clear')
|
|
48
63
|
})
|
|
49
64
|
|
|
50
65
|
it('preserves editing, multiline wrapping, history, and cursor markers across resizes', () => {
|
|
@@ -67,7 +82,7 @@ describe('band composer over the native Pi editor', () => {
|
|
|
67
82
|
expect(editor.getText()).toBe('previous prompt')
|
|
68
83
|
})
|
|
69
84
|
|
|
70
|
-
it('
|
|
85
|
+
it('lists autocomplete above the composer with matching orange dividers and accepts a selection', async () => {
|
|
71
86
|
const editor = setup()
|
|
72
87
|
editor.setAutocompleteProvider({
|
|
73
88
|
getSuggestions: async () => ({ items: [{ value: '/accounts', label: '/accounts' }, { value: '/plugins', label: '/plugins' }], prefix: '/' }),
|
|
@@ -76,9 +91,23 @@ describe('band composer over the native Pi editor', () => {
|
|
|
76
91
|
editor.handleInput('/')
|
|
77
92
|
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
78
93
|
const rows = editor.render(50)
|
|
79
|
-
|
|
94
|
+
const band = rows.findIndex((row) => stripTerminalSequences(row).startsWith('[band]'))
|
|
95
|
+
const completion = rows.findIndex((row) => stripTerminalSequences(row).includes('/accounts'))
|
|
96
|
+
const composerDivider = rows.findIndex((row, index) => index > completion && stripTerminalSequences(row) === '─'.repeat(50))
|
|
97
|
+
expect(rows[0]).toContain('38;2;255;179;71')
|
|
98
|
+
expect(rows[composerDivider]).toContain('38;2;136;136;136')
|
|
99
|
+
expect(completion).toBeGreaterThan(0)
|
|
100
|
+
expect(composerDivider).toBeGreaterThan(completion)
|
|
101
|
+
expect(band).toBeGreaterThan(composerDivider)
|
|
80
102
|
expect(rows.every((row) => visibleWidth(row) <= 50)).toBe(true)
|
|
81
103
|
editor.handleInput('\t')
|
|
82
104
|
expect(editor.getText()).toContain('/accounts')
|
|
83
105
|
})
|
|
106
|
+
|
|
107
|
+
it('does not hand the band row to the editor as its own', () => {
|
|
108
|
+
const editor = setup()
|
|
109
|
+
editor.render(40)
|
|
110
|
+
const at = (y: number) => ({ x: 5, y, width: 40, height: 5 }) as TuiMouseEvent
|
|
111
|
+
expect(editor.handleMouse(at(3))).toBeUndefined()
|
|
112
|
+
})
|
|
84
113
|
})
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
// Harness owns composer geometry and actions; Pi provides the editing primitive.
|
|
2
2
|
import { CustomEditor, type KeybindingsManager, type Theme } from '@earendil-works/pi-coding-agent'
|
|
3
|
-
import { CURSOR_MARKER, type EditorTheme, type TUI, type TuiMouseEvent,
|
|
3
|
+
import { CURSOR_MARKER, type EditorTheme, type TUI, type TuiMouseEvent, parseColor, renderFakeCursor, truncateToWidth } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
import { autocompleteRuleColor, composerRuleColor } from './theme'
|
|
4
6
|
|
|
5
7
|
const BOTTOM = '\u0000harness-composer-bottom'
|
|
6
8
|
|
|
9
|
+
// Generic on purpose: the placeholder names no file, project or product.
|
|
10
|
+
export const COMPOSER_SUGGESTIONS = [
|
|
11
|
+
'fix the failing test',
|
|
12
|
+
'explain how this project is laid out',
|
|
13
|
+
'find where the configuration is loaded',
|
|
14
|
+
'summarize what changed since the last commit',
|
|
15
|
+
'write a test for the next change'
|
|
16
|
+
]
|
|
17
|
+
|
|
7
18
|
export interface ComposerView {
|
|
8
19
|
band(width: number, hiddenAbove: number, hiddenBelow: number): string
|
|
9
20
|
theme(): Theme
|
|
10
|
-
|
|
11
|
-
thinkingKey: string
|
|
21
|
+
placeholder(): string | undefined
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export class HarnessEditor extends CustomEditor {
|
|
15
25
|
private hiddenAbove = 0
|
|
16
26
|
private hiddenBelow = 0
|
|
17
27
|
private contentRows = 1
|
|
18
|
-
private
|
|
28
|
+
private completionRows = 0
|
|
29
|
+
private gutterWidth = 2
|
|
19
30
|
|
|
20
31
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
|
|
21
32
|
super(tui, theme, keybindings, { paddingX: 0 })
|
|
@@ -37,7 +48,7 @@ export class HarnessEditor extends CustomEditor {
|
|
|
37
48
|
|
|
38
49
|
override render(width: number): string[] {
|
|
39
50
|
if (width < 1) return []
|
|
40
|
-
this.gutterWidth = Math.min(
|
|
51
|
+
this.gutterWidth = Math.min(2, Math.max(0, width - 1))
|
|
41
52
|
const innerWidth = Math.max(1, width - this.gutterWidth)
|
|
42
53
|
// Pi 1.2 cannot lay out a two-cell grapheme in a one-cell text area.
|
|
43
54
|
// Lay out safely, then clip the presentation without changing the draft.
|
|
@@ -46,33 +57,52 @@ export class HarnessEditor extends CustomEditor {
|
|
|
46
57
|
const content = rows.slice(1, bottom)
|
|
47
58
|
this.contentRows = content.length
|
|
48
59
|
const theme = this.view.theme()
|
|
60
|
+
const placeholder = this.getText() === '' ? this.view.placeholder() : undefined
|
|
49
61
|
const prompt = content.map((line, index) => {
|
|
50
|
-
const cue = index === 0 ? '
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const hint = theme.fg('accent', this.view.thinkingKey) + ' ' + theme.italic(theme.fg('dim', 'to change thinking effort'))
|
|
54
|
-
// Why: a one-cell slice cuts off the fake cursor's closing marker, so pi-tui
|
|
55
|
-
// paints the rest of the row in reverse video. Two cells keep the marker.
|
|
56
|
-
const prefix = sliceByColumn(line, 0, 2)
|
|
57
|
-
const gap = innerWidth - visibleWidth(prefix) - visibleWidth(hint)
|
|
58
|
-
if (gap >= 1) text = prefix + ' '.repeat(gap) + hint
|
|
59
|
-
}
|
|
60
|
-
return theme.fg('borderMuted', cue) + text
|
|
62
|
+
const cue = theme.fg('dim', index === 0 ? '❯ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth))
|
|
63
|
+
if (index === 0 && placeholder) return cue + this.placeholderRow(placeholder, innerWidth, theme)
|
|
64
|
+
return cue + line
|
|
61
65
|
})
|
|
62
66
|
const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
|
|
63
|
-
|
|
67
|
+
this.completionRows = completions.length
|
|
68
|
+
const completionDivider = completions.length > 0 ? [this.rule(width, theme, autocompleteRuleColor(theme))] : []
|
|
69
|
+
const result = [
|
|
70
|
+
...completionDivider, ...completions,
|
|
71
|
+
this.rule(width, theme), ...prompt, this.rule(width, theme), this.view.band(width, this.hiddenAbove, this.hiddenBelow)
|
|
72
|
+
].map((line) => truncateToWidth(line, width, ''))
|
|
64
73
|
if (this.focused && !result.some((line) => line.includes(CURSOR_MARKER))) {
|
|
65
|
-
const
|
|
74
|
+
const composerOffset = completions.length > 0 ? completions.length + 1 : 0
|
|
75
|
+
const index = composerOffset + Math.max(1, Math.min(result.length - composerOffset - 1, prompt.length))
|
|
66
76
|
result[index] = truncateToWidth(result[index], Math.max(0, width - 1), '') + CURSOR_MARKER + ' '
|
|
67
77
|
}
|
|
68
78
|
return result
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
private rule(width: number, theme: Theme, color = composerRuleColor(theme)): string {
|
|
82
|
+
return theme.style('─'.repeat(width), { fg: parseColor(color) })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private placeholderRow(suggestion: string, innerWidth: number, theme: Theme): string {
|
|
86
|
+
const text = truncateToWidth(`Try "${suggestion}"`, innerWidth, '')
|
|
87
|
+
if (!this.focused) return theme.fg('dim', text)
|
|
88
|
+
// The first character is the fake cursor, so an empty prompt reads as the caret it will type into.
|
|
89
|
+
const [first = '', ...rest] = Array.from(text)
|
|
90
|
+
return CURSOR_MARKER + renderFakeCursor(first) + theme.fg('dim', rest.join(''))
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
override handleMouse(event: TuiMouseEvent) {
|
|
94
|
+
// Pi sees its own rows; the suggestion divider and status band are presentation rows.
|
|
95
|
+
const composerOffset = this.completionRows > 0 ? this.completionRows + 1 : 0
|
|
96
|
+
const band = composerOffset + this.contentRows + 2
|
|
97
|
+
if (event.y === band) return undefined
|
|
98
|
+
if (this.completionRows > 0 && event.y === 0) return undefined
|
|
99
|
+
const y = this.completionRows > 0 && event.y <= this.completionRows
|
|
100
|
+
? this.contentRows + 1 + event.y
|
|
101
|
+
: event.y - composerOffset
|
|
72
102
|
return super.handleMouse({
|
|
73
103
|
...event,
|
|
74
104
|
x: event.x - this.gutterWidth,
|
|
75
|
-
y
|
|
105
|
+
y,
|
|
76
106
|
width: Math.max(1, event.width - this.gutterWidth)
|
|
77
107
|
})
|
|
78
108
|
}
|
|
@@ -4,6 +4,14 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { expect, it } from 'vitest'
|
|
5
5
|
import { terminalKeybindings } from './keybindings'
|
|
6
6
|
|
|
7
|
+
it('steps thinking effort on shift arrows and gives shift+tab to plan mode', () => {
|
|
8
|
+
const manager = terminalKeybindings('/unused/isolated-account')
|
|
9
|
+
expect(manager.matches('\x1b[1;2A', 'app.thinking.increase')).toBe(true)
|
|
10
|
+
expect(manager.matches('\x1b[1;2B', 'app.thinking.decrease')).toBe(true)
|
|
11
|
+
expect(manager.matches('\x1b[Z', 'app.plan.toggle')).toBe(true)
|
|
12
|
+
expect(manager.matches('\x1b[Z', 'app.thinking.cycle')).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
7
15
|
it('reloads edited isolated keybindings on the existing public manager', () => {
|
|
8
16
|
const root = mkdtempSync(join(tmpdir(), 'harness-keybindings-'))
|
|
9
17
|
try {
|
|
@@ -3,11 +3,20 @@ import { readFileSync } from 'node:fs'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { KeybindingsManager, TUI_KEYBINDINGS, type KeybindingDefinitions, type KeybindingsConfig } from '@earendil-works/pi-tui'
|
|
5
5
|
|
|
6
|
+
declare module '@earendil-works/pi-tui' {
|
|
7
|
+
interface Keybindings {
|
|
8
|
+
'app.thinking.increase': true
|
|
9
|
+
'app.thinking.decrease': true
|
|
10
|
+
'app.plan.toggle': true
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
// Harness owns application actions. Editing and selection retain Pi's reusable bindings.
|
|
7
15
|
const actions: Record<string, string | string[]> = {
|
|
8
16
|
'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
|
|
9
17
|
'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
|
|
10
|
-
'app.thinking.cycle':
|
|
18
|
+
'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s', 'app.plan.toggle': 'shift+tab',
|
|
19
|
+
'app.thinking.increase': 'shift+up', 'app.thinking.decrease': 'shift+down',
|
|
11
20
|
'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
|
|
12
21
|
'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
|
|
13
22
|
'app.editor.external': 'ctrl+g', 'app.message.copy': 'ctrl+x',
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { planModeTools } from './planMode'
|
|
4
|
+
|
|
5
|
+
describe('plan mode tools', () => {
|
|
6
|
+
it('keeps read-only inspection, questions and recall, and withdraws changes, commands and delegation', () => {
|
|
7
|
+
const normal = ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'ask_user_question', 'wiki_search', 'memory_append', 'spawn_subagent', 'app_tool', 'history_read']
|
|
8
|
+
expect(planModeTools(normal)).toEqual(['read', 'grep', 'find', 'ls', 'ask_user', 'ask_user_question', 'wiki_search', 'history_read'])
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ASK_USER } from '../../tools/askUser'
|
|
2
|
+
|
|
3
|
+
// Read-only inspection, questions and recall. Everything else is withdrawn in plan mode: file changes, commands,
|
|
4
|
+
// memory writes, delegation and connected apps, since none of them can be judged read-only from their names.
|
|
5
|
+
const READ_ONLY_TOOLS = new Set([
|
|
6
|
+
'read', 'grep', 'find', 'ls', ASK_USER, 'ask_user_question',
|
|
7
|
+
'history_search', 'history_read', 'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts'
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
export function planModeTools(normal: readonly string[]): string[] {
|
|
11
|
+
return normal.filter(name => READ_ONLY_TOOLS.has(name))
|
|
12
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
-
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
@@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
7
7
|
import { createHarness, type Harness } from '../../harness'
|
|
8
8
|
import { memorySecretStore } from '../../accounts/secrets'
|
|
9
9
|
import { connectionFromEnv, socketCall, terminalCall } from '../client'
|
|
10
|
+
import type { TerminalPrepareInput } from '../terminals'
|
|
10
11
|
import { controlCall } from '../control'
|
|
12
|
+
import { readAllRuns } from '../../sessions/runIndex'
|
|
11
13
|
import { createTerminalRuntime, type TerminalRuntime } from './runtime'
|
|
12
14
|
|
|
13
15
|
const opened: Array<{ harness: Harness; runtime?: TerminalRuntime; root: string }> = []
|
|
@@ -21,7 +23,7 @@ afterEach(async () => {
|
|
|
21
23
|
vi.unstubAllEnvs()
|
|
22
24
|
})
|
|
23
25
|
|
|
24
|
-
async function setup() {
|
|
26
|
+
async function setup(input: Partial<TerminalPrepareInput> = {}) {
|
|
25
27
|
const root = mkdtempSync(join(tmpdir(), 'harness-sdk-socket-'))
|
|
26
28
|
const cwd = join(root, 'project')
|
|
27
29
|
mkdirSync(cwd)
|
|
@@ -30,7 +32,7 @@ async function setup() {
|
|
|
30
32
|
opened.push(entry)
|
|
31
33
|
await harness.ready
|
|
32
34
|
const account = harness.accounts.create('Offline test', 'apiKey').accounts[0]
|
|
33
|
-
const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history') })
|
|
35
|
+
const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history'), ...input })
|
|
34
36
|
for (const [name, value] of Object.entries(launch.env)) vi.stubEnv(name, value === null ? undefined : value)
|
|
35
37
|
vi.spyOn(process, 'cwd').mockReturnValue(cwd)
|
|
36
38
|
const ui = { select: vi.fn(async () => undefined), confirm: vi.fn(async () => false), notify: vi.fn(), getEditorText: () => '' } as unknown as ExtensionUIContext
|
|
@@ -40,6 +42,78 @@ async function setup() {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
describe('a native SDK runtime on the actual Harness terminal socket', () => {
|
|
45
|
+
it('reads and changes shared terminal permissions only through the claimed private control key', async () => {
|
|
46
|
+
const { runtime } = await setup()
|
|
47
|
+
await expect(socketCall(connectionFromEnv(), 'terminal.permissions.get')).rejects.toThrow('Only a person')
|
|
48
|
+
expect(await runtime.permissions()).toEqual({ full: false, globalTools: [] })
|
|
49
|
+
await runtime.setGlobalTool('bash', true)
|
|
50
|
+
await runtime.setFullPermission(true)
|
|
51
|
+
expect(await runtime.permissions()).toEqual({ full: true, globalTools: ['bash'] })
|
|
52
|
+
await runtime.setGlobalTool('bash', false)
|
|
53
|
+
await runtime.setFullPermission(false)
|
|
54
|
+
expect(await runtime.permissions()).toEqual({ full: false, globalTools: [] })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('runs asynchronous children through the protected bridge and joins their results before settling', async () => {
|
|
58
|
+
const { harness, runtime } = await setup()
|
|
59
|
+
const ai = await import('@earendil-works/pi-ai')
|
|
60
|
+
const faux = ai.fauxProvider()
|
|
61
|
+
runtime.services.modelRuntime.registerNativeProvider(faux.provider)
|
|
62
|
+
harness.accounts.runtime = async () => runtime.services.modelRuntime
|
|
63
|
+
await runtime.session.setModel(faux.getModel())
|
|
64
|
+
const route = (context: { messages: Array<{ role: string; content: unknown }> }) => {
|
|
65
|
+
const users = context.messages.filter(message => message.role === 'user').map(message => typeof message.content === 'string' ? message.content : (message.content as Array<{ text?: string }>).map(part => part.text ?? '').join(''))
|
|
66
|
+
if (users.includes('terminal-native-child')) return ai.fauxAssistantMessage('TERMINAL_CHILD_EVIDENCE')
|
|
67
|
+
if (JSON.stringify(context.messages).includes('TERMINAL_CHILD_EVIDENCE')) return ai.fauxAssistantMessage('Final with TERMINAL_CHILD_EVIDENCE')
|
|
68
|
+
if (!context.messages.some(message => message.role === 'toolResult')) return ai.fauxAssistantMessage([ai.fauxToolCall('spawn_subagent', { brief: 'terminal-native-child' })], { stopReason: 'toolUse' })
|
|
69
|
+
return ai.fauxAssistantMessage('Draft before joining')
|
|
70
|
+
}
|
|
71
|
+
faux.setResponses(Array(8).fill(route) as never)
|
|
72
|
+
await expect(socketCall(connectionFromEnv(), 'terminal.subagents.tools')).rejects.toThrow('Only a person')
|
|
73
|
+
await runtime.session.prompt('Terminal parent')
|
|
74
|
+
const runs = readAllRuns(harness.env.root)
|
|
75
|
+
const parent = runs.find(run => run.sessionId === 'offline-sdk')!
|
|
76
|
+
const child = runs.find(run => run.relation === 'subagent')!
|
|
77
|
+
expect(parent.status).toBe('completed')
|
|
78
|
+
expect(child).toMatchObject({ parentRunId: parent.id, accountId: parent.accountId, status: 'completed' })
|
|
79
|
+
expect(harness.runs.activeRunIds()).toEqual([])
|
|
80
|
+
const entries = readFileSync(runtime.session.sessionFile!, 'utf8').trim().split('\n').map(line => JSON.parse(line))
|
|
81
|
+
const receipts = entries.filter(entry => entry.customType === 'xingular-subagent-results')
|
|
82
|
+
expect(receipts).toHaveLength(1)
|
|
83
|
+
expect(receipts[0].content).toContain('TERMINAL_CHILD_EVIDENCE')
|
|
84
|
+
expect(harness.terminals.state('offline-sdk').active).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('routes a child approval to its original run through the terminal decision controls', async () => {
|
|
88
|
+
const { harness, runtime } = await setup()
|
|
89
|
+
await terminalCall('begin', { text: 'Parent fixture' })
|
|
90
|
+
await controlCall('terminal.subagents.register', { tools: ['read', 'bash', 'spawn_subagent', 'wait_subagents'], history: { owners: [], receipts: [] } })
|
|
91
|
+
const parentId = harness.runs.activeForSession('offline-sdk')!.runId
|
|
92
|
+
const { loadRun, saveRun } = await import('../../sessions/runStore')
|
|
93
|
+
const parent = loadRun(harness.env.root, parentId)
|
|
94
|
+
saveRun(harness.env.root, { ...parent, id: 'approval-child', sessionId: 'opaque-child', parentRunId: parentId, relation: 'subagent', status: 'queued', endedAt: null })
|
|
95
|
+
const active = harness.runs.start('opaque-child', 'approval-child')
|
|
96
|
+
const answer = active.requestApproval({ target: 'bash', action: 'fixture', rememberable: false })
|
|
97
|
+
let pending: Awaited<ReturnType<typeof runtime.pending>> = []
|
|
98
|
+
await vi.waitFor(async () => { pending = await runtime.pending(); expect(pending).toHaveLength(1) })
|
|
99
|
+
await runtime.approve(pending[0].requestId, 'denied')
|
|
100
|
+
await expect(answer).resolves.toBe('denied')
|
|
101
|
+
expect(await runtime.pending()).toEqual([])
|
|
102
|
+
active.finish('interrupted')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('offers native tools without connected apps and preserves a read-only child ceiling', async () => {
|
|
106
|
+
const { harness, runtime } = await setup({ connections: false, readOnly: true })
|
|
107
|
+
expect(runtime.session.getToolDefinition('spawn_subagent')).toBeDefined()
|
|
108
|
+
await terminalCall('begin', { text: 'Read-only parent' })
|
|
109
|
+
await controlCall('terminal.subagents.register', { tools: ['read', 'write', 'bash', 'spawn_subagent', 'wait_subagents'], history: { owners: [], receipts: [] } })
|
|
110
|
+
const parentRunId = harness.runs.activeForSession('offline-sdk')!.runId
|
|
111
|
+
await expect(harness.subagents.spawn({ parentRunId, brief: 'Cannot widen', tools: ['write'] })).rejects.toThrow('cannot have tools')
|
|
112
|
+
const { loadRun } = await import('../../capabilities/runSnapshot')
|
|
113
|
+
expect(loadRun(harness.env.root, parentRunId)?.tools).toEqual(['read', 'spawn_subagent', 'wait_subagents'])
|
|
114
|
+
await runtime.interrupt()
|
|
115
|
+
})
|
|
116
|
+
|
|
43
117
|
it('keeps the same account, command control and readiness after new/reload with no provider request', async () => {
|
|
44
118
|
const { harness, runtime, ui } = await setup()
|
|
45
119
|
expect(harness.terminals.state('offline-sdk')).toEqual({ ready: true, dirty: false, active: false })
|
|
@@ -187,6 +187,16 @@ describe('the persistent terminal SDK adapter', () => {
|
|
|
187
187
|
expect(runtime.session.sessionFile).toBe(join(realpathSync(history), 'external.jsonl'))
|
|
188
188
|
})
|
|
189
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
|
+
|
|
190
200
|
it('requires the prepared isolated account and refuses malformed session headers', async () => {
|
|
191
201
|
const f = fixture()
|
|
192
202
|
vi.stubEnv('PI_CODING_AGENT_DIR', '')
|