@xingular/harness 0.1.4 → 0.1.6

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,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 {