@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,24 @@
1
+ import type { HubOverview, HubReceipt } from '../hub/schema'
2
+ import type { MenuCall, MenuUi } from './pluginMenu'
3
+
4
+ /** The terminal's catalog uses the same exact-version installer as embedding applications. */
5
+ export async function runHubMenu(ui: MenuUi, call: MenuCall): Promise<void> {
6
+ for (;;) {
7
+ const catalog = await call<HubOverview>('hub.list')
8
+ const labels = catalog.entries.map((entry) => `${entry.title} · ${entry.category} · ${entry.id}@${entry.version}`)
9
+ const picked = await ui.select('Hub', [...labels, 'Refresh catalog', 'Done'])
10
+ if (picked === 'Refresh catalog') {
11
+ const refreshed = await call<HubOverview>('hub.list', { refresh: true })
12
+ if (refreshed.error) ui.notify(`${refreshed.error} Using ${refreshed.origin} content.`, 'warning')
13
+ continue
14
+ }
15
+ const entry = catalog.entries[labels.indexOf(picked ?? '')]
16
+ if (!entry) return
17
+ const summary = `${entry.description}\n\nPublisher: ${entry.publisher}\nLicense: ${entry.license}\nSource: ${entry.sourceUrl}\n\n${entry.delivery.kind === 'document' ? 'Saved as data for a compatible application. Nothing is created or enabled.' : 'Installed switched off. Code remains untrusted and approvals remain unchanged.'}`
18
+ if (!await ui.confirm(`Install ${entry.title} ${entry.version}?`, summary)) continue
19
+ try {
20
+ const receipt = await call<HubReceipt>('hub.install', { id: entry.id, version: entry.version })
21
+ ui.notify(receipt.document ? 'Saved in this library for a compatible application.' : 'Installed, switched off. Use /plugins to review and attach it. Changes apply after restarting the session.', 'info')
22
+ } catch (error) { ui.notify(error instanceof Error ? error.message : String(error), 'error') }
23
+ }
24
+ }
@@ -1,26 +1,17 @@
1
- import { spawnSync } from 'node:child_process'
2
1
  import { describe, expect, it } from 'vitest'
3
2
 
4
- import { bundledPiCli, resolvePiCommand, terminalCommandFiles } from './launch'
3
+ import { resolvePiCommand, terminalCommandFiles } from './launch'
5
4
 
6
5
  describe('terminal launch commands', () => {
7
- it('runs the pinned Pi CLI with the active Node runtime on Windows', () => {
8
- const launch = resolvePiCommand({ platform: 'win32', node: 'C:\\Program Files\\nodejs\\node.exe' })
6
+ it.each(['darwin', 'linux', 'win32'] as const)('runs the owned Harness frontend with the active Node runtime on %s', (platform) => {
7
+ const launch = resolvePiCommand({ platform, node: 'C:\\Program Files\\nodejs\\node.exe', cli: '/bundle/cli.js' })
9
8
 
10
9
  expect(launch.program).toBe('C:\\Program Files\\nodejs\\node.exe')
11
- expect(launch.argsPrefix[0]).toMatch(/[\\/]node_modules[\\/]@earendil-works[\\/]pi-coding-agent[\\/]dist[\\/]bundle[\\/]cli\.js$/)
12
- })
13
-
14
- it('runs the package CLI through Node for a no-model help smoke', () => {
15
- const result = spawnSync(process.execPath, [bundledPiCli(), '--help'], { encoding: 'utf8', timeout: 20_000 })
16
-
17
- expect(result.error).toBeUndefined()
18
- expect(result.status).toBe(0)
19
- expect(`${result.stdout}${result.stderr}`).toMatch(/usage|options|pi/i)
10
+ expect(launch.argsPrefix).toEqual(['/bundle/cli.js', 'terminal-frontend'])
20
11
  })
21
12
 
22
13
  it('keeps an explicitly configured executable even on Windows', () => {
23
- expect(resolvePiCommand({ platform: 'win32', node: 'node.exe', programOverride: () => 'C:\\Tools\\pi.exe' }))
14
+ expect(resolvePiCommand({ platform: 'win32', node: 'node.exe', cli: '/bundle/cli.js', programOverride: () => 'C:\\Tools\\pi.exe' }))
24
15
  .toEqual({ program: 'C:\\Tools\\pi.exe', argsPrefix: [] })
25
16
  })
26
17
 
@@ -1,51 +1,14 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { createRequire } from 'node:module'
3
- import { join, resolve } from 'node:path'
4
-
5
1
  import { HarnessError } from '../core/errors'
6
- import { findBinary } from '../core/path'
7
-
8
- const PI_PACKAGE = '@earendil-works/pi-coding-agent'
9
2
 
10
3
  export interface PiCommand {
11
4
  program: string
12
5
  argsPrefix: string[]
13
6
  }
14
7
 
15
- /** Resolve the Pi package's declared command instead of depending on its POSIX npm shim. */
16
- export function bundledPiCli(): string {
17
- const require = createRequire(import.meta.url)
18
- for (const nodeModules of require.resolve.paths(PI_PACKAGE) ?? []) {
19
- const directory = join(nodeModules, ...PI_PACKAGE.split('/'))
20
- const manifestPath = join(directory, 'package.json')
21
- if (!existsSync(manifestPath)) continue
22
- try {
23
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
24
- name?: unknown
25
- bin?: unknown
26
- }
27
- if (manifest.name === PI_PACKAGE) {
28
- const executable = typeof manifest.bin === 'string'
29
- ? manifest.bin
30
- : manifest.bin && typeof manifest.bin === 'object' && typeof (manifest.bin as Record<string, unknown>).pi === 'string'
31
- ? (manifest.bin as Record<string, string>).pi
32
- : null
33
- if (!executable) throw new Error('the package does not declare its pi command')
34
- const cli = resolve(directory, executable)
35
- if (!existsSync(cli)) throw new Error(`the declared command does not exist at ${cli}`)
36
- return cli
37
- }
38
- } catch {
39
- continue
40
- }
41
- }
42
-
43
- throw new HarnessError('harness_unavailable', `The bundled ${PI_PACKAGE} package does not expose a usable pi command. Reinstall the Harness package and try again.`)
44
- }
45
-
46
8
  export function resolvePiCommand(options: {
47
9
  platform: NodeJS.Platform
48
10
  node: string
11
+ cli: string
49
12
  programOverride?: () => string | null
50
13
  }): PiCommand {
51
14
  if (options.programOverride) {
@@ -53,10 +16,7 @@ export function resolvePiCommand(options: {
53
16
  if (!program) throw new HarnessError('harness_unavailable', 'The Pi CLI was not found on PATH.')
54
17
  return { program, argsPrefix: [] }
55
18
  }
56
- if (options.platform === 'win32') return { program: options.node, argsPrefix: [bundledPiCli()] }
57
- const program = findBinary('pi')
58
- if (!program) throw new HarnessError('harness_unavailable', 'The Pi CLI was not found on PATH.')
59
- return { program, argsPrefix: [] }
19
+ return { program: options.node, argsPrefix: [options.cli, 'terminal-frontend'] }
60
20
  }
61
21
 
62
22
  function bashPath(path: string): string {
@@ -0,0 +1,55 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const transport = vi.hoisted(() => ({
5
+ socket: vi.fn(async (_connection: unknown, call: string) => call === 'control.claim' ? { key: 'lexical-key' } : { tools: [] }),
6
+ telemetry: vi.fn(async (event: string) => event === 'poll' ? { command: null } : {})
7
+ }))
8
+ vi.mock('./client', () => ({
9
+ connectionFromEnv: () => ({ socket: '/extension-test', token: 'lifecycle' }),
10
+ socketCall: transport.socket,
11
+ terminalCall: transport.telemetry
12
+ }))
13
+
14
+ import terminalExtension from './piExtension'
15
+
16
+ function extension() {
17
+ const handlers = new Map<string, (...args: unknown[]) => unknown>()
18
+ const commands = new Map<string, unknown>()
19
+ const api = {
20
+ on: (event: string, handler: (...args: unknown[]) => unknown) => handlers.set(event, handler),
21
+ registerCommand: (name: string, command: unknown) => commands.set(name, command),
22
+ registerTool: vi.fn()
23
+ } as unknown as ExtensionAPI
24
+ const ctx = { sessionManager: { getSessionFile: () => '/history/session.jsonl' }, ui: { getEditorText: () => '' }, abort: vi.fn() } as unknown as ExtensionContext
25
+ return { api, ctx, handlers, commands }
26
+ }
27
+
28
+ afterEach(() => vi.useRealTimers())
29
+
30
+ describe('terminal bridge lifecycle', () => {
31
+ it('keeps one private control claim while replacing native session pollers and restoring readiness', async () => {
32
+ vi.useFakeTimers()
33
+ const first = extension()
34
+ await terminalExtension(first.api)
35
+ await first.handlers.get('session_start')!({ type: 'session_start', reason: 'startup' }, first.ctx)
36
+ await vi.advanceTimersByTimeAsync(100)
37
+ const polls = transport.telemetry.mock.calls.filter(([event]) => event === 'poll').length
38
+ expect(polls).toBe(1)
39
+ await first.handlers.get('session_shutdown')!({ type: 'session_shutdown', reason: 'resume' }, first.ctx)
40
+ await vi.advanceTimersByTimeAsync(300)
41
+ expect(transport.telemetry.mock.calls.filter(([event]) => event === 'poll')).toHaveLength(polls)
42
+ expect(transport.telemetry.mock.calls.filter(([event]) => event === 'shutdown')).toHaveLength(1)
43
+ const next = extension()
44
+ await terminalExtension(next.api)
45
+ await next.handlers.get('session_start')!({ type: 'session_start', reason: 'resume' }, next.ctx)
46
+ await vi.advanceTimersByTimeAsync(100)
47
+ expect(transport.socket.mock.calls.filter(([, call]) => call === 'control.claim')).toHaveLength(1)
48
+ expect(transport.telemetry.mock.calls.filter(([event]) => event === 'ready')).toHaveLength(2)
49
+ expect(transport.telemetry.mock.calls.filter(([event]) => event === 'poll')).toHaveLength(polls + 1)
50
+ expect([...next.commands.keys()]).toEqual(['hub', 'plugins', 'accounts'])
51
+ await next.handlers.get('session_shutdown')!({ type: 'session_shutdown', reason: 'quit' }, next.ctx)
52
+ expect(transport.telemetry.mock.calls.filter(([event]) => event === 'shutdown')).toHaveLength(2)
53
+ expect(vi.getTimerCount()).toBe(0)
54
+ })
55
+ })
@@ -6,7 +6,13 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
6
6
  import { Type } from 'typebox'
7
7
 
8
8
  import { connectionFromEnv, socketCall, terminalCall } from './client'
9
+ import { runAccountsMenu, type SessionAccount } from './accountsMenu'
9
10
  import { runPluginsMenu } from './pluginMenu'
11
+ import { runHubMenu } from './hubMenu'
12
+ import { claimTerminalControl, controlCall } from './control'
13
+ import { SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentReceipt } from '../sessions/subagents'
14
+ import { askUserTool } from '../tools/askUser'
15
+ import type { QuestionAnswer } from '../sessions/registry'
10
16
 
11
17
  interface SocketTool {
12
18
  name: string
@@ -22,9 +28,18 @@ function resultSize(result: unknown): number | null {
22
28
  return content.reduce((total: number, part: { type?: string; text?: unknown }) => total + (part?.type === 'text' && typeof part.text === 'string' ? part.text.length : 0), 0)
23
29
  }
24
30
 
31
+ async function withParentCancellation<T>(signal: AbortSignal | undefined, work: () => Promise<T>): Promise<T> {
32
+ const stop = () => { void controlCall('terminal.interrupt').catch(() => {}) }
33
+ if (signal?.aborted) stop()
34
+ signal?.addEventListener('abort', stop, { once: true })
35
+ try { return await work() } finally { signal?.removeEventListener('abort', stop) }
36
+ }
37
+
25
38
  export default async function (pi: ExtensionAPI) {
26
39
  await terminalCall('startup', { phase: 'Pi extension loaded' })
27
40
  let context: ExtensionContext | null = null
41
+ let nativeSubagents = false
42
+ let subagentParent = false
28
43
  let timer: ReturnType<typeof setInterval> | null = null
29
44
  let polling = false
30
45
  let failure: string | undefined
@@ -43,7 +58,17 @@ export default async function (pi: ExtensionAPI) {
43
58
  const connection = connectionFromEnv()
44
59
  // Claimed here, before the model has run anything, and kept in this process's memory: a
45
60
  // command the model runs can read the session token from its environment, never this key.
46
- const control = await socketCall<{ key: string }>(connection, 'control.claim', {}, 15_000).then((claimed) => claimed.key).catch(() => null)
61
+ const control = await claimTerminalControl(connection).catch(() => null)
62
+ pi.registerCommand('hub', {
63
+ description: 'Browse and install reviewed skills, plugins, and reusable documents',
64
+ handler: async (_args, ctx) => {
65
+ if (!control) return ctx.ui.notify('This session cannot install hub items.', 'warning')
66
+ await runHubMenu(
67
+ { select: (title, options) => ctx.ui.select(title, options), confirm: (title, message) => ctx.ui.confirm(title, message), notify: (message, level) => ctx.ui.notify(message, level) },
68
+ (method, params) => socketCall(connection, method, params ?? {}, 300_000, control)
69
+ )
70
+ }
71
+ })
47
72
  pi.registerCommand('plugins', {
48
73
  description: 'See what this session loads, and switch a plugin on or off or trust its code',
49
74
  handler: async (_args, ctx) => {
@@ -56,6 +81,26 @@ export default async function (pi: ExtensionAPI) {
56
81
  )
57
82
  }
58
83
  })
84
+ pi.registerCommand('accounts', {
85
+ description: 'Add, sign in to, check, switch, or remove the Pi accounts this harness uses',
86
+ handler: async (_args, ctx) => {
87
+ if (!control) return ctx.ui.notify('This session cannot manage accounts.', 'warning')
88
+ const session = await socketCall<SessionAccount>(connection, 'accounts.session', {}, 15_000, control).catch(() => null)
89
+ if (!session) return ctx.ui.notify('This session has no account to manage.', 'warning')
90
+ const outcome = await runAccountsMenu(
91
+ {
92
+ select: (title, options) => ctx.ui.select(title, options),
93
+ confirm: (title, message) => ctx.ui.confirm(title, message),
94
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
95
+ notify: (message, level) => ctx.ui.notify(message, level)
96
+ },
97
+ (method, params) => socketCall(connection, method, params ?? {}, 120_000, control),
98
+ session
99
+ )
100
+ // The session restarts on the account it chose: Pi ends here, and the launcher starts it again.
101
+ if (outcome === 'relaunch') ctx.shutdown()
102
+ }
103
+ })
59
104
  const { tools } = await socketCall<{ tools: SocketTool[] }>(connection, 'tools.list', {}, 15_000)
60
105
  stage = 'Tool registration'
61
106
  for (const tool of tools) {
@@ -70,6 +115,15 @@ export default async function (pi: ExtensionAPI) {
70
115
  }
71
116
  })
72
117
  }
118
+ const native = await controlCall<{ tools: SocketTool[] }>('terminal.subagents.tools')
119
+ nativeSubagents = native.tools.length > 0
120
+ for (const tool of native.tools) pi.registerTool({
121
+ name: tool.name, label: tool.label, description: tool.description, parameters: Type.Unsafe(tool.parameters),
122
+ execute: async (_id, input, signal) => {
123
+ const result = await withParentCancellation(signal, () => controlCall<{ text: string; details?: Record<string, unknown> }>('terminal.subagents.call', { name: tool.name, input }, 2_147_483_647))
124
+ return { content: [{ type: 'text' as const, text: result.text }], details: result.details ?? {} }
125
+ }
126
+ })
73
127
  } catch (error) {
74
128
  await terminalCall('startupFailed', { reason: `${stage} failed: ${error instanceof Error ? error.message : 'Unavailable'}` }).catch(() => {})
75
129
  throw error
@@ -77,6 +131,7 @@ export default async function (pi: ExtensionAPI) {
77
131
 
78
132
  pi.on('session_start', async (_event, ctx) => {
79
133
  context = ctx
134
+ if (timer) clearInterval(timer)
80
135
  await terminalCall('ready', { nativeSessionId: ctx.sessionManager.getSessionFile() })
81
136
  timer = setInterval(() => {
82
137
  if (polling || !context) return
@@ -106,8 +161,21 @@ export default async function (pi: ExtensionAPI) {
106
161
  reasoningTokens = undefined
107
162
  costReported = false
108
163
  // A run a plugin starts with a custom message has no user prompt to record; it still begins a turn.
109
- await terminalCall('begin', { text: event.messageType === 'user' ? event.prompt : '' })
164
+ const begun = await terminalCall<{ runId: string }>('begin', { text: event.messageType === 'user' ? event.prompt : '' })
165
+ if (nativeSubagents) {
166
+ pi.appendEntry(SUBAGENT_OWNER, { runId: begun.runId })
167
+ await controlCall('terminal.subagents.register', { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) })
168
+ subagentParent = true
169
+ }
110
170
  })
171
+ const deliverChildren = async (event: any, ctx: ExtensionContext, settle: boolean) => {
172
+ if (!nativeSubagents || (settle && event.outcome !== 'completed') || ctx.signal?.aborted) return
173
+ const result = await withParentCancellation(ctx.signal, () => controlCall<{ text: string; receipt: SubagentReceipt } | null>('terminal.subagents.delivery', { history: subagentHistory([...ctx.sessionManager.getBranch(), ...event.entries]), settle }, 2_147_483_647))
174
+ if (!result) return
175
+ return { entries: [...event.entries, { type: 'custom_message' as const, customType: SUBAGENT_RESULTS, content: result.text, display: false, details: { [SUBAGENT_RECEIPT]: result.receipt } }], continue: true }
176
+ }
177
+ pi.on('turn_end', (event, ctx) => deliverChildren(event, ctx, false))
178
+ pi.on('agent_before_settle', (event, ctx) => deliverChildren(event, ctx, true))
111
179
  pi.on('message_end', async (event) => {
112
180
  if (event.message.role !== 'assistant') return
113
181
  if (event.message.stopReason === 'error' || event.message.stopReason === 'aborted') failure = event.message.errorMessage ?? `Pi stopped: ${event.message.stopReason}`
@@ -157,9 +225,9 @@ export default async function (pi: ExtensionAPI) {
157
225
  })
158
226
  pi.on('tool_call', async (event) => {
159
227
  try {
160
- const policy = await terminalCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('policy', { tool: event.toolName, args: event.input })
228
+ const policy = await controlCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('terminal.policy', { tool: event.toolName, args: event.input })
161
229
  if (policy.requiresApproval) {
162
- const approval = await terminalCall<{ decision: string }>('approval', { requestId: event.toolCallId, target: event.toolName, action: JSON.stringify(event.input), tool: event.toolName, args: event.input })
230
+ const approval = await controlCall<{ decision: string }>('terminal.approval', { requestId: event.toolCallId, tool: event.toolName, args: event.input })
163
231
  if (approval.decision === 'approved') return
164
232
  return { block: true, reason: 'The user denied this action. Ask how they would like to proceed.' }
165
233
  }
@@ -182,16 +250,33 @@ export default async function (pi: ExtensionAPI) {
182
250
  fileTools.delete(event.toolCallId)
183
251
  if (!event.isError && path) await terminalCall('file', { path, change: 'modified' })
184
252
  })
185
- pi.on('agent_settled', async () => {
186
- await terminalCall('settled', { error: failure })
253
+ pi.on('agent_settled', async (event, ctx) => {
254
+ if (subagentParent && !event.aborted && !failure) {
255
+ try { await controlCall('terminal.subagents.verify', { history: subagentHistory(ctx.sessionManager.getBranch()) }, 2_147_483_647) } catch (error) { failure = error instanceof Error ? error.message : String(error) }
256
+ }
257
+ subagentParent = false
258
+ await terminalCall('settled', { error: failure, aborted: event.aborted })
187
259
  })
188
260
  pi.on('user_bash', async () => {
189
261
  throw new Error('Use the bash tool so this action can be checked.')
190
262
  })
191
263
  pi.on('session_shutdown', async () => {
192
264
  if (timer) clearInterval(timer)
265
+ timer = null
266
+ context = null
267
+ // This clears the controller's readiness while the next native session binds. The
268
+ // process-level control claim survives replacement; only the session poller stops.
193
269
  await terminalCall('shutdown').catch(() => {})
194
270
  })
271
+ const ask = askUserTool({ requestAnswers: questions => terminalCall<QuestionAnswer[]>('questions', { questions }, 1_230_000) })
272
+ pi.registerTool({
273
+ name: ask.name, label: ask.label, description: ask.description,
274
+ parameters: Type.Unsafe(ask.parameters(Type) as Record<string, unknown>),
275
+ async execute(_id, input) {
276
+ const result = await ask.execute(input as Record<string, unknown>)
277
+ return { content: [{ type: 'text', text: typeof result === 'string' ? result : result.text }], details: undefined }
278
+ }
279
+ })
195
280
  pi.registerTool({
196
281
  name: 'ask_user_question',
197
282
  label: 'Ask a question',
@@ -33,6 +33,29 @@ describe('session socket path', () => {
33
33
  })
34
34
 
35
35
  describe('session socket', () => {
36
+ it.skipIf(process.platform === 'win32')('keeps an existing Harness socket reachable when another instance uses the same state folder', async () => {
37
+ const base = mkdtempSync(join(tmpdir(), 'sock-shared-state-'))
38
+ const path = socketPath(base)
39
+ const first = new SessionSocket(path, handlers)
40
+ const second = new SessionSocket(path, handlers)
41
+ const canConnect = (socketPath: string): Promise<void> => new Promise((resolve, reject) => {
42
+ const client = createConnection(socketPath)
43
+ client.once('connect', () => { client.end(); resolve() })
44
+ client.once('error', reject)
45
+ })
46
+ try {
47
+ await first.listen()
48
+ await second.listen()
49
+ expect(second.path).not.toBe(first.path)
50
+ await second.close()
51
+ await expect(canConnect(first.path)).resolves.toBeUndefined()
52
+ } finally {
53
+ await first.close()
54
+ await second.close()
55
+ rmSync(base, { recursive: true, force: true })
56
+ }
57
+ })
58
+
36
59
  it.skipIf(process.platform !== 'win32')('listens on a named pipe and answers a request on Windows', async () => {
37
60
  const socket = new SessionSocket(socketPath('unused'), handlers)
38
61
  await socket.listen()
@@ -23,7 +23,7 @@ export interface SocketGrant {
23
23
  /** The session's connected-apps grant, when it has one. */
24
24
  broker: BrokerSession | null
25
25
  /** Telemetry from the native process, and what to do when it hangs up mid-request. */
26
- terminal?: { accept(event: string, data: Record<string, unknown>): Promise<unknown>; disconnected(): void }
26
+ terminal?: { accept(event: string, data: Record<string, unknown>): Promise<unknown>; disconnected(): void; control?(call: string, data: Record<string, unknown>): unknown }
27
27
  }
28
28
 
29
29
  export interface SocketHandlers {
@@ -36,6 +36,8 @@ export interface SocketHandlers {
36
36
  * session's shell never sees; anything that changes what loads needs it.
37
37
  */
38
38
  plugins?(grant: SocketGrant, call: string, params: Record<string, unknown>, control: boolean): Promise<unknown>
39
+ /** `accounts.*`. Every call needs `control`: adding, signing in, or removing an account is a person's choice. */
40
+ accounts?(grant: SocketGrant, call: string, params: Record<string, unknown>, control: boolean): Promise<unknown>
39
41
  }
40
42
 
41
43
  const MAX_REQUEST_BYTES = 1024 * 1024
@@ -54,6 +56,11 @@ export function socketPath(stateDir: string): string {
54
56
  return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
55
57
  }
56
58
 
59
+ function alternateSocketPath(path: string): string {
60
+ const candidate = join(dirname(path), `harness-${process.pid}-${randomBytes(8).toString('hex')}.sock`)
61
+ return Buffer.byteLength(candidate) <= MAX_SOCKET_PATH ? candidate : join(tmpdir(), `xh-${randomBytes(16).toString('hex')}.sock`)
62
+ }
63
+
57
64
  /** A named pipe has no folder, file mode, or owner to check or clean up. */
58
65
  const isPipePath = (path: string): boolean => path.startsWith('\\\\.\\pipe\\')
59
66
 
@@ -86,6 +93,7 @@ function refusedBind(path: string, error: unknown): unknown {
86
93
  export class SessionSocket {
87
94
  private server: Server | null = null
88
95
  private listening: Promise<void> | null = null
96
+ private socketPathValue: string
89
97
  private readonly grants = new Map<string, SocketGrant>()
90
98
  /**
91
99
  * A second key per token, handed out once. The token is in the session's environment, where a
@@ -95,10 +103,11 @@ export class SessionSocket {
95
103
  private readonly controls = new Map<string, string>()
96
104
  private readonly clients = new Set<Socket>()
97
105
 
98
- constructor(
99
- readonly path: string,
100
- private readonly handlers: SocketHandlers
101
- ) {}
106
+ constructor(path: string, private readonly handlers: SocketHandlers) {
107
+ this.socketPathValue = path
108
+ }
109
+
110
+ get path(): string { return this.socketPathValue }
102
111
 
103
112
  issue(grant: SocketGrant): string {
104
113
  const token = `${newId()}${newId()}`.replaceAll('-', '')
@@ -139,6 +148,7 @@ export class SessionSocket {
139
148
  const call = typeof request.call === 'string' ? request.call : ''
140
149
  const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>
141
150
  if (['wiki.', 'memory.', 'recall.'].some((prefix) => call.startsWith(prefix))) return { result: this.handlers.memory(grant.caller(), call, params) }
151
+ if (call === 'tools.list' && !grant.broker) return { result: { tools: [] } }
142
152
  if (['mcp.', 'tools.'].some((prefix) => call.startsWith(prefix))) {
143
153
  if (!grant.broker) throw new HarnessError('mcp_unauthorized', 'Connected apps are not available to this session.')
144
154
  return { result: await this.handlers.apps(grant.broker, call, params) }
@@ -149,10 +159,19 @@ export class SessionSocket {
149
159
  this.controls.set(token, key)
150
160
  return { result: { key } }
151
161
  }
152
- if (call.startsWith('plugins.') && this.handlers.plugins) {
162
+ if (call.startsWith('terminal.') && grant.terminal?.control) {
163
+ const key = this.controls.get(token)
164
+ if (!key || request.control !== key) throw new HarnessError('unauthorized', 'Only a person at this terminal can answer decisions or change its draft state.')
165
+ return { result: await grant.terminal.control(call, params) }
166
+ }
167
+ if ((call.startsWith('plugins.') || call.startsWith('hub.')) && this.handlers.plugins) {
153
168
  const key = this.controls.get(token)
154
169
  return { result: await this.handlers.plugins(grant, call, params, key !== undefined && request.control === key) }
155
170
  }
171
+ if (call.startsWith('accounts.') && this.handlers.accounts) {
172
+ const key = this.controls.get(token)
173
+ return { result: await this.handlers.accounts(grant, call, params, key !== undefined && request.control === key) }
174
+ }
156
175
  throw new HarnessError('unknown_method', `Unknown call ${call}.`)
157
176
  } catch (error) {
158
177
  return { error: toErrorShape(error) }
@@ -182,22 +201,14 @@ export class SessionSocket {
182
201
  })
183
202
  }
184
203
 
185
- /** Binds the socket once; later calls wait for the same bind. */
186
- listen(): Promise<void> {
187
- this.listening ??= new Promise<void>((resolve, reject) => {
188
- if (!isPipePath(this.path)) {
189
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
190
- rmSync(this.path, { force: true })
191
- }
204
+ private bind(path: string): Promise<void> {
205
+ return new Promise<void>((resolve, reject) => {
192
206
  const server = createServer((client) => this.serve(client))
193
- server.once('error', (error) => {
194
- this.listening = null
195
- reject(refusedBind(this.path, error))
196
- })
197
- server.listen(this.path, () => {
198
- if (!isPipePath(this.path)) {
207
+ server.once('error', reject)
208
+ server.listen(path, () => {
209
+ if (!isPipePath(path)) {
199
210
  try {
200
- chmodSync(this.path, 0o600)
211
+ chmodSync(path, 0o600)
201
212
  } catch (error) {
202
213
  log('session socket permissions', error instanceof Error ? error.message : String(error))
203
214
  }
@@ -206,6 +217,30 @@ export class SessionSocket {
206
217
  resolve()
207
218
  })
208
219
  })
220
+ }
221
+
222
+ /** Binds the usual path, or an instance-specific path if another Harness owns it. */
223
+ private async bindAvailablePath(): Promise<void> {
224
+ if (!isPipePath(this.path)) mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
225
+ try {
226
+ await this.bind(this.path)
227
+ } catch (error) {
228
+ if (isPipePath(this.path) || (error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw refusedBind(this.path, error)
229
+ this.socketPathValue = alternateSocketPath(this.path)
230
+ try {
231
+ await this.bind(this.path)
232
+ } catch (alternateError) {
233
+ throw refusedBind(this.path, alternateError)
234
+ }
235
+ }
236
+ }
237
+
238
+ /** Binds the socket once; later calls wait for the same bind. */
239
+ listen(): Promise<void> {
240
+ this.listening ??= this.bindAvailablePath().catch(error => {
241
+ this.listening = null
242
+ throw error
243
+ })
209
244
  return this.listening
210
245
  }
211
246
 
@@ -11,6 +11,7 @@ import { createHarness, type Harness, type HarnessOptions } from '../harness'
11
11
  import { defaultAssets } from './terminals'
12
12
  import { parseStandaloneOptions, runStandaloneTerminal, standaloneArguments } from './standalone'
13
13
  import { socketPath } from './socket'
14
+ import { SOCKET_ENV, TOKEN_ENV, socketCall } from './client'
14
15
 
15
16
  const scratch: string[] = []
16
17
  const harnesses: Harness[] = []
@@ -128,3 +129,39 @@ describe('standalone terminal startup', () => {
128
129
  .rejects.toThrow(/Could not start Pi terminal.*Pi executable.*\/opt\/pi\/bin\/pi/)
129
130
  })
130
131
  })
132
+
133
+ describe('switching the account of a running terminal', () => {
134
+ it('ends Pi when the person asks for another account, and starts it again on that account', async () => {
135
+ const home = temporaryDirectory('harness-switch-')
136
+ const cwd = temporaryDirectory('harness-switch-cwd-')
137
+ const paths = parseStandaloneOptions([], home)
138
+ const spawned: { args: string[]; env: NodeJS.ProcessEnv }[] = []
139
+ let spareId = ''
140
+ const spawn = ((_program: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: string }) => {
141
+ spawned.push({ args, env: options.env ?? {} })
142
+ const child = new EventEmitter() as ChildProcess
143
+ child.kill = vi.fn(() => true)
144
+ void (async () => {
145
+ // Deferred, as a real process is: the launcher attaches its listeners after spawn returns.
146
+ await Promise.resolve()
147
+ if (spawned.length === 1) {
148
+ // The first Pi asks for the other account through its own extension, then ends.
149
+ spareId = harnesses[0].accounts.create('Spare', 'apiKey').accounts.find((account) => account.label === 'Spare')!.id
150
+ const connection = { socket: options.env![SOCKET_ENV]!, token: options.env![TOKEN_ENV]! }
151
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
152
+ await socketCall(connection, 'accounts.relaunch', { accountId: spareId }, 5_000, key)
153
+ }
154
+ child.emit('exit', 0, null)
155
+ })()
156
+ return child
157
+ }) as unknown as typeof nodeSpawn
158
+
159
+ expect(await runStandaloneTerminal([], { home, cwd, stdinIsTTY: true, stdoutIsTTY: true, createHarness: harnessFactory, spawn })).toBe(0)
160
+
161
+ expect(spawned).toHaveLength(2)
162
+ expect(spawned[1].env.PI_CODING_AGENT_DIR).toContain(join(paths.stateDir, 'accounts', spareId))
163
+ expect(spawned[1].env[TOKEN_ENV]).not.toBe(spawned[0].env[TOKEN_ENV])
164
+ // The session it ended is closed; nothing is left running in the harness.
165
+ expect(harnesses[0].terminals.has('standalone')).toBe(false)
166
+ })
167
+ })
@@ -1,10 +1,11 @@
1
- // The standalone entry point for people using the harness directly. Pi owns the interface;
2
- // the harness prepares the isolated account, context, extension, and session socket.
1
+ // The standalone host prepares an isolated Harness terminal and supervises its frontend.
3
2
  import { spawn as nodeSpawn } from 'node:child_process'
4
3
  import { createHash, randomUUID } from 'node:crypto'
4
+ import { appendFileSync } from 'node:fs'
5
5
  import { homedir } from 'node:os'
6
6
  import { isAbsolute, join, resolve } from 'node:path'
7
7
 
8
+ import { setLogSink } from '../core/bus'
8
9
  import { createHarness, type Harness, type HarnessOptions } from '../harness'
9
10
  import type { TerminalLaunch } from './terminals'
10
11
 
@@ -77,7 +78,7 @@ function spawnError(error: Error, launch: TerminalLaunch): Error {
77
78
  const code = (error as NodeJS.ErrnoException).code
78
79
  if (code !== 'ENOENT' && code !== 'EACCES' && code !== 'EPERM') return error
79
80
  if (launch.program === process.execPath || launch.env.XINGULAR_HARNESS_NODE === launch.program) {
80
- return new Error(`Could not start the Pi terminal because Node could not run "${launch.args[0] ?? 'the bundled Pi CLI'}" (${code}). Verify Node and the bundled Pi package are installed.`, { cause: error })
81
+ return new Error(`Could not start the Harness terminal because Node could not run "${launch.args[0] ?? 'the bundled Harness CLI'}" (${code}). Verify Node and the Harness package are installed.`, { cause: error })
81
82
  }
82
83
  const problem = code === 'ENOENT' ? 'was not found' : `could not start (${code})`
83
84
  return new Error(`Could not start Pi terminal. Pi executable "${launch.program}" ${problem}; verify it is installed and executable or check the configured terminal executable.`, { cause: error })
@@ -127,7 +128,17 @@ export async function runStandaloneTerminal(argv: string[], dependencies: Standa
127
128
  const paths = parseStandaloneOptions(argv, dependencies.home, cwd)
128
129
  const makeHarness = dependencies.createHarness ?? createHarness
129
130
  const harness = makeHarness({ dataRoot: paths.dataRoot, stateDir: paths.stateDir, productName: 'Xingular Harness' })
130
- const sessionId = (dependencies.createSessionId ?? randomUUID)()
131
+ const newSessionId = dependencies.createSessionId ?? randomUUID
132
+ let current = { sessionId: newSessionId(), accountId: null as string | null, nativeSessionId: null as string | null }
133
+ // The frontend draws on this terminal, so the host's diagnostics go to a file the person can read instead.
134
+ const logFile = join(paths.stateDir, 'harness.log')
135
+ setLogSink((text) => {
136
+ try {
137
+ appendFileSync(logFile, `${new Date().toISOString()} ${text}\n`)
138
+ } catch {
139
+ // Diagnostics are best effort; a full disk must not stop the terminal.
140
+ }
141
+ })
131
142
 
132
143
  try {
133
144
  await harness.ready
@@ -135,10 +146,20 @@ export async function runStandaloneTerminal(argv: string[], dependencies: Standa
135
146
 
136
147
  const cwdHash = createHash('sha256').update(cwd).digest('hex')
137
148
  const sessionFolder = join(paths.dataRoot, 'sessions', 'terminal', cwdHash)
138
- const launch = await harness.terminals.prepare({ sessionId, cwd, sessionFolder })
139
- return await spawnPi(launch, cwd, dependencies.spawn)
149
+ for (;;) {
150
+ const launch = await harness.terminals.prepare({ ...current, cwd, sessionFolder })
151
+ const code = await spawnPi(launch, cwd, dependencies.spawn)
152
+ // `/accounts` asks for a switch and ends Pi. The conversation resumes on the chosen account.
153
+ const next = harness.terminals.takeRelaunch(current.sessionId)
154
+ if (!next) return code
155
+ const label = harness.accounts.find(next.accountId).label
156
+ process.stdout.write(`Switching this session to account ${label}.\n`)
157
+ await harness.terminals.dispose(current.sessionId)
158
+ current = { sessionId: newSessionId(), accountId: next.accountId, nativeSessionId: next.nativeSessionId }
159
+ }
140
160
  } finally {
141
- await harness.terminals.dispose(sessionId)
161
+ await harness.terminals.dispose(current.sessionId)
142
162
  await harness.close()
163
+ setLogSink(null)
143
164
  }
144
165
  }