@xingular/harness 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -0
@@ -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,12 @@ 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 } from './control'
13
+ import { askUserTool } from '../tools/askUser'
14
+ import type { QuestionAnswer } from '../sessions/registry'
10
15
 
11
16
  interface SocketTool {
12
17
  name: string
@@ -43,7 +48,17 @@ export default async function (pi: ExtensionAPI) {
43
48
  const connection = connectionFromEnv()
44
49
  // Claimed here, before the model has run anything, and kept in this process's memory: a
45
50
  // 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)
51
+ const control = await claimTerminalControl(connection).catch(() => null)
52
+ pi.registerCommand('hub', {
53
+ description: 'Browse and install reviewed skills, plugins, and reusable documents',
54
+ handler: async (_args, ctx) => {
55
+ if (!control) return ctx.ui.notify('This session cannot install hub items.', 'warning')
56
+ await runHubMenu(
57
+ { select: (title, options) => ctx.ui.select(title, options), confirm: (title, message) => ctx.ui.confirm(title, message), notify: (message, level) => ctx.ui.notify(message, level) },
58
+ (method, params) => socketCall(connection, method, params ?? {}, 300_000, control)
59
+ )
60
+ }
61
+ })
47
62
  pi.registerCommand('plugins', {
48
63
  description: 'See what this session loads, and switch a plugin on or off or trust its code',
49
64
  handler: async (_args, ctx) => {
@@ -56,6 +71,26 @@ export default async function (pi: ExtensionAPI) {
56
71
  )
57
72
  }
58
73
  })
74
+ pi.registerCommand('accounts', {
75
+ description: 'Add, sign in to, check, switch, or remove the Pi accounts this harness uses',
76
+ handler: async (_args, ctx) => {
77
+ if (!control) return ctx.ui.notify('This session cannot manage accounts.', 'warning')
78
+ const session = await socketCall<SessionAccount>(connection, 'accounts.session', {}, 15_000, control).catch(() => null)
79
+ if (!session) return ctx.ui.notify('This session has no account to manage.', 'warning')
80
+ const outcome = await runAccountsMenu(
81
+ {
82
+ select: (title, options) => ctx.ui.select(title, options),
83
+ confirm: (title, message) => ctx.ui.confirm(title, message),
84
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
85
+ notify: (message, level) => ctx.ui.notify(message, level)
86
+ },
87
+ (method, params) => socketCall(connection, method, params ?? {}, 120_000, control),
88
+ session
89
+ )
90
+ // The session restarts on the account it chose: Pi ends here, and the launcher starts it again.
91
+ if (outcome === 'relaunch') ctx.shutdown()
92
+ }
93
+ })
59
94
  const { tools } = await socketCall<{ tools: SocketTool[] }>(connection, 'tools.list', {}, 15_000)
60
95
  stage = 'Tool registration'
61
96
  for (const tool of tools) {
@@ -77,6 +112,7 @@ export default async function (pi: ExtensionAPI) {
77
112
 
78
113
  pi.on('session_start', async (_event, ctx) => {
79
114
  context = ctx
115
+ if (timer) clearInterval(timer)
80
116
  await terminalCall('ready', { nativeSessionId: ctx.sessionManager.getSessionFile() })
81
117
  timer = setInterval(() => {
82
118
  if (polling || !context) return
@@ -190,8 +226,21 @@ export default async function (pi: ExtensionAPI) {
190
226
  })
191
227
  pi.on('session_shutdown', async () => {
192
228
  if (timer) clearInterval(timer)
229
+ timer = null
230
+ context = null
231
+ // This clears the controller's readiness while the next native session binds. The
232
+ // process-level control claim survives replacement; only the session poller stops.
193
233
  await terminalCall('shutdown').catch(() => {})
194
234
  })
235
+ const ask = askUserTool({ requestAnswers: questions => terminalCall<QuestionAnswer[]>('questions', { questions }, 1_230_000) })
236
+ pi.registerTool({
237
+ name: ask.name, label: ask.label, description: ask.description,
238
+ parameters: Type.Unsafe(ask.parameters(Type) as Record<string, unknown>),
239
+ async execute(_id, input) {
240
+ const result = await ask.execute(input as Record<string, unknown>)
241
+ return { content: [{ type: 'text', text: typeof result === 'string' ? result : result.text }], details: undefined }
242
+ }
243
+ })
195
244
  pi.registerTool({
196
245
  name: 'ask_user_question',
197
246
  label: 'Ask a question',
@@ -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
@@ -149,10 +151,19 @@ export class SessionSocket {
149
151
  this.controls.set(token, key)
150
152
  return { result: { key } }
151
153
  }
152
- if (call.startsWith('plugins.') && this.handlers.plugins) {
154
+ if (call.startsWith('terminal.') && grant.terminal?.control) {
155
+ const key = this.controls.get(token)
156
+ if (!key || request.control !== key) throw new HarnessError('unauthorized', 'Only a person at this terminal can answer decisions or change its draft state.')
157
+ return { result: await grant.terminal.control(call, params) }
158
+ }
159
+ if ((call.startsWith('plugins.') || call.startsWith('hub.')) && this.handlers.plugins) {
153
160
  const key = this.controls.get(token)
154
161
  return { result: await this.handlers.plugins(grant, call, params, key !== undefined && request.control === key) }
155
162
  }
163
+ if (call.startsWith('accounts.') && this.handlers.accounts) {
164
+ const key = this.controls.get(token)
165
+ return { result: await this.handlers.accounts(grant, call, params, key !== undefined && request.control === key) }
166
+ }
156
167
  throw new HarnessError('unknown_method', `Unknown call ${call}.`)
157
168
  } catch (error) {
158
169
  return { error: toErrorShape(error) }
@@ -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,5 +1,4 @@
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'
5
4
  import { homedir } from 'node:os'
@@ -77,7 +76,7 @@ function spawnError(error: Error, launch: TerminalLaunch): Error {
77
76
  const code = (error as NodeJS.ErrnoException).code
78
77
  if (code !== 'ENOENT' && code !== 'EACCES' && code !== 'EPERM') return error
79
78
  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 })
79
+ 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
80
  }
82
81
  const problem = code === 'ENOENT' ? 'was not found' : `could not start (${code})`
83
82
  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 +126,8 @@ export async function runStandaloneTerminal(argv: string[], dependencies: Standa
127
126
  const paths = parseStandaloneOptions(argv, dependencies.home, cwd)
128
127
  const makeHarness = dependencies.createHarness ?? createHarness
129
128
  const harness = makeHarness({ dataRoot: paths.dataRoot, stateDir: paths.stateDir, productName: 'Xingular Harness' })
130
- const sessionId = (dependencies.createSessionId ?? randomUUID)()
129
+ const newSessionId = dependencies.createSessionId ?? randomUUID
130
+ let current = { sessionId: newSessionId(), accountId: null as string | null, nativeSessionId: null as string | null }
131
131
 
132
132
  try {
133
133
  await harness.ready
@@ -135,10 +135,19 @@ export async function runStandaloneTerminal(argv: string[], dependencies: Standa
135
135
 
136
136
  const cwdHash = createHash('sha256').update(cwd).digest('hex')
137
137
  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)
138
+ for (;;) {
139
+ const launch = await harness.terminals.prepare({ ...current, cwd, sessionFolder })
140
+ const code = await spawnPi(launch, cwd, dependencies.spawn)
141
+ // `/accounts` asks for a switch and ends Pi. The conversation resumes on the chosen account.
142
+ const next = harness.terminals.takeRelaunch(current.sessionId)
143
+ if (!next) return code
144
+ const label = harness.accounts.find(next.accountId).label
145
+ process.stdout.write(`Switching this session to account ${label}.\n`)
146
+ await harness.terminals.dispose(current.sessionId)
147
+ current = { sessionId: newSessionId(), accountId: next.accountId, nativeSessionId: next.nativeSessionId }
148
+ }
140
149
  } finally {
141
- await harness.terminals.dispose(sessionId)
150
+ await harness.terminals.dispose(current.sessionId)
142
151
  await harness.close()
143
152
  }
144
153
  }
@@ -282,7 +282,8 @@ describe('a pi terminal session', () => {
282
282
  const listed = await socketCall<{ tools: { name: string; parameters: { type: string } }[] }>(connection, 'tools.list')
283
283
  expect(listed.tools.map((tool) => tool.name)).toEqual(['mcp_list_tools', 'mcp_call_tool'])
284
284
  expect(listed.tools[0].parameters.type).toBe('object')
285
- await expect(socketCall(connection, 'accounts.list')).rejects.toMatchObject({ code: 'unknown_method' })
285
+ // Account calls exist, but only the session's extension holds the key that unlocks them.
286
+ await expect(socketCall(connection, 'accounts.list')).rejects.toMatchObject({ code: 'unauthorized' })
286
287
  await expect(socketCall({ ...connection, token: 'guess' }, 'wiki.list')).rejects.toMatchObject({ code: 'unauthorized' })
287
288
  await harness.terminals.dispose('term-1')
288
289
  await expect(socketCall(connection, 'wiki.list')).rejects.toMatchObject({ code: 'unauthorized' })
@@ -328,3 +329,71 @@ describe('xagent-wiki command', () => {
328
329
  expect(parseCli(['nonsense'])).toEqual({ kind: 'help' })
329
330
  })
330
331
  })
332
+
333
+
334
+ describe('decisions in the Harness-owned frontend', () => {
335
+ it('requires the private input-owner key for pending decisions and exact draft state', async () => {
336
+ const { connection, harness, tell } = await setup()
337
+ await tell('ready', { nativeSessionId: '/native.jsonl' })
338
+ await expect(socketCall(connection, 'terminal.pending')).rejects.toMatchObject({ code: 'unauthorized' })
339
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
340
+ await socketCall(connection, 'terminal.draft', { dirty: true }, 1000, key)
341
+ expect(harness.terminals.state('term-1').dirty).toBe(true)
342
+ await socketCall(connection, 'terminal.draft', { dirty: false }, 1000, key)
343
+ expect(harness.terminals.state('term-1').dirty).toBe(false)
344
+ await expect(socketCall(connection, 'terminal.approve', { requestId: 'missing', decision: 'approved' }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
345
+ })
346
+
347
+ it('answers through the existing run registry and rejects stale answers', async () => {
348
+ const { connection, tell, events } = await setup()
349
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
350
+ await tell('ready')
351
+ await tell('begin', { text: 'Ask a question' })
352
+ const answer = tell('question', { requestId: 'native-question', question: 'Choose?', options: [{ label: 'A', description: 'First' }], multiSelect: false })
353
+ let id = ''
354
+ await vi.waitFor(async () => {
355
+ const pending = await socketCall<{ requests: Array<{ requestId: string }> }>(connection, 'terminal.pending', {}, 1000, key)
356
+ expect(pending.requests).toHaveLength(1)
357
+ id = pending.requests[0].requestId
358
+ })
359
+ await socketCall(connection, 'terminal.answer', { requestId: id, answer: { selected: ['A'] } }, 1000, key)
360
+ expect(await answer).toEqual({ selected: ['A'] })
361
+ expect(events.some(event => event.type === 'question.answered')).toBe(true)
362
+ await expect(socketCall(connection, 'terminal.answer', { requestId: id, answer: { selected: ['A'] } }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
363
+ })
364
+
365
+ it('keeps an approval and question with the same native tool id independent', async () => {
366
+ const { connection, tell } = await setup()
367
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
368
+ await tell('ready'); await tell('begin', { text: 'Ask after approval' })
369
+ const approval = tell('approval', { requestId: 'same-tool-call', target: 'ask_user_question', action: '{}' })
370
+ let approvalId = ''
371
+ await vi.waitFor(async () => {
372
+ const { requests } = await socketCall<{ requests: Array<{ requestId: string }> }>(connection, 'terminal.pending', {}, 1000, key)
373
+ expect(requests).toHaveLength(1); approvalId = requests[0].requestId
374
+ })
375
+ await socketCall(connection, 'terminal.approve', { requestId: approvalId, decision: 'approved' }, 1000, key)
376
+ expect(await approval).toEqual({ decision: 'approved' })
377
+ const question = tell('question', { requestId: 'same-tool-call', question: 'Continue?', options: [{ label: 'Yes' }] })
378
+ let questionId = ''
379
+ await vi.waitFor(async () => {
380
+ const { requests } = await socketCall<{ requests: Array<{ requestId: string; type: string }> }>(connection, 'terminal.pending', {}, 1000, key)
381
+ expect(requests).toHaveLength(1); expect(requests[0].type).toBe('question'); questionId = requests[0].requestId
382
+ })
383
+ expect(questionId).not.toBe(approvalId)
384
+ await socketCall(connection, 'terminal.answer', { requestId: questionId, answer: { selected: ['Yes'] } }, 1000, key)
385
+ expect(await question).toEqual({ selected: ['Yes'] })
386
+ })
387
+
388
+ it('cancels a pending question through the run interrupt path', async () => {
389
+ const { connection, tell, harness } = await setup()
390
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
391
+ await tell('ready')
392
+ await tell('begin', { text: 'Wait for a question' })
393
+ const answer = tell('question', { requestId: 'cancel-question', question: 'Wait?', options: [], multiSelect: false })
394
+ await vi.waitFor(async () => expect((await socketCall<{ requests: unknown[] }>(connection, 'terminal.pending', {}, 1000, key)).requests).toHaveLength(1))
395
+ await socketCall(connection, 'terminal.interrupt', {}, 1000, key)
396
+ expect(await answer).toEqual({ selected: [] })
397
+ expect(harness.terminals.state('term-1').active).toBe(false)
398
+ })
399
+ })
@@ -1,15 +1,13 @@
1
- // Terminal sessions: a person working with the `pi` command in a terminal that the embedding
2
- // application draws. The harness prepares the launch (isolated account, compiled instructions,
3
- // skills, the session socket), then follows the process through its extension. It never owns
4
- // the terminal: the application spawns the process from the spec returned here and reports when
5
- // it exits.
1
+ // A host owns the PTY and process supervision; Harness owns the terminal application inside it.
2
+ // This service prepares the isolated account, compiled context, trusted resource selection and
3
+ // socket bridge. The default launch runs our frontend with Pi's persistent SDK runtime.
6
4
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
7
5
  import { dirname, join } from 'node:path'
8
6
  import { fileURLToPath } from 'node:url'
9
7
 
10
8
  import type { Accounts } from '../accounts/accounts'
11
9
  import { configDir, launchEnv } from '../accounts/env'
12
- import type { Account } from '../accounts/store'
10
+ import { readAudit, type Account } from '../accounts/store'
13
11
  import type { Bus } from '../core/bus'
14
12
  import { log } from '../core/bus'
15
13
  import type { HarnessEnv } from '../core/env'
@@ -20,8 +18,9 @@ import type { BrokerSession, ConnectedApps } from '../mcp/service'
20
18
  import type { CallerContext } from '../memory/caller'
21
19
  import { dispatch } from '../memory/dispatch'
22
20
  import type { AttachParams, PluginScope } from '../plugins/api'
23
- import type { Plugins } from '../plugins/plugins'
21
+ import type { Loading, Plugins } from '../plugins/plugins'
24
22
  import type { Plugin } from '../plugins/types'
23
+ import type { Hub } from '../hub/hub'
25
24
  import { compile, copyToRun, memoryDelta, record, render, saveForSession, type Launch } from '../prompt/compile'
26
25
  import type { RunRecord } from '../sessions/contracts'
27
26
  import { isActiveStatus } from '../sessions/contracts'
@@ -36,6 +35,16 @@ import { TerminalController, type PolicyReply } from './controller'
36
35
  import { resolvePiCommand, terminalCommandFiles } from './launch'
37
36
  import { SessionSocket, socketPath } from './socket'
38
37
 
38
+ /** Identity of the exact resource decision that this terminal was prepared with. */
39
+ function resourceIdentity(loading: Loading | null): string {
40
+ if (!loading) return 'null'
41
+ return JSON.stringify({
42
+ extensions: loading.extensionPaths, builtins: loading.builtins, skills: loading.skillPaths,
43
+ prompts: loading.promptPaths, themes: loading.themePaths, detached: loading.detached,
44
+ project: loading.projectCode, packages: loading.packageCode
45
+ })
46
+ }
47
+
39
48
  /** The files a terminal session needs on disk, outside this process. */
40
49
  export interface TerminalAssets {
41
50
  /** The Node binary that runs the commands. */
@@ -102,8 +111,9 @@ export interface TerminalsOptions {
102
111
  plugins?: Plugins
103
112
  /** Lists plugins and applies a person's choices, for the session's `/plugins` command. */
104
113
  pluginControl?: PluginControl
114
+ hub?: Hub
105
115
  assets?: TerminalAssets
106
- /** The `pi` executable. Defaults to the PATH command on POSIX and the bundled CLI on Windows. */
116
+ /** The `pi` executable. Defaults to the pinned bundled CLI through the active Node runtime. */
107
117
  program?: () => string | null
108
118
  }
109
119
 
@@ -121,13 +131,17 @@ export class Terminals {
121
131
  private readonly sessions = new Map<string, Live>()
122
132
  private readonly socket: SessionSocket
123
133
  private readonly assets: TerminalAssets
134
+ /** A session's `/accounts` switch, taken once its Pi process has ended. */
135
+ private readonly relaunches = new Map<string, { accountId: string; nativeSessionId: string | null }>()
124
136
 
125
137
  constructor(private readonly options: TerminalsOptions) {
126
138
  this.assets = options.assets ?? defaultAssets()
139
+ options.runs.listen(event => this.sessions.get(event.sessionId)?.controller.observe(event))
127
140
  this.socket = new SessionSocket(socketPath(options.stateDir), {
128
141
  memory: (caller, call, params) => dispatch(options.env, caller, call, params),
129
142
  apps: (session, call, params) => this.appsCall(session, call, params),
130
- plugins: (grant, call, params, control) => this.pluginsCall(grant.sessionId, call, params, control)
143
+ plugins: (grant, call, params, control) => this.pluginsCall(grant.sessionId, call, params, control),
144
+ accounts: (grant, call, params, control) => this.accountsCall(grant.sessionId, call, params, control)
131
145
  })
132
146
  }
133
147
 
@@ -200,6 +214,12 @@ export class Terminals {
200
214
  if (!api || !live) throw new HarnessError('unknown_method', `Unknown call ${call}.`)
201
215
  const projectId = live.input.projectId ?? null
202
216
  const scope = { cwd: live.input.cwd, accountId: live.account.id, projectId }
217
+ if (call === 'hub.list' && this.options.hub) return this.options.hub.list(params.refresh === true)
218
+ if (call === 'hub.install' && this.options.hub) {
219
+ if (!control) throw new HarnessError('unauthorized', 'Only a person at this terminal can install items. Type /hub.')
220
+ if (typeof params.id !== 'string' || typeof params.version !== 'string') throw invalid('An item id and version are required.')
221
+ return this.options.hub.install(params.id, params.version)
222
+ }
203
223
  if (call === 'plugins.list') return api.list(scope)
204
224
  if (call === 'plugins.session') return { projectId }
205
225
  if (!control) throw new HarnessError('unauthorized', 'Only a person at this terminal can change plugins. Type /plugins.')
@@ -220,6 +240,86 @@ export class Terminals {
220
240
  }
221
241
  }
222
242
 
243
+ /**
244
+ * `/accounts` in a terminal. Every call needs the session's control key: adding, signing in,
245
+ * or removing an account is a person's choice, never the model's shell command.
246
+ */
247
+ private async accountsCall(sessionId: string, call: string, params: Record<string, unknown>, control: boolean): Promise<unknown> {
248
+ const { accounts } = this.options
249
+ const live = this.sessions.get(sessionId)
250
+ if (!live) throw new HarnessError('unknown_method', `Unknown call ${call}.`)
251
+ if (!control) throw new HarnessError('unauthorized', 'Only a person at this terminal can manage accounts. Type /accounts.')
252
+ const text = (name: string): string => {
253
+ if (typeof params[name] !== 'string' || !params[name]) throw invalid(`${name} is required.`)
254
+ return params[name] as string
255
+ }
256
+ const optional = (name: string): string | null => (typeof params[name] === 'string' ? (params[name] as string) : null)
257
+ switch (call) {
258
+ case 'accounts.deviceId':
259
+ return accounts.installationDeviceId()
260
+ case 'accounts.list':
261
+ return accounts.overview()
262
+ case 'accounts.session':
263
+ return { accountId: live.account.id, label: live.account.label }
264
+ case 'accounts.create':
265
+ return accounts.create(text('label'), optional('authKind') ?? 'subscription')
266
+ case 'accounts.rename':
267
+ return accounts.rename(text('accountId'), text('label'))
268
+ case 'accounts.setDefault':
269
+ return accounts.setDefault(text('accountId'))
270
+ case 'accounts.remove': {
271
+ const accountId = text('accountId')
272
+ // Every terminal in this process counts, this session's own included. Another process's terminals are not visible here.
273
+ if (accounts.terminalCount(accountId) > 0) throw new HarnessError('account_in_use', 'A running terminal is using this account, this one included.', 'Switch this session to another account first, then remove it.')
274
+ return accounts.remove(accountId)
275
+ }
276
+ case 'accounts.refresh':
277
+ return accounts.refresh(optional('accountId'))
278
+ case 'accounts.providers':
279
+ return accounts.providers()
280
+ case 'accounts.loginStart':
281
+ return accounts.loginStart(text('accountId'), text('provider'), optional('method'))
282
+ case 'accounts.loginStatus':
283
+ return accounts.loginStatus(text('accountId'))
284
+ case 'accounts.loginRespond':
285
+ accounts.loginRespond(text('loginId'), text('promptId'), optional('value'))
286
+ return {}
287
+ case 'accounts.loginCancel':
288
+ accounts.loginCancel(text('accountId'), optional('loginId'))
289
+ return {}
290
+ case 'accounts.setApiKey':
291
+ return accounts.setApiKey(text('accountId'), text('key'), text('provider'))
292
+ case 'accounts.removeProvider':
293
+ return accounts.removeProvider(text('accountId'), text('provider'))
294
+ case 'accounts.signOut':
295
+ return accounts.signOut(text('accountId'))
296
+ case 'accounts.savePool': {
297
+ const pool = params.pool as { id?: unknown; label?: unknown; accountIds?: unknown } | undefined
298
+ if (!pool || typeof pool.label !== 'string' || !Array.isArray(pool.accountIds) || !pool.accountIds.every((id) => typeof id === 'string')) throw invalid('A pool needs a label and its account ids.')
299
+ return accounts.savePool({ id: typeof pool.id === 'string' ? pool.id : undefined, label: pool.label, accountIds: pool.accountIds as string[] })
300
+ }
301
+ case 'accounts.removePool':
302
+ return accounts.removePool(text('poolId'))
303
+ case 'accounts.audit':
304
+ return readAudit(accounts.dir, optional('accountId'), typeof params.limit === 'number' ? params.limit : 100)
305
+ case 'accounts.relaunch': {
306
+ // Checked here so a bad choice is refused before the session is asked to end.
307
+ const target = accounts.resolve(text('accountId'))
308
+ this.relaunches.set(sessionId, { accountId: target.id, nativeSessionId: live.controller.nativeSessionId })
309
+ return { accountId: target.id, label: target.label }
310
+ }
311
+ default:
312
+ throw new HarnessError('unknown_method', `Unknown call ${call}.`)
313
+ }
314
+ }
315
+
316
+ /** The account a session asked to switch to. Taken once, after its Pi process has ended and before `dispose`. */
317
+ takeRelaunch(sessionId: string): { accountId: string; nativeSessionId: string | null } | null {
318
+ const request = this.relaunches.get(sessionId) ?? null
319
+ this.relaunches.delete(sessionId)
320
+ return request
321
+ }
322
+
223
323
  private live(sessionId: string): Live {
224
324
  const live = this.sessions.get(sessionId)
225
325
  if (!live) throw new HarnessError('terminal_not_ready', 'The terminal controller is unavailable. Reopen this session.')
@@ -307,7 +407,7 @@ export class Terminals {
307
407
  const { root } = env
308
408
  if (!input?.sessionId || !input.cwd || !input.sessionFolder) throw invalid('A terminal session needs an id, a working folder, and its own folder.')
309
409
  await this.dispose(input.sessionId)
310
- const command = resolvePiCommand({ platform: process.platform, node: this.assets.node, programOverride: this.options.program })
410
+ const command = resolvePiCommand({ platform: process.platform, node: this.assets.node, cli: this.assets.cli, programOverride: this.options.program })
311
411
  // A pinned account is used as it is; it is never exchanged for another one.
312
412
  const account = input.accountId ? accounts.find(input.accountId) : accounts.resolve(null, null)
313
413
  const launch = launchEnv(accounts.dir, account)
@@ -339,14 +439,23 @@ export class Terminals {
339
439
  begin: ({ reservedRunId, text }) => this.begin(live, reservedRunId, text),
340
440
  policy: (run, tool, args) => this.decide(live, run, tool, args),
341
441
  native: (nativeSessionId) => bus.emit({ type: 'terminal.native', sessionId, nativeSessionId }),
342
- telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event })
442
+ telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event }),
443
+ interrupt: runId => this.options.runs.interrupt(runId),
444
+ resolveApproval: (runId, id, decision) => this.options.runs.resolveApproval(runId, id, decision),
445
+ answerQuestion: (runId, id, answer) => this.options.runs.answerQuestion(runId, id, answer),
446
+ answerQuestions: (runId, batchId, answers) => this.options.runs.answerQuestions(runId, batchId, answers)
343
447
  })
344
448
  }
345
449
  token = this.socket.issue({
346
450
  sessionId,
347
451
  caller: () => ({ ...caller, runId: live.controller.activeRun?.runId ?? null }),
348
452
  broker,
349
- terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected() }
453
+ terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected(), control: async (call, params) => {
454
+ if (call !== 'terminal.resources') return live.controller.control(call, params)
455
+ const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, account), install: false, caller: { projectId: input.projectId ?? null } }) ?? null
456
+ if (resourceIdentity(current) !== resourceIdentity(loading)) throw new HarnessError('terminal_resources_changed', 'Trusted resources changed since this terminal was prepared. Review /plugins and reopen the terminal to load the new selection.')
457
+ return {}
458
+ } }
350
459
  })
351
460
  live.token = token
352
461
  this.sessions.set(sessionId, live)
@@ -370,6 +479,7 @@ export class Terminals {
370
479
  args,
371
480
  env: {
372
481
  ...launch,
482
+ XINGULAR_HARNESS_NAME: env.productName,
373
483
  ...(process.platform === 'win32' ? { XINGULAR_HARNESS_NODE: this.assets.node, XINGULAR_HARNESS_CLI: this.assets.cli } : {}),
374
484
  ...prependSearchPath(this.ensureCommands(), searchPath()),
375
485
  [SOCKET_ENV]: this.socket.path,