@xingular/harness 0.1.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,62 @@
1
+ // Pi 1.2.0 omits CLI builtins, rich builtin renderers, clipboard reads and the global theme setter
2
+ // from its public exports. Keep those version-sensitive presentation hooks here; session/runtime
3
+ // work uses public APIs. Clipboard helpers are only called for an explicit user paste.
4
+ import { getPackageDir, VERSION, type InlineExtension, type Theme, type ToolRenderers } from '@earendil-works/pi-coding-agent'
5
+ import { join } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+
8
+ export interface PiAdapter {
9
+ builtins: InlineExtension[]
10
+ setTheme(theme: Theme): void
11
+ withBuiltInRenderers(toolName: string, definition: ToolRenderers | undefined): ToolRenderers | undefined
12
+ readClipboardText(): Promise<string | null>
13
+ readClipboardFilePaths(): Promise<string[] | null>
14
+ readClipboardImage(): Promise<{ bytes: Uint8Array; mimeType: string } | null>
15
+ extensionForImageMimeType(mimeType: string): string | null
16
+ }
17
+
18
+ let adapter: PiAdapter | undefined
19
+ let loading: Promise<PiAdapter> | undefined
20
+
21
+ export function assertPiAdapterVersion(version: string): void {
22
+ if (version !== '1.2.0') throw new Error(`The Harness terminal adapter requires Pi 1.2.0; found ${version}. Install the Harness package's pinned dependencies.`)
23
+ }
24
+
25
+ export function loadPiAdapter(): Promise<PiAdapter> {
26
+ loading ??= (async () => {
27
+ assertPiAdapterVersion(VERSION)
28
+ const root = getPackageDir()
29
+ const [extensions, theme, renderers, clipboard, images] = await Promise.all([
30
+ import(pathToFileURL(join(root, 'dist/extensions/index.js')).href),
31
+ import(pathToFileURL(join(root, 'dist/modes/interactive/theme/theme.js')).href),
32
+ import(pathToFileURL(join(root, 'dist/core/tools/renderers/index.js')).href),
33
+ import(pathToFileURL(join(root, 'dist/utils/clipboard.js')).href),
34
+ import(pathToFileURL(join(root, 'dist/utils/clipboard-image.js')).href)
35
+ ])
36
+ if (!Array.isArray(extensions.builtInExtensions) || typeof theme.setThemeInstance !== 'function' || typeof renderers.withBuiltInRenderers !== 'function' || typeof clipboard.readClipboardText !== 'function' || typeof clipboard.readClipboardFilePaths !== 'function' || typeof images.readClipboardImage !== 'function' || typeof images.extensionForImageMimeType !== 'function') {
37
+ throw new Error('The pinned Pi terminal adapter no longer exposes its required builtin or theme hooks.')
38
+ }
39
+ adapter = {
40
+ builtins: extensions.builtInExtensions as InlineExtension[],
41
+ setTheme: theme.setThemeInstance,
42
+ withBuiltInRenderers: renderers.withBuiltInRenderers,
43
+ readClipboardText: clipboard.readClipboardText,
44
+ readClipboardFilePaths: clipboard.readClipboardFilePaths,
45
+ readClipboardImage: images.readClipboardImage,
46
+ extensionForImageMimeType: images.extensionForImageMimeType
47
+ }
48
+ return adapter
49
+ })()
50
+ return loading
51
+ }
52
+
53
+ /** Initialize once with loadPiAdapter() before rendering native rich tool components. */
54
+ export function setPiTheme(theme: Theme): void {
55
+ if (!adapter) throw new Error('Initialize the Pi terminal adapter before setting its theme.')
56
+ adapter.setTheme(theme)
57
+ }
58
+
59
+ export function withBuiltInRenderers(toolName: string, definition: ToolRenderers | undefined): ToolRenderers | undefined {
60
+ if (!adapter) throw new Error('Initialize the Pi terminal adapter before resolving builtin renderers.')
61
+ return adapter.withBuiltInRenderers(toolName, definition)
62
+ }
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent'
2
+ import { colorToHex, stripTerminalSequences, visibleWidth, type TerminalColorMode } from '@earendil-works/pi-tui'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { createHarnessLightTheme, createHarnessTheme } from './theme'
6
+ import { gradientLogo, HarnessWelcome, PI_LOGO } from './welcome'
7
+
8
+ function titanium(mode: TerminalColorMode = 'truecolor'): Theme {
9
+ return createHarnessTheme({ getColorMode: () => mode } as Theme)
10
+ }
11
+
12
+ describe('the Harness titanium presentation', () => {
13
+ it('uses the upstream titanium foreground and background colors with complete Pi fallbacks', () => {
14
+ const theme = titanium()
15
+ expect(theme.name).toBe('harness-titanium')
16
+ expect(theme.appearance).toBe('dark')
17
+ const expected = {
18
+ accent: '#00b4ff', border: '#2a3038', success: '#00ff88', error: '#ff4757',
19
+ customMessageLabel: '#d4c090', userMessageBg: '#0f1216', toolErrorBg: '#1a0f10',
20
+ thinkingMax: '#d4c090', scrollbarTrack: '#2a3038', scrollbarThumb: '#9ca3b0',
21
+ searchMatchBg: '#0082b3', searchMatchText: '#e8ecf4'
22
+ } as const
23
+ for (const [token, color] of Object.entries(expected)) {
24
+ expect(colorToHex(theme.colors[token as keyof typeof expected]), token).toBe(color)
25
+ }
26
+ expect(Object.keys(theme.colors)).toHaveLength(56)
27
+ expect(theme.getFgAnsi('text')).toBe('\x1b[39m')
28
+ expect(theme.getBgAnsi('userMessageBg')).toBe('\x1b[48;2;15;18;22m')
29
+ })
30
+
31
+ it('offers the shipped light palette with complete native component fallbacks', () => {
32
+ const theme = createHarnessLightTheme(titanium())
33
+ expect(theme.name).toBe('light')
34
+ expect(theme.appearance).toBe('light')
35
+ expect(Object.keys(theme.colors)).toHaveLength(56)
36
+ expect(colorToHex(theme.colors.accent)).toBe('#5a8080')
37
+ expect(colorToHex(theme.colors.selectedBg)).toBe('#d0d0e0')
38
+ expect(colorToHex(theme.colors.toolSuccessBg)).toBe('#e8f0e8')
39
+ })
40
+
41
+ it('keeps the current terminal color mode and uses the exact upstream logo ramps', () => {
42
+ const truecolor = gradientLogo(PI_LOGO, 'truecolor')
43
+ expect(truecolor[0]).toMatch(/^\x1b\[38;2;248;79;204m█/)
44
+ expect(truecolor[0]).toContain('\x1b[38;2;147;98;244m█\x1b[0m')
45
+ expect(truecolor.map(stripTerminalSequences)).toEqual(PI_LOGO)
46
+ const theme = titanium('256color')
47
+ expect(theme.getColorMode()).toBe('256color')
48
+ const indexed = gradientLogo(PI_LOGO, theme.getColorMode())
49
+ expect(indexed[0]).toMatch(/^\x1b\[38;5;206m█/)
50
+ expect(indexed[0]).toContain('\x1b[38;5;99m█\x1b[0m')
51
+ expect(indexed.join('')).not.toContain('[38;2;')
52
+ })
53
+
54
+ it('keeps every rendered row within the terminal across narrow widths and Unicode product names', () => {
55
+ for (const name of ['Xingular Harness', 'Harness 知識 🧠', 'A'.repeat(120)]) {
56
+ const welcome = new HarnessWelcome(titanium(), name)
57
+ for (let width = 0; width <= 160; width++) {
58
+ const lines = welcome.render(width)
59
+ for (const line of lines) expect(visibleWidth(line), `width ${width}, ${name}`).toBeLessThanOrEqual(width)
60
+ if (width < 14) expect(lines).toEqual([])
61
+ }
62
+ }
63
+ })
64
+
65
+ it('shows a centered five-row logo alone in narrow terminals and centers the full lockup when it fits', () => {
66
+ const welcome = new HarnessWelcome(titanium(), 'Xingular Harness', '0.1.6')
67
+ const narrow = welcome.render(30).map(stripTerminalSequences)
68
+ expect(narrow).toEqual(PI_LOGO.map((line) => ' '.repeat(9) + line))
69
+ const wide = welcome.render(100).map(stripTerminalSequences)
70
+ expect(wide[1]).toContain('Xingular Harness')
71
+ expect(wide[4]).toContain('v0.1.6')
72
+ expect(wide[0].indexOf('█')).toBe(34)
73
+ expect(wide.slice(6).join(' ')).toContain('/accounts')
74
+ expect(wide.slice(6).join(' ')).toContain('/plugins')
75
+ expect(wide.slice(6).join(' ')).toContain('/hub')
76
+ expect(wide.join('')).not.toContain('omp')
77
+ expect(welcome.render(49)).toHaveLength(5)
78
+ })
79
+ })
@@ -0,0 +1,41 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { answerTerminalQuestion } from './questions'
4
+
5
+ function fixture() {
6
+ const ui = { select: vi.fn(), input: vi.fn() }
7
+ return { ui, context: ui as unknown as ExtensionUIContext, signal: new AbortController().signal }
8
+ }
9
+
10
+ describe('terminal question answers', () => {
11
+ it('keeps action-like option labels distinct from dialog actions', async () => {
12
+ const { ui, context, signal } = fixture()
13
+ ui.select.mockResolvedValue('1. Write an answer — Actual choice')
14
+ expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Choose?', options: [{ label: 'Write an answer', description: 'Actual choice' }] }, signal)).toEqual({ selected: ['Write an answer'] })
15
+ expect(ui.input).not.toHaveBeenCalled()
16
+ })
17
+
18
+ it('returns to choices when text entry is cancelled and retains multiple selections', async () => {
19
+ const { ui, context, signal } = fixture()
20
+ ui.select.mockResolvedValueOnce('1. A').mockResolvedValueOnce('Write an answer').mockResolvedValueOnce('Submit choices')
21
+ ui.input.mockResolvedValue(undefined)
22
+ expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Choose?', options: [{ label: 'A' }], multiSelect: true }, signal)).toEqual({ selected: ['A'] })
23
+ expect(ui.select).toHaveBeenCalledTimes(3)
24
+ expect(ui.select.mock.calls[2][1]).toContain('1. ✓ A')
25
+ })
26
+
27
+ it('shows real batch progress and returns cancellation without inventing an answer', async () => {
28
+ const { ui, context, signal } = fixture()
29
+ ui.input.mockResolvedValue(undefined)
30
+ expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Why?', summary: 'Reason', batchId: 'batch', batchIndex: 2, batchCount: 3 }, signal)).toBeUndefined()
31
+ expect(ui.input).toHaveBeenCalledWith('2 of 3 · Reason: Why?', undefined, { signal })
32
+ })
33
+
34
+ it('honors text restrictions and ignores answers after expiry', async () => {
35
+ const { ui, context } = fixture()
36
+ const controller = new AbortController()
37
+ ui.select.mockImplementation(async () => { controller.abort(); return '1. A' })
38
+ expect(await answerTerminalQuestion(context, { requestId: 'q', options: [{ label: 'A' }], allowText: false }, controller.signal)).toBeUndefined()
39
+ expect(ui.select.mock.calls[0][1]).toEqual(['1. A'])
40
+ })
41
+ })
@@ -0,0 +1,47 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
2
+ import type { QuestionAnswer } from '../../sessions/registry'
3
+
4
+ export interface TerminalQuestion {
5
+ requestId: string
6
+ question?: string
7
+ summary?: string
8
+ options?: { label: string; description?: string }[]
9
+ multiSelect?: boolean
10
+ allowText?: boolean
11
+ batchId?: string
12
+ batchIndex?: number
13
+ batchCount?: number
14
+ }
15
+
16
+ /** Answers stay local until the complete group can be submitted to the registry. */
17
+ export async function answerTerminalQuestion(ui: ExtensionUIContext, request: TerminalQuestion, signal: AbortSignal): Promise<QuestionAnswer | undefined> {
18
+ const choices = request.options ?? []
19
+ const selected: string[] = []
20
+ const progress = request.batchId ? `${request.batchIndex} of ${request.batchCount} · ` : ''
21
+ const title = `${progress}${request.summary ? request.summary + ': ' : ''}${request.question ?? 'Question'}`
22
+ if (!choices.length) {
23
+ if (request.allowText === false) return { selected }
24
+ const text = await ui.input(title, undefined, { signal })
25
+ return text === undefined || signal.aborted ? undefined : { selected, text }
26
+ }
27
+ while (!signal.aborted) {
28
+ const labels = choices.map((option, index) => `${index + 1}. ${selected.includes(option.label) ? '✓ ' : ''}${option.label}${option.description ? ' — ' + option.description : ''}`)
29
+ const menu = [...labels, ...(request.allowText !== false ? ['Write an answer'] : []), ...(request.multiSelect ? ['Submit choices'] : [])]
30
+ const choice = await ui.select(title, menu, { signal })
31
+ if (choice === undefined || signal.aborted) return undefined
32
+ if (choice === 'Submit choices') return { selected }
33
+ if (choice === 'Write an answer') {
34
+ const text = await ui.input(title, undefined, { signal })
35
+ if (signal.aborted) return undefined
36
+ if (text !== undefined) return { selected, text }
37
+ continue
38
+ }
39
+ const index = labels.indexOf(choice)
40
+ if (index < 0) throw new Error('Choose one of the displayed answers.')
41
+ const label = choices[index]!.label
42
+ if (selected.includes(label)) selected.splice(selected.indexOf(label), 1)
43
+ else selected.push(label)
44
+ if (!request.multiSelect) return { selected }
45
+ }
46
+ return undefined
47
+ }
@@ -0,0 +1,178 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ import { createHarness, type Harness } from '../../harness'
8
+ import { memorySecretStore } from '../../accounts/secrets'
9
+ import { connectionFromEnv, socketCall, terminalCall } from '../client'
10
+ import type { TerminalPrepareInput } from '../terminals'
11
+ import { controlCall } from '../control'
12
+ import { readAllRuns } from '../../sessions/runIndex'
13
+ import { createTerminalRuntime, type TerminalRuntime } from './runtime'
14
+
15
+ const opened: Array<{ harness: Harness; runtime?: TerminalRuntime; root: string }> = []
16
+ afterEach(async () => {
17
+ for (const entry of opened.splice(0)) {
18
+ await entry.runtime?.dispose()
19
+ await entry.harness.close()
20
+ rmSync(entry.root, { recursive: true, force: true })
21
+ }
22
+ vi.restoreAllMocks()
23
+ vi.unstubAllEnvs()
24
+ })
25
+
26
+ async function setup(input: Partial<TerminalPrepareInput> = {}) {
27
+ const root = mkdtempSync(join(tmpdir(), 'harness-sdk-socket-'))
28
+ const cwd = join(root, 'project')
29
+ mkdirSync(cwd)
30
+ const harness = createHarness({ dataRoot: join(root, 'data'), stateDir: join(root, 'state'), secrets: memorySecretStore(), resolver: async () => [], piProgram: () => '/not-spawned/pi', assets: { node: process.execPath, cli: join(root, 'cli.js'), piExtension: join(root, 'terminal-pi.js') } })
31
+ const entry: { harness: Harness; runtime?: TerminalRuntime; root: string } = { harness, root }
32
+ opened.push(entry)
33
+ await harness.ready
34
+ const account = harness.accounts.create('Offline test', 'apiKey').accounts[0]
35
+ const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history'), ...input })
36
+ for (const [name, value] of Object.entries(launch.env)) vi.stubEnv(name, value === null ? undefined : value)
37
+ vi.spyOn(process, 'cwd').mockReturnValue(cwd)
38
+ const ui = { select: vi.fn(async () => undefined), confirm: vi.fn(async () => false), notify: vi.fn(), getEditorText: () => '' } as unknown as ExtensionUIContext
39
+ const runtime = await createTerminalRuntime(launch.args, { ui, notify: vi.fn(), changed: vi.fn(), shutdown: vi.fn() })
40
+ entry.runtime = runtime
41
+ return { harness, runtime, ui, account }
42
+ }
43
+
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
+
117
+ it('keeps the same account, command control and readiness after new/reload with no provider request', async () => {
118
+ const { harness, runtime, ui } = await setup()
119
+ expect(harness.terminals.state('offline-sdk')).toEqual({ ready: true, dirty: false, active: false })
120
+ expect(runtime.session.extensionRunner.getExtensionPaths()).toEqual(['<inline:harness-terminal-bridge>'])
121
+ expect(runtime.services.agentDir).toContain(harness.accounts.dir)
122
+ for (const command of ['accounts', 'plugins', 'hub']) expect(runtime.session.extensionRunner.getCommand(command)).toBeDefined()
123
+ await runtime.session.prompt('/accounts')
124
+ expect(ui.select).toHaveBeenCalled()
125
+ await runtime.newSession()
126
+ await runtime.reload()
127
+ await runtime.session.prompt('/accounts')
128
+ expect(ui.select).toHaveBeenCalledTimes(2)
129
+ expect(harness.terminals.state('offline-sdk')).toEqual({ ready: true, dirty: false, active: false })
130
+ await expect(controlCall('terminal.resources')).resolves.toEqual({})
131
+ await expect(socketCall(connectionFromEnv(), 'control.claim')).rejects.toThrow('already claimed')
132
+ const blocked = await runtime.session.extensionRunner.emitToolCall({ type: 'tool_call', toolName: 'bash', toolCallId: 'no-active-run', input: { command: 'echo must-not-execute' } })
133
+ expect(blocked?.block).toBe(true)
134
+ await runtime.dispose()
135
+ expect(harness.terminals.state('offline-sdk').ready).toBe(false)
136
+ })
137
+
138
+ it('submits grouped terminal answers atomically through the protected real registry', async () => {
139
+ const { harness, runtime } = await setup()
140
+ const events: Array<{ type: string; payload: unknown }> = []
141
+ harness.runs.listen(event => { events.push(event) })
142
+ await terminalCall('begin', { text: 'Grouped terminal fixture' })
143
+ const tool = runtime.session.getToolDefinition('ask_user')!
144
+ const result = tool.execute('batch-tool', { questions: [
145
+ { summary: 'First choice', question: 'Which?', options: [{ label: 'A', description: 'First option' }] },
146
+ { summary: 'Second answer', question: 'Why?' }
147
+ ] }, undefined, undefined, runtime.session.extensionRunner.createToolContext('batch-tool', undefined))
148
+ let questions: Array<{ requestId: string; batchId: string }> = []
149
+ await vi.waitFor(async () => {
150
+ questions = await runtime.pending() as typeof questions
151
+ expect(questions).toHaveLength(2)
152
+ })
153
+ expect(questions[0].batchId).toBe(questions[1].batchId)
154
+ const answers = [{ questionId: questions[0].requestId, answer: { selected: ['A'] } }, { questionId: questions[1].requestId, answer: { selected: [], text: 'Because' } }]
155
+ await expect(socketCall(connectionFromEnv(), 'terminal.answers', { batchId: questions[0].batchId, answers })).rejects.toThrow('Only a person')
156
+ await expect(controlCall('terminal.answers', { batchId: questions[0].batchId, answers: answers.slice(0, 1) })).rejects.toThrow('batch')
157
+ expect(events.filter(event => event.type === 'question.answered')).toHaveLength(0)
158
+ await runtime.answerBatch(questions[0].batchId, answers)
159
+ await expect(result).resolves.toMatchObject({ content: [{ text: 'First choice: The user chose: A.\nSecond answer: The user answered: Because' }] })
160
+ expect(events.filter(event => event.type === 'question.answered')).toHaveLength(2)
161
+ await expect(controlCall('terminal.pending')).resolves.toEqual({ requests: [] })
162
+ })
163
+
164
+ it('settles an actual socket-backed pending question before disposing the native session', async () => {
165
+ const { harness, runtime } = await setup()
166
+ await terminalCall('begin', { text: 'Offline question bridge fixture' })
167
+ let questionReached!: () => void
168
+ const reached = new Promise<void>((resolve) => { questionReached = resolve })
169
+ harness.runs.listen((event) => { if (event.type === 'question.requested') questionReached() })
170
+ const tool = runtime.session.getToolDefinition('ask_user_question')!
171
+ const answer = tool.execute('offline-question', { question: 'Continue?', options: [] }, undefined, undefined, runtime.session.extensionRunner.createToolContext('offline-question', undefined))
172
+ await reached
173
+ await expect(controlCall<{ requests: unknown[] }>('terminal.pending')).resolves.toMatchObject({ requests: [expect.anything()] })
174
+ await runtime.dispose()
175
+ await expect(answer).resolves.toMatchObject({ content: [{ type: 'text', text: '{"selected":[]}' }] })
176
+ expect(harness.terminals.state('offline-sdk')).toEqual({ ready: false, dirty: false, active: false })
177
+ })
178
+ })