@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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.agentx-backup-'))).toBe(true)
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
 
@@ -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 = {
@@ -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)}.agentx-backup-${Math.floor(Date.now() / 1000)}`)
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. A desktop application starts with a minimal PATH, so the login
2
- // shell's PATH is resolved once and used for everything the harness launches.
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
- if (resolved !== null) return resolved
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
- try {
14
- const output = execFileSync(process.env.SHELL || '/bin/zsh', ['-ilc', 'printf %s "$PATH"'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
15
- // Interactive shells may print banners; the PATH is the last line.
16
- entries.push(...(output.split('\n').filter(Boolean).at(-1) ?? '').split(delimiter))
17
- } catch {
18
- // The inherited PATH and the usual folders still apply.
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.push(...(process.env.PATH ?? '').split(delimiter))
21
- const home = homedir()
22
- if (home) entries.push(...['.local/bin', '.bun/bin', '.npm-global/bin', '.cargo/bin'].map((folder) => join(home, folder)))
23
- entries.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin')
24
- resolved = [...new Set(entries.filter(Boolean))].join(delimiter)
25
- return resolved
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
- if (isAbsolute(name)) return isExecutable(name) ? name : null
40
- for (const directory of searchPath().split(delimiter)) {
41
- const candidate = join(directory, name)
42
- if (isExecutable(candidate)) return candidate
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
  }
@@ -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> = { PATH: searchPath() }
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)
@@ -62,7 +62,7 @@ export class McpManager {
62
62
  }
63
63
 
64
64
  private async connectStdio(config: StdioConfig) {
65
- const client = new Client({ name: 'agent-x', version: '0.1.0' })
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: 'agent-x', version: '0.1.0' })
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 })
@@ -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 `agentx-wiki` commands in
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
- ? '`agentx-wiki block append <label> <text>`, `block replace <label> --old <text> --new <text>`, or `block rethink <label> <text>`'
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
  }
@@ -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 `agentx-wiki` command. */
24
+ /** `cli` when the caller reaches the Wiki through the `xagent-wiki` command. */
25
25
  surface?: string | null
26
26
  }
27
27
 
@@ -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 `agentx-wiki` command, the bundled MCP server).
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'
@@ -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/agent-x`. */
29
+ /** `/` for everything, or a project namespace such as `/projects/xagent`. */
30
30
  namespace: string
31
31
  sourceIds: string[]
32
32
  confidence: string
@@ -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/agent-x/architecture`) and namespace (`/projects/agent-x`). */
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('agentx-wiki search')
101
- expect(cli).toContain('agentx-wiki block append')
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', () => {
@@ -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: `agentx-wiki block append <label> <text>`, `block replace <label> --old <text> --new <text>`, and `block rethink <label> <text>` edit your memory blocks. `agentx-wiki facts [query]` recalls saved facts. `agentx-wiki history <query>` searches earlier runs and `agentx-wiki history-read <run id>` returns one.'
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: `agentx-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].'
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() })
@@ -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 { UsageRequest } from './contracts'
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: 'agent-x-approvals', factory: gate as never, hidden: true }],
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 { content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never, details: {} }
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) => {
@@ -1,10 +1,10 @@
1
- // `agentx-wiki` and `agentx-mcp`: the harness's memory, Wiki, and connected apps as commands,
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 = 'agentx-wiki'
7
- export const APPS_COMMAND = 'agentx-mcp'
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 `agentx-wiki <argv>`; returns the exit code. */
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 `agentx-mcp <argv>`; returns the exit code. */
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)) {
@@ -1,5 +1,5 @@
1
- // How a process outside the harness (the `pi` command's extension, the `agentx-wiki` and
2
- // `agentx-mcp` commands) reaches it: one newline-delimited JSON request and reply per
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
+ }
@@ -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 `agentx-wiki` / `agentx-mcp` commands) reaches 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
  }