@xingular/harness 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,112 @@
1
+ // The `/plugins` command of a terminal session, without Pi: what it lists, what it offers for
2
+ // each row, and the conversation it has with the person. It asks through whatever interface it
3
+ // is given (Pi's own dialogs in a terminal) and changes things through one function, so the
4
+ // same flow is tested without a terminal.
5
+ import type { Attachment } from '../plugins/attachments'
6
+ import type { Contribution, Plugin } from '../plugins/types'
7
+
8
+ export interface MenuUi {
9
+ select(title: string, options: string[]): Promise<string | undefined>
10
+ confirm(title: string, message: string): Promise<boolean>
11
+ notify(message: string, level?: 'info' | 'warning' | 'error'): void
12
+ }
13
+
14
+ /** `plugins.list`, `plugins.attach`, `plugins.detach`, `plugins.trust`, `plugins.untrust`. */
15
+ export type MenuCall = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
16
+
17
+ export interface MenuRow {
18
+ /** The plugin's id, or one contribution's. */
19
+ id: string
20
+ label: string
21
+ on: boolean
22
+ /** The plugin it is, or belongs to. */
23
+ plugin: Plugin
24
+ /** Set when the row is the whole plugin. */
25
+ whole: boolean
26
+ }
27
+
28
+ const here = (attachment: Attachment, projectId: string | null): boolean => {
29
+ if (projectId && attachment.on.projectIds.includes(projectId)) return true
30
+ if (projectId && attachment.off.projectIds.includes(projectId)) return false
31
+ return attachment.global
32
+ }
33
+
34
+ const mark = (on: boolean): string => (on ? '●' : '○')
35
+
36
+ /** What can be switched or trusted from here: each found or installed plugin, and each item of the rest that has a switch. */
37
+ export function menuRows(plugins: readonly Plugin[], projectId: string | null): MenuRow[] {
38
+ const rows: MenuRow[] = []
39
+ for (const plugin of plugins) {
40
+ if (plugin.attachment) {
41
+ const on = here(plugin.attachment, projectId)
42
+ rows.push({ id: plugin.id, plugin, whole: true, on, label: `${mark(on)} ${plugin.title}${plugin.trust === 'untrusted' ? ' · code not trusted' : plugin.runsCode ? ' · runs code' : ''}` })
43
+ continue
44
+ }
45
+ if (plugin.trust !== 'none') rows.push({ id: plugin.id, plugin, whole: true, on: plugin.trust === 'trusted', label: `${mark(plugin.trust === 'trusted')} ${plugin.title} · ${plugin.trust === 'trusted' ? 'code trusted' : 'code not trusted'}` })
46
+ for (const entry of plugin.contributions) {
47
+ if (!entry.attachment) continue
48
+ rows.push({ id: entry.id, plugin, whole: false, on: entry.state === 'on', label: `${mark(entry.state === 'on')} ${entry.name} · ${kindWord(entry)} in ${plugin.title}` })
49
+ }
50
+ }
51
+ return rows
52
+ }
53
+
54
+ const kindWord = (entry: Contribution): string => (entry.kind === 'connection' ? 'connected app' : entry.kind === 'code' ? 'extension' : entry.kind)
55
+
56
+ const SWITCH_OFF_PROJECT = 'Switch off for this project'
57
+ const SWITCH_ON_PROJECT = 'Switch on for this project'
58
+ const SWITCH_OFF_EVERYWHERE = 'Switch off everywhere'
59
+ const SWITCH_ON_EVERYWHERE = 'Switch on everywhere'
60
+ const TRUST = 'Trust its code'
61
+ const UNTRUST = 'Stop trusting its code'
62
+ const BACK = 'Back'
63
+ const DONE = 'Done'
64
+
65
+ /** What a person can do with one row. A whole plugin whose code waits for trust can be trusted here. */
66
+ export function rowActions(row: MenuRow, projectId: string | null): string[] {
67
+ const switchable = row.whole ? row.plugin.attachment !== null : true
68
+ const actions: string[] = []
69
+ if (switchable) {
70
+ if (projectId) actions.push(row.on ? SWITCH_OFF_PROJECT : SWITCH_ON_PROJECT)
71
+ actions.push(row.on ? SWITCH_OFF_EVERYWHERE : SWITCH_ON_EVERYWHERE)
72
+ }
73
+ if (row.whole && row.plugin.code) actions.push(row.plugin.trust === 'untrusted' ? TRUST : UNTRUST)
74
+ return [...actions, BACK]
75
+ }
76
+
77
+ /**
78
+ * Runs the command: list, pick a row, pick what to do, until the person is done. Nothing changes
79
+ * without a choice made here, and trusting code asks once more with the files in view.
80
+ */
81
+ export async function runPluginsMenu(ui: MenuUi, call: MenuCall, projectId: string | null): Promise<void> {
82
+ let changed = false
83
+ for (;;) {
84
+ const rows = menuRows(await call<Plugin[]>('plugins.list'), projectId)
85
+ if (!rows.length) {
86
+ ui.notify('There is nothing to switch or trust here yet.', 'info')
87
+ return
88
+ }
89
+ const picked = await ui.select(`Plugins${changed ? ' (changes apply when this session restarts)' : ''}`, [...rows.map((row) => row.label), DONE])
90
+ const row = rows.find((candidate) => candidate.label === picked)
91
+ if (!row) break
92
+ const action = await ui.select(`${row.plugin.title}: ${row.plugin.description || 'No description.'}`, rowActions(row, projectId))
93
+ if (!action || action === BACK) continue
94
+ try {
95
+ if (action === TRUST || action === UNTRUST) {
96
+ const code = row.plugin.code!
97
+ if (action === TRUST) {
98
+ const sure = await ui.confirm('Trust this code?', `${code.files.join('\n')}\n\nFingerprint ${code.hash.slice(0, 12)}. It runs inside the session and can do anything this app can, including reading your files and credentials.`)
99
+ if (!sure) continue
100
+ }
101
+ await call(action === TRUST ? 'plugins.trust' : 'plugins.untrust', { hash: code.hash })
102
+ } else {
103
+ const on = action === SWITCH_ON_PROJECT || action === SWITCH_ON_EVERYWHERE
104
+ await call(on ? 'plugins.attach' : 'plugins.detach', { id: row.id, everywhere: action === SWITCH_ON_EVERYWHERE || action === SWITCH_OFF_EVERYWHERE })
105
+ }
106
+ changed = true
107
+ } catch (error) {
108
+ ui.notify(error instanceof Error ? error.message : String(error), 'error')
109
+ }
110
+ }
111
+ if (changed) ui.notify('Saved. It applies when this session restarts.', 'info')
112
+ }
@@ -0,0 +1,182 @@
1
+ // The session socket: how a process the harness did not start in-process (the `pi` command in
2
+ // a terminal, with its extension and the `agentx-wiki` / `agentx-mcp` commands) reaches the
3
+ // session it belongs to. Owner-only Unix socket, newline-delimited JSON, one request per
4
+ // connection. Each terminal session holds a random token bound to its caller, its connected-apps
5
+ // grant, and its controller; the token stops working when the terminal ends.
6
+ import { createHash } from 'node:crypto'
7
+ import { chmodSync, mkdirSync, rmSync } from 'node:fs'
8
+ import { createServer, type Server, type Socket } from 'node:net'
9
+ import { tmpdir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+
12
+ import { log } from '../core/bus'
13
+ import { HarnessError, toErrorShape } from '../core/errors'
14
+ import { newId } from '../core/fs'
15
+ import type { BrokerSession } from '../mcp/service'
16
+ import type { CallerContext } from '../memory/caller'
17
+
18
+ /** What a token stands for. */
19
+ export interface SocketGrant {
20
+ sessionId: string
21
+ /** Who memory, Wiki, and recall calls are made as. The run is filled in while a turn is live. */
22
+ caller: () => CallerContext
23
+ /** The session's connected-apps grant, when it has one. */
24
+ broker: BrokerSession | null
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 }
27
+ }
28
+
29
+ export interface SocketHandlers {
30
+ /** `wiki.*`, `memory.*`, `recall.*`. */
31
+ memory(caller: CallerContext, call: string, params: Record<string, unknown>): string
32
+ /** `mcp.list`, `mcp.call`, `mcp.revision`, `tools.list`, `tools.call`. */
33
+ apps(session: BrokerSession, call: string, params: Record<string, unknown>): Promise<unknown>
34
+ /**
35
+ * `plugins.*`. `control` is true only for the session's own extension, which holds a key the
36
+ * session's shell never sees; anything that changes what loads needs it.
37
+ */
38
+ plugins?(grant: SocketGrant, call: string, params: Record<string, unknown>, control: boolean): Promise<unknown>
39
+ }
40
+
41
+ const MAX_REQUEST_BYTES = 1024 * 1024
42
+ /** macOS refuses a socket path longer than 103 bytes. */
43
+ const MAX_SOCKET_PATH = 100
44
+
45
+ export function socketPath(stateDir: string): string {
46
+ const path = join(stateDir, 'run', 'harness.sock')
47
+ if (Buffer.byteLength(path) <= MAX_SOCKET_PATH) return path
48
+ return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
49
+ }
50
+
51
+ export class SessionSocket {
52
+ private server: Server | null = null
53
+ private listening: Promise<void> | null = null
54
+ private readonly grants = new Map<string, SocketGrant>()
55
+ /**
56
+ * A second key per token, handed out once. The token is in the session's environment, where a
57
+ * command the model runs can read it; this key is claimed by the session's extension as it
58
+ * loads, before any model turn, and kept in its memory only.
59
+ */
60
+ private readonly controls = new Map<string, string>()
61
+ private readonly clients = new Set<Socket>()
62
+
63
+ constructor(
64
+ readonly path: string,
65
+ private readonly handlers: SocketHandlers
66
+ ) {}
67
+
68
+ issue(grant: SocketGrant): string {
69
+ const token = `${newId()}${newId()}`.replaceAll('-', '')
70
+ this.grants.set(token, grant)
71
+ return token
72
+ }
73
+
74
+ revoke(token: string): void {
75
+ this.grants.delete(token)
76
+ this.controls.delete(token)
77
+ }
78
+
79
+ revokeSession(sessionId: string): void {
80
+ for (const [token, grant] of this.grants) if (grant.sessionId === sessionId) this.revoke(token)
81
+ }
82
+
83
+ /** Runs one request line and returns the reply line's object. */
84
+ async handle(line: string, connection?: { waiting: (grant: SocketGrant | null) => void }): Promise<{ result: unknown } | { error: unknown }> {
85
+ try {
86
+ let request: Record<string, unknown>
87
+ try {
88
+ request = JSON.parse(line) as Record<string, unknown>
89
+ } catch {
90
+ throw new HarnessError('invalid_request', 'The request is not JSON.')
91
+ }
92
+ const token = typeof request.token === 'string' ? request.token : ''
93
+ const grant = this.grants.get(token)
94
+ if (!grant) throw new HarnessError('unauthorized', 'This session token is not valid. It ends when its terminal session ends.')
95
+ if (typeof request.event === 'string') {
96
+ if (!grant.terminal) throw new HarnessError('unknown_method', 'This session has no terminal controller.')
97
+ connection?.waiting(grant)
98
+ try {
99
+ return { result: (await grant.terminal.accept(request.event, (request.data ?? {}) as Record<string, unknown>)) ?? {} }
100
+ } finally {
101
+ connection?.waiting(null)
102
+ }
103
+ }
104
+ const call = typeof request.call === 'string' ? request.call : ''
105
+ const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>
106
+ if (['wiki.', 'memory.', 'recall.'].some((prefix) => call.startsWith(prefix))) return { result: this.handlers.memory(grant.caller(), call, params) }
107
+ if (['mcp.', 'tools.'].some((prefix) => call.startsWith(prefix))) {
108
+ if (!grant.broker) throw new HarnessError('mcp_unauthorized', 'Connected apps are not available to this session.')
109
+ return { result: await this.handlers.apps(grant.broker, call, params) }
110
+ }
111
+ if (call === 'control.claim') {
112
+ if (this.controls.has(token)) throw new HarnessError('unauthorized', "This session's control key was already claimed.")
113
+ const key = `${newId()}${newId()}`.replaceAll('-', '')
114
+ this.controls.set(token, key)
115
+ return { result: { key } }
116
+ }
117
+ if (call.startsWith('plugins.') && this.handlers.plugins) {
118
+ const key = this.controls.get(token)
119
+ return { result: await this.handlers.plugins(grant, call, params, key !== undefined && request.control === key) }
120
+ }
121
+ throw new HarnessError('unknown_method', `Unknown call ${call}.`)
122
+ } catch (error) {
123
+ return { error: toErrorShape(error) }
124
+ }
125
+ }
126
+
127
+ private serve(client: Socket): void {
128
+ let buffer = ''
129
+ let waiting: SocketGrant | null = null
130
+ this.clients.add(client)
131
+ client.on('error', () => {})
132
+ client.once('close', () => {
133
+ this.clients.delete(client)
134
+ // A decision the native process was waiting on can no longer reach it.
135
+ waiting?.terminal?.disconnected()
136
+ })
137
+ client.on('data', (chunk) => {
138
+ buffer += chunk.toString('utf8')
139
+ if (Buffer.byteLength(buffer) > MAX_REQUEST_BYTES) {
140
+ client.destroy()
141
+ return
142
+ }
143
+ const newline = buffer.indexOf('\n')
144
+ if (newline < 0) return
145
+ client.pause()
146
+ void this.handle(buffer.slice(0, newline), { waiting: (grant) => void (waiting = grant) }).then((reply) => client.end(`${JSON.stringify(reply)}\n`))
147
+ })
148
+ }
149
+
150
+ /** Binds the socket once; later calls wait for the same bind. */
151
+ listen(): Promise<void> {
152
+ this.listening ??= new Promise<void>((resolve, reject) => {
153
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
154
+ rmSync(this.path, { force: true })
155
+ const server = createServer((client) => this.serve(client))
156
+ server.once('error', (error) => {
157
+ this.listening = null
158
+ reject(error)
159
+ })
160
+ server.listen(this.path, () => {
161
+ try {
162
+ chmodSync(this.path, 0o600)
163
+ } catch (error) {
164
+ log('session socket permissions', error instanceof Error ? error.message : String(error))
165
+ }
166
+ this.server = server
167
+ resolve()
168
+ })
169
+ })
170
+ return this.listening
171
+ }
172
+
173
+ async close(): Promise<void> {
174
+ this.grants.clear()
175
+ const server = this.server
176
+ this.server = null
177
+ this.listening = null
178
+ for (const client of this.clients) client.destroy()
179
+ if (server) await new Promise<void>((resolve) => server.close(() => resolve()))
180
+ rmSync(this.path, { force: true })
181
+ }
182
+ }
@@ -0,0 +1,115 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import type { ChildProcess, spawn as nodeSpawn } from 'node:child_process'
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ import { memorySecretStore } from '../accounts/secrets'
10
+ import { createHarness, type Harness, type HarnessOptions } from '../harness'
11
+ import { defaultAssets } from './terminals'
12
+ import { parseStandaloneOptions, runStandaloneTerminal, standaloneArguments } from './standalone'
13
+ import { socketPath } from './socket'
14
+
15
+ const scratch: string[] = []
16
+ const harnesses: Harness[] = []
17
+
18
+ afterEach(async () => {
19
+ for (const harness of harnesses.splice(0)) await harness.close()
20
+ for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true })
21
+ vi.restoreAllMocks()
22
+ })
23
+
24
+ function temporaryDirectory(prefix: string): string {
25
+ const path = mkdtempSync(join(tmpdir(), prefix))
26
+ scratch.push(path)
27
+ return path
28
+ }
29
+
30
+ function fakeSpawn(onSpawn: (program: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: string }) => void, outcome: { code?: number; error?: Error } = {}): typeof nodeSpawn {
31
+ return ((program: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: string }) => {
32
+ onSpawn(program, args, options)
33
+ const child = new EventEmitter() as ChildProcess
34
+ child.kill = vi.fn(() => true)
35
+ queueMicrotask(() => {
36
+ if (outcome.error) child.emit('error', outcome.error)
37
+ else child.emit('exit', outcome.code ?? 0, null)
38
+ })
39
+ return child
40
+ }) as unknown as typeof nodeSpawn
41
+ }
42
+
43
+ function harnessFactory(options: HarnessOptions): Harness {
44
+ const harness = createHarness({ ...options, secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi' })
45
+ harnesses.push(harness)
46
+ return harness
47
+ }
48
+
49
+ describe('standalone terminal startup', () => {
50
+ it('routes the no-command and terminal invocations to Pi, leaving host commands explicit', () => {
51
+ expect(standaloneArguments([])).toEqual([])
52
+ expect(standaloneArguments(['--data', '/tmp/data'])).toEqual(['--data', '/tmp/data'])
53
+ expect(standaloneArguments(['terminal', '--state', '/tmp/state'])).toEqual(['--state', '/tmp/state'])
54
+ expect(standaloneArguments(['serve', '--state', '/tmp/state'])).toBeNull()
55
+ })
56
+
57
+ it('uses a dedicated default data and state root and accepts path overrides', () => {
58
+ expect(parseStandaloneOptions([], '/home/test', '/work/project')).toEqual({
59
+ dataRoot: '/home/test/.xingular-harness/data',
60
+ stateDir: '/home/test/.xingular-harness/state'
61
+ })
62
+ expect(parseStandaloneOptions(['--data', './knowledge', '--state', './device'], '/home/test', '/work/project')).toEqual({
63
+ dataRoot: '/work/project/knowledge',
64
+ stateDir: '/work/project/device'
65
+ })
66
+ expect(() => parseStandaloneOptions(['--data'])).toThrow('--data needs a directory path.')
67
+ expect(() => parseStandaloneOptions(['--model', 'unknown'])).toThrow('Unknown standalone option: --model')
68
+ })
69
+
70
+ it('starts Pi in the current directory, creates the first isolated account, and cleans up on exit', async () => {
71
+ const home = temporaryDirectory('harness-standalone-')
72
+ const cwd = temporaryDirectory('harness-cwd-')
73
+ const paths = parseStandaloneOptions([], home)
74
+ let invocation: { program: string; args: string[]; options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: string } } | undefined
75
+ const spawn = fakeSpawn((program, args, options) => {
76
+ invocation = { program, args, options }
77
+ })
78
+
79
+ expect(await runStandaloneTerminal([], { home, cwd, stdinIsTTY: true, stdoutIsTTY: true, createSessionId: () => 'standalone', createHarness: harnessFactory, spawn })).toBe(0)
80
+
81
+ const harness = harnesses.shift()!
82
+ expect(harness.accounts.overview().accounts).toMatchObject([{ label: 'Default', isDefault: true }])
83
+ expect(invocation?.program).toBe('/opt/pi/bin/pi')
84
+ expect(invocation?.options).toMatchObject({ cwd, stdio: 'inherit' })
85
+ expect(invocation?.args).toContain('--extension')
86
+ expect(invocation?.args).toContain(defaultAssets().piExtension)
87
+ expect(invocation?.args).toContain(join(paths.dataRoot, 'sessions', 'terminal', createHash('sha256').update(resolve(cwd)).digest('hex'), 'pi'))
88
+ expect(invocation?.options.env?.PI_CODING_AGENT_DIR).toContain(join(paths.stateDir, 'accounts'))
89
+ expect(invocation?.options.env?.XINGULAR_HARNESS_SOCKET).toBeTruthy()
90
+ expect(invocation?.options.env?.XINGULAR_HARNESS_TOKEN).toBeTruthy()
91
+ expect(existsSync(join(paths.stateDir, 'accounts', harness.accounts.overview().accounts[0].id, 'home', 'pi', 'auth.json'))).toBe(false)
92
+ expect(existsSync(socketPath(paths.stateDir))).toBe(false)
93
+ expect(harness.terminals.has('standalone')).toBe(false)
94
+ })
95
+
96
+ it('requires an interactive TTY before creating Harness data', async () => {
97
+ const create = vi.fn(harnessFactory)
98
+ await expect(runStandaloneTerminal([], { stdinIsTTY: false, stdoutIsTTY: true, createHarness: create })).rejects.toThrow('needs a TTY')
99
+ expect(create).not.toHaveBeenCalled()
100
+ })
101
+
102
+ it('cleans up the prepared terminal when Pi cannot be spawned', async () => {
103
+ const home = temporaryDirectory('harness-standalone-fail-')
104
+ const cwd = temporaryDirectory('harness-cwd-fail-')
105
+ const spawn = fakeSpawn(() => undefined, { error: new Error('spawn failed') })
106
+
107
+ await expect(runStandaloneTerminal([], { home, cwd, stdinIsTTY: true, stdoutIsTTY: true, createSessionId: () => 'failed', createHarness: harnessFactory, spawn })).rejects.toThrow('spawn failed')
108
+
109
+ const harness = harnesses.shift()!
110
+ const account = harness.accounts.overview().accounts[0]
111
+ expect(existsSync(join(harness.accounts.dir, account.id, 'home', 'pi', 'auth.json'))).toBe(false)
112
+ expect(existsSync(socketPath(harness.stateDir))).toBe(false)
113
+ expect(harness.terminals.has('failed')).toBe(false)
114
+ })
115
+ })
@@ -0,0 +1,126 @@
1
+ // The standalone entry point for people using the harness directly. Pi owns the interface;
2
+ // the harness prepares the isolated account, context, extension, and session socket.
3
+ import { spawn as nodeSpawn } from 'node:child_process'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { homedir } from 'node:os'
6
+ import { join, resolve } from 'node:path'
7
+
8
+ import { createHarness, type Harness, type HarnessOptions } from '../harness'
9
+ import type { TerminalLaunch } from './terminals'
10
+
11
+ export interface StandalonePaths {
12
+ dataRoot: string
13
+ stateDir: string
14
+ }
15
+
16
+ export interface StandaloneDependencies {
17
+ home?: string
18
+ cwd?: string
19
+ stdinIsTTY?: boolean
20
+ stdoutIsTTY?: boolean
21
+ createSessionId?: () => string
22
+ createHarness?: (options: HarnessOptions) => Harness
23
+ spawn?: typeof nodeSpawn
24
+ }
25
+
26
+ export function standaloneArguments(argv: string[]): string[] | null {
27
+ const command = argv[0]
28
+ if (!command || command.startsWith('--') || command === '-h') return argv
29
+ return command === 'terminal' ? argv.slice(1) : null
30
+ }
31
+
32
+ export function parseStandaloneOptions(argv: string[], home = homedir(), cwd = process.cwd()): StandalonePaths {
33
+ const root = join(home, '.xingular-harness')
34
+ const paths: StandalonePaths = {
35
+ dataRoot: join(root, 'data'),
36
+ stateDir: join(root, 'state')
37
+ }
38
+
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const argument = argv[index]
41
+ if (argument !== '--data' && argument !== '--state') throw new Error(`Unknown standalone option: ${argument}`)
42
+ const value = argv[index + 1]
43
+ if (!value || value.startsWith('--')) throw new Error(`${argument} needs a directory path.`)
44
+ paths[argument === '--data' ? 'dataRoot' : 'stateDir'] = resolve(cwd, value)
45
+ index += 1
46
+ }
47
+
48
+ return paths
49
+ }
50
+
51
+ function processEnvironment(overrides: Record<string, string | null>): NodeJS.ProcessEnv {
52
+ const environment = { ...process.env }
53
+ for (const [name, value] of Object.entries(overrides)) {
54
+ if (value === null) delete environment[name]
55
+ else environment[name] = value
56
+ }
57
+ return environment
58
+ }
59
+
60
+ function exitCode(code: number | null, signal: NodeJS.Signals | null): number {
61
+ if (code !== null) return code
62
+ if (signal === 'SIGINT') return 130
63
+ if (signal === 'SIGTERM') return 143
64
+ if (signal === 'SIGHUP') return 129
65
+ return 1
66
+ }
67
+
68
+ function spawnPi(launch: TerminalLaunch, cwd: string, spawn = nodeSpawn): Promise<number> {
69
+ return new Promise((resolveExit, reject) => {
70
+ const child = spawn(launch.program, launch.args, {
71
+ cwd,
72
+ env: processEnvironment(launch.env),
73
+ stdio: 'inherit'
74
+ })
75
+ let settled = false
76
+ const cleanup = (): void => {
77
+ process.off('SIGINT', onInterrupt)
78
+ process.off('SIGTERM', onTerminate)
79
+ process.off('SIGHUP', onHangup)
80
+ }
81
+ const finish = (action: () => void): void => {
82
+ if (settled) return
83
+ settled = true
84
+ cleanup()
85
+ action()
86
+ }
87
+ // Ctrl+C is delivered to both processes in the foreground process group. Keep this host
88
+ // alive while Pi handles it so the account credentials and harness socket are cleaned up.
89
+ const onInterrupt = (): void => undefined
90
+ const onTerminate = (): void => {
91
+ child.kill('SIGTERM')
92
+ }
93
+ const onHangup = (): void => undefined
94
+
95
+ process.on('SIGINT', onInterrupt)
96
+ process.on('SIGTERM', onTerminate)
97
+ process.on('SIGHUP', onHangup)
98
+ child.once('error', (error) => finish(() => reject(error)))
99
+ child.once('exit', (code, signal) => finish(() => resolveExit(exitCode(code, signal))))
100
+ })
101
+ }
102
+
103
+ export async function runStandaloneTerminal(argv: string[], dependencies: StandaloneDependencies = {}): Promise<number> {
104
+ if (!(dependencies.stdinIsTTY ?? process.stdin.isTTY) || !(dependencies.stdoutIsTTY ?? process.stdout.isTTY)) {
105
+ throw new Error('The interactive Harness terminal needs a TTY. Run it from a terminal, or use `xingular-harness serve` for a host process.')
106
+ }
107
+
108
+ const cwd = resolve(dependencies.cwd ?? process.cwd())
109
+ const paths = parseStandaloneOptions(argv, dependencies.home, cwd)
110
+ const makeHarness = dependencies.createHarness ?? createHarness
111
+ const harness = makeHarness({ dataRoot: paths.dataRoot, stateDir: paths.stateDir, productName: 'Xingular Harness' })
112
+ const sessionId = (dependencies.createSessionId ?? randomUUID)()
113
+
114
+ try {
115
+ await harness.ready
116
+ if (harness.accounts.overview().accounts.length === 0) harness.accounts.create('Default', 'subscription')
117
+
118
+ const cwdHash = createHash('sha256').update(cwd).digest('hex')
119
+ const sessionFolder = join(paths.dataRoot, 'sessions', 'terminal', cwdHash)
120
+ const launch = await harness.terminals.prepare({ sessionId, cwd, sessionFolder })
121
+ return await spawnPi(launch, cwd, dependencies.spawn)
122
+ } finally {
123
+ await harness.terminals.dispose(sessionId)
124
+ await harness.close()
125
+ }
126
+ }