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