@xingular/harness 0.1.3 → 0.1.5

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.
@@ -1,9 +1,9 @@
1
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'
2
+ // a terminal, with its extension and the `xagent-wiki` / `xagent-mcp` commands) reaches the
3
+ // session it belongs to. Owner-only Unix socket (a named pipe on Windows), newline-delimited JSON,
4
+ // one request per connection. Each terminal session holds a random token bound to its caller, its
5
+ // connected-apps grant, and its controller; the token stops working when the terminal ends.
6
+ import { createHash, randomBytes } from 'node:crypto'
7
7
  import { chmodSync, mkdirSync, rmSync, statSync } from 'node:fs'
8
8
  import { createServer, type Server, type Socket } from 'node:net'
9
9
  import { tmpdir } from 'node:os'
@@ -42,16 +42,32 @@ const MAX_REQUEST_BYTES = 1024 * 1024
42
42
  /** macOS refuses a socket path longer than 103 bytes. */
43
43
  const MAX_SOCKET_PATH = 100
44
44
 
45
+ /**
46
+ * Windows has no socket files for Node's `net`: it listens on a named pipe, which lives in a
47
+ * machine-wide namespace. A random name keeps another local process from guessing it and standing
48
+ * in front of the session; the token still decides what a caller may do.
49
+ */
45
50
  export function socketPath(stateDir: string): string {
51
+ if (process.platform === 'win32') return `\\\\.\\pipe\\xingular-harness-${randomBytes(12).toString('hex')}`
46
52
  const path = join(stateDir, 'run', 'harness.sock')
47
53
  if (Buffer.byteLength(path) <= MAX_SOCKET_PATH) return path
48
54
  return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
49
55
  }
50
56
 
57
+ /** A named pipe has no folder, file mode, or owner to check or clean up. */
58
+ const isPipePath = (path: string): boolean => path.startsWith('\\\\.\\pipe\\')
59
+
51
60
  /** Names the folder and the user when the operating system refuses the socket, so the cause shows. */
52
61
  function refusedBind(path: string, error: unknown): unknown {
53
62
  const code = (error as { code?: unknown } | null)?.code
54
63
  if (code !== 'EACCES' && code !== 'EPERM') return error
64
+ if (isPipePath(path)) {
65
+ return new HarnessError(
66
+ 'session_socket_denied',
67
+ `The session pipe ${path} could not be created (${code}).`,
68
+ 'Start the harness again from your own user session. If it keeps failing, report this code and the Windows version.'
69
+ )
70
+ }
55
71
  let folder: string
56
72
  try {
57
73
  const stat = statSync(dirname(path))
@@ -169,18 +185,22 @@ export class SessionSocket {
169
185
  /** Binds the socket once; later calls wait for the same bind. */
170
186
  listen(): Promise<void> {
171
187
  this.listening ??= new Promise<void>((resolve, reject) => {
172
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
173
- rmSync(this.path, { force: true })
188
+ if (!isPipePath(this.path)) {
189
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
190
+ rmSync(this.path, { force: true })
191
+ }
174
192
  const server = createServer((client) => this.serve(client))
175
193
  server.once('error', (error) => {
176
194
  this.listening = null
177
195
  reject(refusedBind(this.path, error))
178
196
  })
179
197
  server.listen(this.path, () => {
180
- try {
181
- chmodSync(this.path, 0o600)
182
- } catch (error) {
183
- log('session socket permissions', error instanceof Error ? error.message : String(error))
198
+ if (!isPipePath(this.path)) {
199
+ try {
200
+ chmodSync(this.path, 0o600)
201
+ } catch (error) {
202
+ log('session socket permissions', error instanceof Error ? error.message : String(error))
203
+ }
184
204
  }
185
205
  this.server = server
186
206
  resolve()
@@ -196,6 +216,6 @@ export class SessionSocket {
196
216
  this.listening = null
197
217
  for (const client of this.clients) client.destroy()
198
218
  if (server) await new Promise<void>((resolve) => server.close(() => resolve()))
199
- rmSync(this.path, { force: true })
219
+ if (!isPipePath(this.path)) rmSync(this.path, { force: true })
200
220
  }
201
221
  }
@@ -117,4 +117,14 @@ describe('standalone terminal startup', () => {
117
117
  expect(existsSync(socketPath(harness.stateDir))).toBe(false)
118
118
  expect(harness.terminals.has('failed')).toBe(false)
119
119
  })
120
+
121
+ it('explains a missing Pi executable in terms of the terminal launch', async () => {
122
+ const home = temporaryDirectory('harness-standalone-enoent-')
123
+ const cwd = temporaryDirectory('harness-cwd-enoent-')
124
+ const error = Object.assign(new Error('spawn /opt/pi/bin/pi ENOENT'), { code: 'ENOENT' })
125
+ const spawn = fakeSpawn(() => undefined, { error })
126
+
127
+ await expect(runStandaloneTerminal([], { home, cwd, stdinIsTTY: true, stdoutIsTTY: true, createSessionId: () => 'missing-pi', createHarness: harnessFactory, spawn }))
128
+ .rejects.toThrow(/Could not start Pi terminal.*Pi executable.*\/opt\/pi\/bin\/pi/)
129
+ })
120
130
  })
@@ -73,6 +73,16 @@ function exitCode(code: number | null, signal: NodeJS.Signals | null): number {
73
73
  return 1
74
74
  }
75
75
 
76
+ function spawnError(error: Error, launch: TerminalLaunch): Error {
77
+ const code = (error as NodeJS.ErrnoException).code
78
+ if (code !== 'ENOENT' && code !== 'EACCES' && code !== 'EPERM') return error
79
+ if (launch.program === process.execPath || launch.env.XINGULAR_HARNESS_NODE === launch.program) {
80
+ return new Error(`Could not start the Pi terminal because Node could not run "${launch.args[0] ?? 'the bundled Pi CLI'}" (${code}). Verify Node and the bundled Pi package are installed.`, { cause: error })
81
+ }
82
+ const problem = code === 'ENOENT' ? 'was not found' : `could not start (${code})`
83
+ 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 })
84
+ }
85
+
76
86
  function spawnPi(launch: TerminalLaunch, cwd: string, spawn = nodeSpawn): Promise<number> {
77
87
  return new Promise((resolveExit, reject) => {
78
88
  const child = spawn(launch.program, launch.args, {
@@ -103,7 +113,7 @@ function spawnPi(launch: TerminalLaunch, cwd: string, spawn = nodeSpawn): Promis
103
113
  process.on('SIGINT', onInterrupt)
104
114
  process.on('SIGTERM', onTerminate)
105
115
  process.on('SIGHUP', onHangup)
106
- child.once('error', (error) => finish(() => reject(error)))
116
+ child.once('error', (error) => finish(() => reject(spawnError(error, launch))))
107
117
  child.once('exit', (code, signal) => finish(() => resolveExit(exitCode(code, signal))))
108
118
  })
109
119
  }
@@ -1,4 +1,5 @@
1
- import { mkdtempSync, readFileSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { spawnSync } from 'node:child_process'
2
3
  import { connect } from 'node:net'
3
4
  import { tmpdir } from 'node:os'
4
5
  import { join } from 'node:path'
@@ -24,6 +25,10 @@ afterEach(async () => {
24
25
  async function setup(policy: ToolPolicy = {}) {
25
26
  const root = rootWithProject()
26
27
  const harness = createHarness({ dataRoot: root, stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), policy, piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' } })
28
+ const legacyBin = join(harness.stateDir, 'run', 'bin')
29
+ mkdirSync(legacyBin, { recursive: true })
30
+ writeFileSync(join(legacyBin, 'agentx-wiki'), 'old wrapper')
31
+ writeFileSync(join(legacyBin, 'agentx-mcp'), 'old wrapper')
27
32
  open.push(harness)
28
33
  const account = harness.accounts.create('Main', 'apiKey').accounts[0]
29
34
  const events: RunEvent[] = []
@@ -59,7 +64,7 @@ describe('a pi terminal session', () => {
59
64
  expect(launch.accountId).toBe(account.id)
60
65
  expect(launch.args[0]).toBe('--append-system-prompt')
61
66
  expect(launch.args[1]).toContain('# Xingular Harness context')
62
- expect(launch.args[1]).toContain('agentx-wiki')
67
+ expect(launch.args[1]).toContain('xagent-wiki')
63
68
  // Pi's own discovery is off; its command's built-in extensions are named, and the harness's comes last.
64
69
  expect(launch.args.slice(2)).toEqual([
65
70
  '--model', 'chosen-model', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes',
@@ -68,13 +73,36 @@ describe('a pi terminal session', () => {
68
73
  ])
69
74
  expect(launch.env.PI_CODING_AGENT_DIR).toBe(join(harness.accounts.dir, account.id, 'home', 'pi'))
70
75
  expect(String(launch.env.PATH).split(':')[0]).toBe(join(harness.stateDir, 'run', 'bin'))
71
- expect(readFileSync(join(harness.stateDir, 'run', 'bin', 'agentx-wiki'), 'utf8')).toBe("#!/bin/sh\nexec '/opt/node' '/opt/harness/cli.js' wiki \"$@\"\n")
72
- expect(readFileSync(join(harness.stateDir, 'run', 'bin', 'agentx-mcp'), 'utf8')).toContain("'/opt/harness/cli.js' mcp")
76
+ expect(readFileSync(join(harness.stateDir, 'run', 'bin', 'xagent-wiki'), 'utf8')).toBe("#!/bin/sh\nexec '/opt/node' '/opt/harness/cli.js' wiki \"$@\"\n")
77
+ expect(readFileSync(join(harness.stateDir, 'run', 'bin', 'xagent-mcp'), 'utf8')).toContain("'/opt/harness/cli.js' mcp")
78
+ expect(existsSync(join(harness.stateDir, 'run', 'bin', 'agentx-wiki'))).toBe(false)
79
+ expect(existsSync(join(harness.stateDir, 'run', 'bin', 'agentx-mcp'))).toBe(false)
73
80
  // The session keeps the pack it started with.
74
81
  expect(JSON.parse(readFileSync(join(sessionFolder, 'context.json'), 'utf8')).profile).toBe('session')
75
82
  expect(root).toBeTruthy()
76
83
  })
77
84
 
85
+ it.skipIf(process.platform !== 'win32')('starts the resolved Pi CLI through Node without a model or interactive terminal', async () => {
86
+ const root = rootWithProject()
87
+ const stateDir = mkdtempSync(join(tmpdir(), 'hs-pi-smoke-'))
88
+ const harness = createHarness({ dataRoot: root, stateDir, secrets: memorySecretStore() })
89
+ open.push(harness)
90
+ harness.accounts.create('Main', 'subscription')
91
+ const launch = await harness.terminals.prepare({ sessionId: 'pi-smoke', cwd: root, sessionFolder: join(root, 'sessions', 'pi-smoke') })
92
+ const cli = launch.args[0]
93
+ const pathKeys = Object.keys(launch.env).filter((name) => name.toLowerCase() === 'path')
94
+
95
+ expect(launch.program).toBe(process.execPath)
96
+ expect(pathKeys).toHaveLength(1)
97
+ expect(launch.env[pathKeys[0]]).toContain(';')
98
+ expect(launch.env.XINGULAR_HARNESS_NODE).toBe(process.execPath)
99
+ expect(readFileSync(join(stateDir, 'run', 'bin', 'xagent-wiki.cmd'), 'utf8')).toContain(' wiki %*\r\n')
100
+ const result = spawnSync(launch.program, [cli, '--help'], { cwd: root, encoding: 'utf8', timeout: 20_000 })
101
+ expect(result.error).toBeUndefined()
102
+ expect(result.status).toBe(0)
103
+ expect(`${result.stdout}${result.stderr}`).toMatch(/usage|options|pi coding agent/i)
104
+ })
105
+
78
106
  it('passes what the harness found as paths, the enabled registry items with them, and its own extension last', async () => {
79
107
  const root = rootWithProject()
80
108
  writeFile(join(root, 'registry/skills/release/SKILL.md'), '# Release\n')
@@ -284,7 +312,7 @@ describe('the session socket', () => {
284
312
  })
285
313
  })
286
314
 
287
- describe('agentx-wiki command', () => {
315
+ describe('xagent-wiki command', () => {
288
316
  it('maps commands onto memory and Wiki calls', () => {
289
317
  expect(parseCli(['search', 'deploy', 'steps', '--kind', 'page'])).toEqual({ kind: 'call', call: 'wiki.search', params: { query: 'deploy steps', kinds: ['page'] } })
290
318
  expect(parseCli(['read', '/projects/app/arch'])).toEqual({ kind: 'call', call: 'wiki.read', params: { id: '/projects/app/arch' } })
@@ -3,7 +3,7 @@
3
3
  // skills, the session socket), then follows the process through its extension. It never owns
4
4
  // the terminal: the application spawns the process from the spec returned here and reports when
5
5
  // it exits.
6
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
7
7
  import { dirname, join } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
 
@@ -15,7 +15,7 @@ import { log } from '../core/bus'
15
15
  import type { HarnessEnv } from '../core/env'
16
16
  import { HarnessError, invalid } from '../core/errors'
17
17
  import { newId, now } from '../core/fs'
18
- import { findBinary, searchPath } from '../core/path'
18
+ import { prependSearchPath, searchPath } from '../core/path'
19
19
  import type { BrokerSession, ConnectedApps } from '../mcp/service'
20
20
  import type { CallerContext } from '../memory/caller'
21
21
  import { dispatch } from '../memory/dispatch'
@@ -33,6 +33,7 @@ import type { ToolSpec } from '../tools/types'
33
33
  import { APPS_COMMAND, WIKI_COMMAND } from './cli'
34
34
  import { SOCKET_ENV, TOKEN_ENV } from './client'
35
35
  import { TerminalController, type PolicyReply } from './controller'
36
+ import { resolvePiCommand, terminalCommandFiles } from './launch'
36
37
  import { SessionSocket, socketPath } from './socket'
37
38
 
38
39
  /** The files a terminal session needs on disk, outside this process. */
@@ -102,7 +103,7 @@ export interface TerminalsOptions {
102
103
  /** Lists plugins and applies a person's choices, for the session's `/plugins` command. */
103
104
  pluginControl?: PluginControl
104
105
  assets?: TerminalAssets
105
- /** The `pi` executable. Defaults to the one on the search path. */
106
+ /** The `pi` executable. Defaults to the PATH command on POSIX and the bundled CLI on Windows. */
106
107
  program?: () => string | null
107
108
  }
108
109
 
@@ -134,19 +135,27 @@ export class Terminals {
134
135
  private ensureCommands(): string {
135
136
  const bin = join(this.options.stateDir, 'run', 'bin')
136
137
  mkdirSync(bin, { recursive: true })
137
- const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
138
- for (const [name, mode] of [[WIKI_COMMAND, 'wiki'], [APPS_COMMAND, 'mcp']] as const) {
139
- const wrapper = join(bin, name)
140
- const text = `#!/bin/sh\nexec ${quote(this.assets.node)} ${quote(this.assets.cli)} ${mode} "$@"\n`
141
- let current: string | null = null
138
+ for (const name of ['agentx-wiki', 'agentx-mcp', 'agentx-wiki.cmd', 'agentx-mcp.cmd']) {
139
+ const path = join(bin, name)
142
140
  try {
143
- current = readFileSync(wrapper, 'utf8')
141
+ if (lstatSync(path).isFile()) unlinkSync(path)
144
142
  } catch {
145
- current = null
143
+ // These names belonged to the previous generated wrappers.
146
144
  }
147
- if (current !== text) {
148
- writeFileSync(wrapper, text)
149
- chmodSync(wrapper, 0o755)
145
+ }
146
+ for (const [name, mode] of [[WIKI_COMMAND, 'wiki'], [APPS_COMMAND, 'mcp']] as const) {
147
+ for (const file of terminalCommandFiles(process.platform, this.assets.node, this.assets.cli, name, mode)) {
148
+ const path = join(bin, file.name)
149
+ let current: string | null = null
150
+ try {
151
+ current = readFileSync(path, 'utf8')
152
+ } catch {
153
+ current = null
154
+ }
155
+ if (current !== file.content) {
156
+ writeFileSync(path, file.content)
157
+ chmodSync(path, 0o755)
158
+ }
150
159
  }
151
160
  }
152
161
  return bin
@@ -298,8 +307,7 @@ export class Terminals {
298
307
  const { root } = env
299
308
  if (!input?.sessionId || !input.cwd || !input.sessionFolder) throw invalid('A terminal session needs an id, a working folder, and its own folder.')
300
309
  await this.dispose(input.sessionId)
301
- const program = this.options.program ? this.options.program() : findBinary('pi')
302
- if (!program) throw new HarnessError('harness_unavailable', 'The Pi CLI was not found on PATH.')
310
+ const command = resolvePiCommand({ platform: process.platform, node: this.assets.node, programOverride: this.options.program })
303
311
  // A pinned account is used as it is; it is never exchanged for another one.
304
312
  const account = input.accountId ? accounts.find(input.accountId) : accounts.resolve(null, null)
305
313
  const launch = launchEnv(accounts.dir, account)
@@ -343,7 +351,7 @@ export class Terminals {
343
351
  live.token = token
344
352
  this.sessions.set(sessionId, live)
345
353
 
346
- const args = ['--append-system-prompt', render(env, pack)]
354
+ const args = [...command.argsPrefix, '--append-system-prompt', render(env, pack)]
347
355
  if (input.model) args.push('--model', input.model)
348
356
  if (loading) {
349
357
  // Pi's own discovery is off: the terminal loads what the harness found and nothing else.
@@ -358,9 +366,15 @@ export class Terminals {
358
366
  args.push('--extension', this.assets.piExtension, '--session-dir', this.historyDir(input))
359
367
  if (input.nativeSessionId) args.push('--session', input.nativeSessionId)
360
368
  return {
361
- program,
369
+ program: command.program,
362
370
  args,
363
- env: { ...launch, PATH: `${this.ensureCommands()}:${searchPath()}`, [SOCKET_ENV]: this.socket.path, [TOKEN_ENV]: token },
371
+ env: {
372
+ ...launch,
373
+ ...(process.platform === 'win32' ? { XINGULAR_HARNESS_NODE: this.assets.node, XINGULAR_HARNESS_CLI: this.assets.cli } : {}),
374
+ ...prependSearchPath(this.ensureCommands(), searchPath()),
375
+ [SOCKET_ENV]: this.socket.path,
376
+ [TOKEN_ENV]: token
377
+ },
364
378
  accountId: account.id,
365
379
  nativeSessionId: input.nativeSessionId ?? null
366
380
  }
@@ -1,5 +1,5 @@
1
1
  // The memory and Wiki tools a session gets, defined once. An SDK session gets them as Pi tools;
2
- // a terminal session gets them from the `agentx-wiki` command or the bundled MCP server. All of
2
+ // a terminal session gets them from the `xagent-wiki` command or the bundled MCP server. All of
3
3
  // them reach the same calls.
4
4
 
5
5
  export type WikiCall =
@@ -116,7 +116,7 @@ export const WIKI_TOOLS: WikiToolDefinition[] = [
116
116
  name: 'wiki_read',
117
117
  call: 'wiki.read',
118
118
  label: 'Read Wiki entry',
119
- description: 'Read one Wiki page, fact, skill, rule, or prompt in full, with its sources, links, and backlinks. Pass an id (arch, skills/review) or a Wiki path (/projects/agent-x/arch).',
119
+ description: 'Read one Wiki page, fact, skill, rule, or prompt in full, with its sources, links, and backlinks. Pass an id (arch, skills/review) or a Wiki path (/projects/xagent/arch).',
120
120
  parameters: { type: 'object', properties: { id: text('Entry id or Wiki path'), revision: text('Optional pinned revision selected for this reply'), offset: { type: 'integer', minimum: 0, description: 'Character offset when reading a pinned revision; use nextOffset to continue.' } }, required: ['id'] }
121
121
  },
122
122
  {
@@ -3,10 +3,13 @@
3
3
  import type { CallerContext } from '../memory/caller'
4
4
  import type { CapabilitySet } from '../capabilities/types'
5
5
  import type { ActiveRun } from '../sessions/registry'
6
+ import type { ToolAttachmentRef } from '../sessions/contracts'
6
7
 
7
8
  export interface ToolResult {
8
9
  text: string
9
10
  image?: { data: string; mimeType: string }
11
+ /** References to content stored by the provider; bytes never enter the run-event stream. */
12
+ attachments?: ToolAttachmentRef[]
10
13
  }
11
14
 
12
15
  export interface ToolSpec {