@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.
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingular/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
|
|
6
6
|
"repository": {
|
|
@@ -200,7 +200,7 @@ test('existing account config moves into the shared folder, newest wins', async
|
|
|
200
200
|
// acc2's settings are newer than the shared copy acc1 just moved in, so they win.
|
|
201
201
|
const second = ensureHome(dir, { id: 'acc2' })
|
|
202
202
|
expect(readFileSync(join(sharedDir(dir), 'settings.json'), 'utf8')).toBe('{"from":"acc2"}')
|
|
203
|
-
expect(readdirSync(sharedDir(dir)).some((name) => name.startsWith('settings.json.
|
|
203
|
+
expect(readdirSync(sharedDir(dir)).some((name) => name.startsWith('settings.json.xagent-backup-'))).toBe(true)
|
|
204
204
|
expect(lstatSync(join(second, 'settings.json')).isSymbolicLink()).toBe(true)
|
|
205
205
|
})
|
|
206
206
|
|
package/src/accounts/env.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { configDir, ensureSharedDir, linkSharedEntries, sharedDir, type EntryLin
|
|
|
7
7
|
|
|
8
8
|
const temp = () => mkdtempSync(join(tmpdir(), 'harness-env-'))
|
|
9
9
|
const refused = (code = 'EPERM') => Object.assign(new Error(`${code}: operation not permitted, symlink`), { code })
|
|
10
|
-
const backups = (folder: string) => readdirSync(folder).filter((name) => name.includes('agentx-backup'))
|
|
10
|
+
const backups = (folder: string) => readdirSync(folder).filter((name) => name.includes('xagent-backup') || name.includes('agentx-backup'))
|
|
11
11
|
|
|
12
12
|
// A Windows account without the symlink privilege: file symlinks are refused, junctions and hard links work.
|
|
13
13
|
const windowsLike: EntryLinker = {
|
package/src/accounts/env.ts
CHANGED
|
@@ -107,7 +107,7 @@ const modified = (path: string): number => {
|
|
|
107
107
|
|
|
108
108
|
/** Moves a real file or folder aside so the shared link can take its place; returns where it went. */
|
|
109
109
|
function backUp(path: string): string {
|
|
110
|
-
const backup = join(dirname(path), `${basename(path)}.
|
|
110
|
+
const backup = join(dirname(path), `${basename(path)}.xagent-backup-${Math.floor(Date.now() / 1000)}`)
|
|
111
111
|
renameSync(path, backup)
|
|
112
112
|
return backup
|
|
113
113
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { prependSearchPath } from './path'
|
|
4
|
+
|
|
5
|
+
describe('search paths', () => {
|
|
6
|
+
it('uses Windows separators and keeps the inherited PATH key casing', () => {
|
|
7
|
+
expect(prependSearchPath('C:\\Harness\\bin', 'C:\\Windows\\System32;C:\\Node', { Path: 'C:\\Windows\\System32;C:\\Node' }, 'win32'))
|
|
8
|
+
.toEqual({ Path: 'C:\\Harness\\bin;C:\\Windows\\System32;C:\\Node' })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('uses the platform default key when the environment has no PATH entry', () => {
|
|
12
|
+
expect(prependSearchPath('/harness/bin', '/usr/bin', {}, 'linux')).toEqual({ PATH: '/harness/bin:/usr/bin' })
|
|
13
|
+
})
|
|
14
|
+
})
|
package/src/core/path.ts
CHANGED
|
@@ -1,33 +1,57 @@
|
|
|
1
|
-
// Where executables are found.
|
|
2
|
-
// shell's PATH is resolved once
|
|
1
|
+
// Where executables are found. POSIX desktop apps may start with a minimal PATH, so the login
|
|
2
|
+
// shell's PATH is resolved once. Windows keeps the inherited PATH and its native separator.
|
|
3
3
|
import { execFileSync } from 'node:child_process'
|
|
4
4
|
import { accessSync, constants, statSync } from 'node:fs'
|
|
5
5
|
import { homedir } from 'node:os'
|
|
6
|
-
import { delimiter, isAbsolute, join } from 'node:path'
|
|
6
|
+
import { delimiter, extname, isAbsolute, join } from 'node:path'
|
|
7
7
|
|
|
8
|
-
let resolved: string | null = null
|
|
8
|
+
let resolved: { platform: NodeJS.Platform; inherited: string; path: string } | null = null
|
|
9
9
|
|
|
10
10
|
export function searchPath(): string {
|
|
11
|
-
|
|
11
|
+
const platform = process.platform
|
|
12
|
+
const pathKey = pathVariableName(process.env)
|
|
13
|
+
const inherited = process.env[pathKey] ?? ''
|
|
14
|
+
if (resolved?.platform === platform && resolved.inherited === inherited) return resolved.path
|
|
15
|
+
const separator = pathSeparator(platform)
|
|
12
16
|
const entries: string[] = []
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
if (platform !== 'win32') {
|
|
18
|
+
try {
|
|
19
|
+
const output = execFileSync(process.env.SHELL || '/bin/zsh', ['-ilc', 'printf %s "$PATH"'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
20
|
+
// Interactive shells may print banners; the PATH is the last line.
|
|
21
|
+
entries.push(...(output.split('\n').filter(Boolean).at(-1) ?? '').split(separator))
|
|
22
|
+
} catch {
|
|
23
|
+
// The inherited PATH and the usual folders still apply.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
entries.push(...inherited.split(separator))
|
|
27
|
+
if (platform !== 'win32') {
|
|
28
|
+
const home = homedir()
|
|
29
|
+
if (home) entries.push(...['.local/bin', '.bun/bin', '.npm-global/bin', '.cargo/bin'].map((folder) => join(home, folder)))
|
|
30
|
+
entries.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin')
|
|
19
31
|
}
|
|
20
|
-
entries.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
const path = [...new Set(entries.filter(Boolean))].join(separator)
|
|
33
|
+
resolved = { platform, inherited, path }
|
|
34
|
+
return path
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The inherited PATH key, preserving Windows' existing capitalization. */
|
|
38
|
+
export function pathVariableName(environment: NodeJS.ProcessEnv): string {
|
|
39
|
+
return Object.keys(environment).find((name) => name.toLowerCase() === 'path') ?? 'PATH'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A child environment with a directory prepended using the selected platform's PATH rules. */
|
|
43
|
+
export function prependSearchPath(directory: string, existing: string, environment: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): Record<string, string> {
|
|
44
|
+
const values = [directory, existing].filter(Boolean)
|
|
45
|
+
return { [pathVariableName(environment)]: values.join(pathSeparator(platform)) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pathSeparator(platform: NodeJS.Platform): string {
|
|
49
|
+
return platform === 'win32' ? ';' : delimiter
|
|
26
50
|
}
|
|
27
51
|
|
|
28
52
|
function isExecutable(path: string): boolean {
|
|
29
53
|
try {
|
|
30
|
-
accessSync(path, constants.X_OK)
|
|
54
|
+
accessSync(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK)
|
|
31
55
|
return statSync(path).isFile()
|
|
32
56
|
} catch {
|
|
33
57
|
return false
|
|
@@ -36,10 +60,20 @@ function isExecutable(path: string): boolean {
|
|
|
36
60
|
|
|
37
61
|
/** The executable `name` resolves to: itself when absolute, else the first match on the search path. */
|
|
38
62
|
export function findBinary(name: string): string | null {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
const platform = process.platform
|
|
64
|
+
const suffixes = platform === 'win32' && !extname(name)
|
|
65
|
+
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
66
|
+
: []
|
|
67
|
+
const names = [name, ...suffixes.map((suffix) => `${name}${suffix}`)]
|
|
68
|
+
if (isAbsolute(name)) {
|
|
69
|
+
for (const candidate of names) if (isExecutable(candidate)) return candidate
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
for (const directory of searchPath().split(pathSeparator(platform))) {
|
|
73
|
+
for (const candidateName of names) {
|
|
74
|
+
const candidate = join(directory, candidateName)
|
|
75
|
+
if (isExecutable(candidate)) return candidate
|
|
76
|
+
}
|
|
43
77
|
}
|
|
44
78
|
return null
|
|
45
79
|
}
|
package/src/mcp/connections.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { secretKey, type SecretStore } from '../accounts/secrets'
|
|
|
7
7
|
import type { Bus } from '../core/bus'
|
|
8
8
|
import { HarnessError, invalid, notFound } from '../core/errors'
|
|
9
9
|
import { newId, readJson, safeId, writeJson } from '../core/fs'
|
|
10
|
-
import { findBinary, searchPath } from '../core/path'
|
|
10
|
+
import { findBinary, pathVariableName, searchPath } from '../core/path'
|
|
11
11
|
import { isDir } from '../memory/store'
|
|
12
12
|
import type { RunEvent } from '../sessions/contracts'
|
|
13
13
|
import * as attachments from '../plugins/attachments'
|
|
@@ -450,7 +450,7 @@ export class Connections {
|
|
|
450
450
|
}
|
|
451
451
|
let command = findBinary(connection.command) ?? (isAbsolute(connection.command) ? connection.command : null)
|
|
452
452
|
if (!command) throw new HarnessError('mcp_unavailable', 'MCP executable was not found. Select its absolute path.')
|
|
453
|
-
const env: Record<string, string> = {
|
|
453
|
+
const env: Record<string, string> = { [pathVariableName(process.env)]: searchPath() }
|
|
454
454
|
for (const key of ['HOME', 'TMPDIR', 'LANG']) if (process.env[key]) env[key] = process.env[key]!
|
|
455
455
|
for (const key of connection.envKeys) {
|
|
456
456
|
const value = await this.secret(connection.id, key)
|
package/src/mcp/manager.ts
CHANGED
|
@@ -62,7 +62,7 @@ export class McpManager {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
private async connectStdio(config: StdioConfig) {
|
|
65
|
-
const client = new Client({ name: '
|
|
65
|
+
const client = new Client({ name: 'xagent', version: '0.1.0' })
|
|
66
66
|
const transport = new StdioClientTransport({ command: config.command, args: config.args, cwd: config.cwd ?? undefined, env: config.env, stderr: 'pipe' })
|
|
67
67
|
try {
|
|
68
68
|
await client.connect(transport, { timeout: 30000 })
|
|
@@ -82,7 +82,7 @@ export class McpManager {
|
|
|
82
82
|
const options = { authProvider, requestInit: { headers: config.headers } }
|
|
83
83
|
// Servers that predate Streamable HTTP only answer on SSE.
|
|
84
84
|
for (const Transport of [StreamableHTTPClientTransport, SSEClientTransport]) {
|
|
85
|
-
const client = new Client({ name: '
|
|
85
|
+
const client = new Client({ name: 'xagent', version: '0.1.0' })
|
|
86
86
|
const transport = new Transport(new URL(config.url), options)
|
|
87
87
|
try {
|
|
88
88
|
await client.connect(transport, { timeout: 30000 })
|
package/src/memory/blocks.ts
CHANGED
|
@@ -367,13 +367,13 @@ export function undoLast(wiki: string, blockId: string): Block {
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
/**
|
|
370
|
-
* The memory-blocks section of a compiled prompt. `cli` names the `
|
|
370
|
+
* The memory-blocks section of a compiled prompt. `cli` names the `xagent-wiki` commands in
|
|
371
371
|
* place of the tools, for sessions that have the command and not the tools.
|
|
372
372
|
*/
|
|
373
373
|
export function render(blocks: readonly Block[], caller: Caller, cli: boolean): string {
|
|
374
374
|
if (!blocks.length) return ''
|
|
375
375
|
const how = cli
|
|
376
|
-
? '`
|
|
376
|
+
? '`xagent-wiki block append <label> <text>`, `block replace <label> --old <text> --new <text>`, or `block rethink <label> <text>`'
|
|
377
377
|
: 'memory_append, memory_replace, or memory_rethink'
|
|
378
378
|
return `## Memory blocks\n\nThese blocks are your memory that is always in view, and they persist across conversations. When you learn something worth keeping, edit a block you may change with ${how}. Keep each block within its limit.\n${renderItems(blocks, caller)}`
|
|
379
379
|
}
|
package/src/memory/caller.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface CallerContext {
|
|
|
21
21
|
briefProjectIds?: string[] | null
|
|
22
22
|
/** `Chat` for an agent's own conversation, `Code` for a session in a project. */
|
|
23
23
|
actor?: string | null
|
|
24
|
-
/** `cli` when the caller reaches the Wiki through the `
|
|
24
|
+
/** `cli` when the caller reaches the Wiki through the `xagent-wiki` command. */
|
|
25
25
|
surface?: string | null
|
|
26
26
|
}
|
|
27
27
|
|
package/src/memory/dispatch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// One entry point for every memory, recall, and Wiki call a session makes, whichever way it
|
|
2
|
-
// reaches the harness (a Pi tool, the `
|
|
2
|
+
// reaches the harness (a Pi tool, the `xagent-wiki` command, the bundled MCP server).
|
|
3
3
|
import type { Bus } from '../core/bus'
|
|
4
4
|
import type { HarnessEnv } from '../core/env'
|
|
5
5
|
import { HarnessError } from '../core/errors'
|
package/src/memory/facts.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface MemoryFront {
|
|
|
26
26
|
id: string
|
|
27
27
|
title: string
|
|
28
28
|
kind: string
|
|
29
|
-
/** `/` for everything, or a project namespace such as `/projects/
|
|
29
|
+
/** `/` for everything, or a project namespace such as `/projects/xagent`. */
|
|
30
30
|
namespace: string
|
|
31
31
|
sourceIds: string[]
|
|
32
32
|
confidence: string
|
package/src/memory/store.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function ensureProjectNamespace(root: string, slug: string, name: string)
|
|
|
93
93
|
|
|
94
94
|
// ---- Locations ----------------------------------------------------------------
|
|
95
95
|
|
|
96
|
-
/** A page's Wiki path (`/projects/
|
|
96
|
+
/** A page's Wiki path (`/projects/xagent/architecture`) and namespace (`/projects/xagent`). */
|
|
97
97
|
export interface Location {
|
|
98
98
|
path: string
|
|
99
99
|
namespace: string
|
|
@@ -97,8 +97,8 @@ test('a session gets blocks, rules, skills, knowledge and tools', () => {
|
|
|
97
97
|
for (const expected of ['<block label="user"', '<block label="project/xingular-agent"', 'Always run the tests.', 'review (skills/review)', '/projects/xingular-agent/decisions — Decisions', 'Use pnpm', 'App tools and connected apps: Use get_capabilities.']) expect(text).toContain(expected)
|
|
98
98
|
expect(text).not.toContain('Astro')
|
|
99
99
|
const cli = render(env, compile(env, { ...session('p1'), surface: 'cli' }))
|
|
100
|
-
expect(cli).toContain('
|
|
101
|
-
expect(cli).toContain('
|
|
100
|
+
expect(cli).toContain('xagent-wiki search')
|
|
101
|
+
expect(cli).toContain('xagent-wiki block append')
|
|
102
102
|
})
|
|
103
103
|
|
|
104
104
|
test('long rules shorten only the rules section', () => {
|
package/src/prompt/compile.ts
CHANGED
|
@@ -321,7 +321,7 @@ export function toolGroups(context: CallerContext, launch: Launch): OrderedGroup
|
|
|
321
321
|
tools: MEMORY_GROUP,
|
|
322
322
|
order: GROUP_ORDER.Memory,
|
|
323
323
|
guidance: cli
|
|
324
|
-
? 'Memory: `
|
|
324
|
+
? 'Memory: `xagent-wiki block append <label> <text>`, `block replace <label> --old <text> --new <text>`, and `block rethink <label> <text>` edit your memory blocks. `xagent-wiki facts [query]` recalls saved facts. `xagent-wiki history <query>` searches earlier runs and `xagent-wiki history-read <run id>` returns one.'
|
|
325
325
|
: 'Memory: memory_append, memory_replace, and memory_rethink edit your memory blocks; memory_facts recalls saved facts. history_search finds what was said and done in earlier conversations and runs, including a chat that was cleared; history_read returns one run.'
|
|
326
326
|
},
|
|
327
327
|
{
|
|
@@ -329,7 +329,7 @@ export function toolGroups(context: CallerContext, launch: Launch): OrderedGroup
|
|
|
329
329
|
tools: KNOWLEDGE_GROUP,
|
|
330
330
|
order: GROUP_ORDER.Knowledge,
|
|
331
331
|
guidance: cli
|
|
332
|
-
? 'Knowledge: `
|
|
332
|
+
? 'Knowledge: `xagent-wiki search <query>`, `read <id or path>`, `list [namespace]`, `skill [name]`, and `propose <json>` to record durable knowledge. Cite what you use as [id@revision].'
|
|
333
333
|
: 'Knowledge: wiki_search, wiki_read, and wiki_list read the Wiki; skill_load loads a skill\'s steps; wiki_propose records durable knowledge, quoting its evidence (the Wiki validates it and may send it to the Inbox). Cite what you use as [id@revision].'
|
|
334
334
|
},
|
|
335
335
|
{
|
|
@@ -43,6 +43,15 @@ const contextSnapshotPayload = z.object({
|
|
|
43
43
|
contextUpdateTruncated: z.boolean().optional(),
|
|
44
44
|
toolsTruncated: z.boolean().optional()
|
|
45
45
|
})
|
|
46
|
+
/** Generic reference to content a provider stores outside the run-event stream. */
|
|
47
|
+
export const toolAttachmentRefSchema = z.object({
|
|
48
|
+
id: z.string().min(1).max(300),
|
|
49
|
+
name: z.string().min(1).max(500),
|
|
50
|
+
mimeType: z.string().min(1).max(100)
|
|
51
|
+
}).strict()
|
|
52
|
+
export const toolAttachmentRefsSchema = z.array(toolAttachmentRefSchema).max(16)
|
|
53
|
+
export type ToolAttachmentRef = z.infer<typeof toolAttachmentRefSchema>
|
|
54
|
+
|
|
46
55
|
const toolPayload = z.object({
|
|
47
56
|
toolCallId: z.string(),
|
|
48
57
|
name: z.string(),
|
|
@@ -54,7 +63,9 @@ const toolPayload = z.object({
|
|
|
54
63
|
ok: z.boolean().optional(),
|
|
55
64
|
/** On `tool.completed`: how long the call ran, and the size of what it returned to the model. */
|
|
56
65
|
durationMs: z.number().optional(),
|
|
57
|
-
resultChars: z.number().optional()
|
|
66
|
+
resultChars: z.number().optional(),
|
|
67
|
+
/** Opaque attachment references. Content stays outside the run event stream. */
|
|
68
|
+
attachments: toolAttachmentRefsSchema.optional()
|
|
58
69
|
})
|
|
59
70
|
const retryStartedPayload = z.object({ attempt: z.number(), maxAttempts: z.number(), delayMs: z.number(), error: z.string().max(1_000).optional() })
|
|
60
71
|
const retryEndedPayload = z.object({ attempt: z.number(), success: z.boolean(), error: z.string().max(1_000).optional() })
|
package/src/sessions/runner.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Loading, Registered } from '../plugins/plugins'
|
|
|
11
11
|
import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY, type ContextUpdate } from '../prompt/compile'
|
|
12
12
|
import type { RegistryResources } from '../skills/registry'
|
|
13
13
|
import type { ToolSpec } from '../tools/types'
|
|
14
|
-
import type
|
|
14
|
+
import { toolAttachmentRefsSchema, type UsageRequest } from './contracts'
|
|
15
15
|
import { completedReasoningSummary, REASONING_SUMMARY_APIS, requestConciseReasoningSummary } from './reasoningSummaries'
|
|
16
16
|
import { extensionUi, type ExtensionHost } from './extensionUi'
|
|
17
17
|
import { observeResources, registrationHash } from '../plugins/runtime'
|
|
@@ -162,6 +162,14 @@ function evidenceText(value: unknown): string | null {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** Pi keeps provider-only metadata in `details`; only a bounded, generic reference enters the event. */
|
|
166
|
+
function toolAttachmentRefs(result: unknown) {
|
|
167
|
+
const details = (result as { details?: unknown } | null | undefined)?.details
|
|
168
|
+
const refs = details && typeof details === 'object' ? (details as { attachments?: unknown }).attachments : undefined
|
|
169
|
+
const parsed = toolAttachmentRefsSchema.safeParse(refs)
|
|
170
|
+
return parsed.success && parsed.data.length ? parsed.data : undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
165
173
|
function boundedSchema(value: unknown, budget: { remaining: number }, depth = 0): { value: unknown; truncated: boolean } {
|
|
166
174
|
if (budget.remaining <= 0 || depth > 12) return { value: '[truncated]', truncated: true }
|
|
167
175
|
if (typeof value === 'string') {
|
|
@@ -355,12 +363,14 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
|
|
|
355
363
|
const resultChars = resultSize(tool.result)
|
|
356
364
|
const output = evidenceText(tool.result)
|
|
357
365
|
const boundedOutput = output === null ? null : limitedText(output)
|
|
366
|
+
const attachments = toolAttachmentRefs(tool.result)
|
|
358
367
|
run.emit('tool.completed', {
|
|
359
368
|
toolCallId: tool.toolCallId,
|
|
360
369
|
name: tool.toolName,
|
|
361
370
|
ok: !tool.isError,
|
|
362
371
|
...(started === undefined ? {} : { durationMs: Date.now() - started }),
|
|
363
372
|
...(resultChars === null ? {} : { resultChars }),
|
|
373
|
+
...(attachments ? { attachments } : {}),
|
|
364
374
|
...(boundedOutput ? { output: boundedOutput.text, ...(boundedOutput.truncated ? { outputTruncated: true } : {}) } : {})
|
|
365
375
|
})
|
|
366
376
|
break
|
|
@@ -442,7 +452,7 @@ export async function loadResources(pi: typeof import('@earendil-works/pi-coding
|
|
|
442
452
|
agentDir: params.account.configDir,
|
|
443
453
|
// Inline extensions load after the ones on disk, and the gate after all of them, so it sees
|
|
444
454
|
// what every plugin made of a call.
|
|
445
|
-
extensionFactories: [...(params.inline ?? []).map((extension) => ({ name: extension.name, factory: extension.factory as never, hidden: true })), { name: '
|
|
455
|
+
extensionFactories: [...(params.inline ?? []).map((extension) => ({ name: extension.name, factory: extension.factory as never, hidden: true })), { name: 'xagent-approvals', factory: gate as never, hidden: true }],
|
|
446
456
|
additionalSkillPaths: [...(params.resources?.skillPaths ?? []), ...(loading?.skillPaths ?? [])],
|
|
447
457
|
additionalPromptTemplatePaths: [...(params.resources?.promptPaths ?? []), ...(loading?.promptPaths ?? [])],
|
|
448
458
|
// The compiler writes one prompt; nothing else is appended here.
|
|
@@ -511,7 +521,10 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
|
|
|
511
521
|
const result = await spec.execute(input as Record<string, unknown>)
|
|
512
522
|
const output = typeof result === 'string' ? { text: result } : result
|
|
513
523
|
if (output.image && !supportsImageInput) throw new HarnessError('image_input_unsupported', 'This model cannot inspect images. Choose a model that supports image input.')
|
|
514
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never,
|
|
526
|
+
details: output.attachments?.length ? { attachments: output.attachments } : {}
|
|
527
|
+
}
|
|
515
528
|
}
|
|
516
529
|
})
|
|
517
530
|
)
|
|
@@ -215,6 +215,27 @@ describe('session turns on Pi', () => {
|
|
|
215
215
|
expect(completed.output.length).toBeLessThanOrEqual(24_000)
|
|
216
216
|
})
|
|
217
217
|
|
|
218
|
+
it('records generic tool attachment references without storing their content bytes', async () => {
|
|
219
|
+
const { events, run } = setup(async ({ gate }) => fakeSession(async (emit) => {
|
|
220
|
+
emit({ type: 'tool_execution_end', toolCallId: 'image-1', toolName: 'capture', isError: false, result: {
|
|
221
|
+
content: [{ type: 'text', text: 'Captured screen.' }, { type: 'image', data: 'model-image-bytes' }],
|
|
222
|
+
details: { attachments: [{ id: 'attachment-1', name: 'Screen capture', mimeType: 'image/jpeg' }] }
|
|
223
|
+
} })
|
|
224
|
+
emit({ type: 'tool_execution_end', toolCallId: 'image-2', toolName: 'capture', isError: false, result: {
|
|
225
|
+
content: [{ type: 'text', text: 'Invalid attachment metadata was ignored.' }],
|
|
226
|
+
details: { attachments: [{ id: 'attachment-2', name: 'Capture', mimeType: 'image/jpeg', data: 'must-not-persist' }] }
|
|
227
|
+
} })
|
|
228
|
+
}, gate))
|
|
229
|
+
|
|
230
|
+
await run('attachments-run')
|
|
231
|
+
await tick()
|
|
232
|
+
const completed = events.filter((event) => event.type === 'tool.completed').map((event) => event.payload as Record<string, unknown>)
|
|
233
|
+
expect(completed[0]?.attachments).toEqual([{ id: 'attachment-1', name: 'Screen capture', mimeType: 'image/jpeg' }])
|
|
234
|
+
expect(completed[1]).not.toHaveProperty('attachments')
|
|
235
|
+
expect(JSON.stringify(events)).not.toContain('model-image-bytes')
|
|
236
|
+
expect(JSON.stringify(events)).not.toContain('must-not-persist')
|
|
237
|
+
})
|
|
238
|
+
|
|
218
239
|
it('records bounded retry and compaction events without exposing thinking text', async () => {
|
|
219
240
|
const { events, run } = setup(async ({ gate }) =>
|
|
220
241
|
fakeSession(async (emit) => {
|
package/src/terminal/cli.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// `
|
|
1
|
+
// `xagent-wiki` and `xagent-mcp`: the harness's memory, Wiki, and connected apps as commands,
|
|
2
2
|
// for a `pi` terminal session's shell. Each command maps onto one call to the session socket.
|
|
3
3
|
import type { WikiCall } from '../tools/definitions'
|
|
4
4
|
import { connectionFromEnv, socketCall } from './client'
|
|
5
5
|
|
|
6
|
-
export const WIKI_COMMAND = '
|
|
7
|
-
export const APPS_COMMAND = '
|
|
6
|
+
export const WIKI_COMMAND = 'xagent-wiki'
|
|
7
|
+
export const APPS_COMMAND = 'xagent-mcp'
|
|
8
8
|
|
|
9
9
|
export const USAGE = `Usage: ${WIKI_COMMAND} <command> [arguments]
|
|
10
10
|
|
|
@@ -109,7 +109,7 @@ async function readStdin(): Promise<string> {
|
|
|
109
109
|
|
|
110
110
|
const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
111
111
|
|
|
112
|
-
/** Runs `
|
|
112
|
+
/** Runs `xagent-wiki <argv>`; returns the exit code. */
|
|
113
113
|
export async function runWikiCli(argv: string[]): Promise<number> {
|
|
114
114
|
const command = parseCli(argv)
|
|
115
115
|
if (command.kind === 'help') {
|
|
@@ -130,7 +130,7 @@ export async function runWikiCli(argv: string[]): Promise<number> {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/** Runs `
|
|
133
|
+
/** Runs `xagent-mcp <argv>`; returns the exit code. */
|
|
134
134
|
export async function runAppsCli(argv: string[]): Promise<number> {
|
|
135
135
|
const [command, name, args] = argv
|
|
136
136
|
if (command !== 'list' && !(command === 'call' && name)) {
|
package/src/terminal/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// How a process outside the harness (the `pi` command's extension, the `
|
|
2
|
-
// `
|
|
1
|
+
// How a process outside the harness (the `pi` command's extension, the `xagent-wiki` and
|
|
2
|
+
// `xagent-mcp` commands) reaches it: one newline-delimited JSON request and reply per
|
|
3
3
|
// connection to the session socket, authorized by the token the harness put in its environment.
|
|
4
4
|
import { createConnection } from 'node:net'
|
|
5
5
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { bundledPiCli, resolvePiCommand, terminalCommandFiles } from './launch'
|
|
5
|
+
|
|
6
|
+
describe('terminal launch commands', () => {
|
|
7
|
+
it('runs the pinned Pi CLI with the active Node runtime on Windows', () => {
|
|
8
|
+
const launch = resolvePiCommand({ platform: 'win32', node: 'C:\\Program Files\\nodejs\\node.exe' })
|
|
9
|
+
|
|
10
|
+
expect(launch.program).toBe('C:\\Program Files\\nodejs\\node.exe')
|
|
11
|
+
expect(launch.argsPrefix[0]).toMatch(/[\\/]node_modules[\\/]@earendil-works[\\/]pi-coding-agent[\\/]dist[\\/]bundle[\\/]cli\.js$/)
|
|
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)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('keeps an explicitly configured executable even on Windows', () => {
|
|
23
|
+
expect(resolvePiCommand({ platform: 'win32', node: 'node.exe', programOverride: () => 'C:\\Tools\\pi.exe' }))
|
|
24
|
+
.toEqual({ program: 'C:\\Tools\\pi.exe', argsPrefix: [] })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('provides Git Bash and native Windows launchers for each Harness command', () => {
|
|
28
|
+
expect(terminalCommandFiles('win32', 'C:\\Program Files\\nodejs\\node.exe', 'C:\\Harness\\cli.js', 'xagent-wiki', 'wiki'))
|
|
29
|
+
.toEqual([
|
|
30
|
+
{ name: 'xagent-wiki', content: "#!/bin/sh\nexec '/c/Program Files/nodejs/node.exe' '/c/Harness/cli.js' wiki \"$@\"\n" },
|
|
31
|
+
{ name: 'xagent-wiki.cmd', content: '@echo off\r\n"%XINGULAR_HARNESS_NODE%" "%XINGULAR_HARNESS_CLI%" wiki %*\r\nexit /b %ERRORLEVEL%\r\n' }
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { HarnessError } from '../core/errors'
|
|
6
|
+
import { findBinary } from '../core/path'
|
|
7
|
+
|
|
8
|
+
const PI_PACKAGE = '@earendil-works/pi-coding-agent'
|
|
9
|
+
|
|
10
|
+
export interface PiCommand {
|
|
11
|
+
program: string
|
|
12
|
+
argsPrefix: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
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
|
+
export function resolvePiCommand(options: {
|
|
47
|
+
platform: NodeJS.Platform
|
|
48
|
+
node: string
|
|
49
|
+
programOverride?: () => string | null
|
|
50
|
+
}): PiCommand {
|
|
51
|
+
if (options.programOverride) {
|
|
52
|
+
const program = options.programOverride()
|
|
53
|
+
if (!program) throw new HarnessError('harness_unavailable', 'The Pi CLI was not found on PATH.')
|
|
54
|
+
return { program, argsPrefix: [] }
|
|
55
|
+
}
|
|
56
|
+
if (options.platform === 'win32') return { program: options.node, argsPrefix: [bundledPiCli()] }
|
|
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: [] }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bashPath(path: string): string {
|
|
63
|
+
const drive = /^([a-z]):[\\/](.*)$/i.exec(path)
|
|
64
|
+
if (drive) return `/${drive[1].toLowerCase()}/${drive[2].replaceAll('\\', '/')}`
|
|
65
|
+
if (path.startsWith('\\\\')) return `//${path.slice(2).replaceAll('\\', '/')}`
|
|
66
|
+
return path
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function shellQuote(value: string): string {
|
|
70
|
+
return `'${value.replaceAll("'", "'\\''")}'`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Files Pi's Git Bash shell and native Windows shells use to call Harness subcommands. */
|
|
74
|
+
export function terminalCommandFiles(platform: NodeJS.Platform, node: string, cli: string, name: string, mode: string): Array<{ name: string; content: string }> {
|
|
75
|
+
const shellNode = platform === 'win32' ? bashPath(node) : node
|
|
76
|
+
const shellCli = platform === 'win32' ? bashPath(cli) : cli
|
|
77
|
+
const files = [{
|
|
78
|
+
name,
|
|
79
|
+
content: `#!/bin/sh\nexec ${shellQuote(shellNode)} ${shellQuote(shellCli)} ${mode} "$@"\n`
|
|
80
|
+
}]
|
|
81
|
+
if (platform === 'win32') {
|
|
82
|
+
files.push({
|
|
83
|
+
name: `${name}.cmd`,
|
|
84
|
+
content: `@echo off\r\n"%XINGULAR_HARNESS_NODE%" "%XINGULAR_HARNESS_CLI%" ${mode} %*\r\nexit /b %ERRORLEVEL%\r\n`
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
return files
|
|
88
|
+
}
|
package/src/terminal/socket.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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 `
|
|
2
|
+
// a terminal, with its extension and the `xagent-wiki` / `xagent-mcp` commands) reaches the
|
|
3
3
|
// session it belongs to. Owner-only Unix socket (a named pipe on Windows), newline-delimited JSON,
|
|
4
4
|
// one request per connection. Each terminal session holds a random token bound to its caller, its
|
|
5
5
|
// connected-apps grant, and its controller; the token stops working when the terminal ends.
|
|
@@ -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
|
}
|