@xingular/harness 0.1.4 → 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.
- package/README.md +7 -3
- package/dist/cli.js +249 -117
- package/package.json +1 -1
- package/src/accounts/accounts.test.ts +1 -1
- package/src/accounts/env.test.ts +1 -1
- package/src/accounts/env.ts +1 -1
- package/src/core/path.test.ts +14 -0
- package/src/core/path.ts +56 -22
- package/src/mcp/connections.ts +2 -2
- package/src/mcp/manager.ts +2 -2
- package/src/memory/blocks.ts +2 -2
- package/src/memory/caller.ts +1 -1
- package/src/memory/dispatch.ts +1 -1
- package/src/memory/facts.ts +1 -1
- package/src/memory/store.ts +1 -1
- package/src/prompt/compile.test.ts +2 -2
- package/src/prompt/compile.ts +2 -2
- package/src/sessions/contracts.ts +12 -1
- package/src/sessions/runner.ts +16 -3
- package/src/sessions/sessions.test.ts +21 -0
- package/src/terminal/cli.ts +5 -5
- package/src/terminal/client.ts +2 -2
- package/src/terminal/launch.test.ts +34 -0
- package/src/terminal/launch.ts +88 -0
- package/src/terminal/socket.ts +1 -1
- package/src/terminal/standalone.test.ts +10 -0
- package/src/terminal/standalone.ts +11 -1
- package/src/terminal/terminal.test.ts +33 -5
- package/src/terminal/terminals.ts +32 -18
- package/src/tools/definitions.ts +2 -2
- package/src/tools/types.ts +3 -0
|
@@ -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('
|
|
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', '
|
|
72
|
-
expect(readFileSync(join(harness.stateDir, 'run', 'bin', '
|
|
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('
|
|
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 {
|
|
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
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
141
|
+
if (lstatSync(path).isFile()) unlinkSync(path)
|
|
144
142
|
} catch {
|
|
145
|
-
|
|
143
|
+
// These names belonged to the previous generated wrappers.
|
|
146
144
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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: {
|
|
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
|
}
|
package/src/tools/definitions.ts
CHANGED
|
@@ -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 `
|
|
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/
|
|
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
|
{
|
package/src/tools/types.ts
CHANGED
|
@@ -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 {
|