@xingular/harness 0.1.6 → 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.
- package/CHANGELOG.md +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
package/src/terminal/launch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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',
|
package/src/terminal/socket.ts
CHANGED
|
@@ -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('
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
|
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,
|