@xingular/harness 2.1.1 → 3.0.0
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/CHANGELOG.md +18 -0
- package/NOTICE.md +22 -0
- package/README.md +70 -37
- package/dist/cli.js +6572 -6700
- package/dist/terminal-pi.js +51 -38
- package/docs/terminal-tui.md +50 -12
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +7 -2
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +13 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +60 -13
- package/src/sessions/sessions.ts +43 -18
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +41 -39
- package/src/terminal/terminals.ts +31 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +15 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +3 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
package/src/terminal/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `xagent-wiki` and `xagent-mcp`: the harness's
|
|
2
|
-
// for a `pi` terminal session's shell. Each command maps onto one call to the session socket.
|
|
3
|
-
import type {
|
|
1
|
+
// `xagent-wiki` and `xagent-mcp`: the harness's notes, skills, history and connected apps as
|
|
2
|
+
// commands, for a `pi` terminal session's shell. Each command maps onto one call to the session socket.
|
|
3
|
+
import type { HarnessCall } from '../tools/definitions'
|
|
4
4
|
import { connectionFromEnv, socketCall } from './client'
|
|
5
5
|
|
|
6
6
|
export const WIKI_COMMAND = 'xagent-wiki'
|
|
@@ -8,22 +8,14 @@ export const APPS_COMMAND = 'xagent-mcp'
|
|
|
8
8
|
|
|
9
9
|
export const USAGE = `Usage: ${WIKI_COMMAND} <command> [arguments]
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
skill [name] Load a skill, or list skills
|
|
17
|
-
facts [query] [--namespace NS] Recall facts (also: memory)
|
|
11
|
+
search <query> [--project SLUG] Search the Wiki's project notes
|
|
12
|
+
read <path> Read one note, e.g. projects/<slug>/decisions
|
|
13
|
+
list [project slug | library] List notes
|
|
14
|
+
write <path> <text | -> Save a note (when this session may write the Wiki)
|
|
15
|
+
skill [name] Load one of your skills, or list them
|
|
18
16
|
history <query> [--scope all] [--since YYYY-MM-DD]
|
|
19
17
|
Search earlier conversations and runs (also: recall)
|
|
20
18
|
history-read <run id> Read one earlier run (also: recall-read)
|
|
21
|
-
block append <label> <text> Add to a memory block (user, project, or a shared block)
|
|
22
|
-
block replace <label> --old <text> --new <text>
|
|
23
|
-
Replace one exact passage in a memory block
|
|
24
|
-
block rethink <label> <text> Rewrite a whole memory block
|
|
25
|
-
propose <json | -> Record knowledge, e.g. {"kind":"fact","title":"…","body":"…",
|
|
26
|
-
"type":"preference","sourceRefs":[{"kind":"run","ref":"current"}]}
|
|
27
19
|
`
|
|
28
20
|
|
|
29
21
|
export const APPS_USAGE = `Usage: ${APPS_COMMAND} <command>
|
|
@@ -33,15 +25,15 @@ export const APPS_USAGE = `Usage: ${APPS_COMMAND} <command>
|
|
|
33
25
|
Use one of them
|
|
34
26
|
`
|
|
35
27
|
|
|
36
|
-
export type CliCommand = { kind: 'call'; call:
|
|
28
|
+
export type CliCommand = { kind: 'call'; call: HarnessCall; params: Record<string, unknown> } | { kind: 'help' } | { kind: 'stdin-write'; path: string }
|
|
37
29
|
|
|
38
30
|
function options(args: string[]): { positional: string[]; flags: Record<string, string> } {
|
|
39
31
|
const positional: string[] = []
|
|
40
32
|
const flags: Record<string, string> = {}
|
|
41
33
|
for (let index = 0; index < args.length; index += 1) {
|
|
42
|
-
const arg = args[index]
|
|
34
|
+
const arg = args[index]!
|
|
43
35
|
if (arg.startsWith('--') && index + 1 < args.length) {
|
|
44
|
-
flags[arg.slice(2)] = args[index + 1]
|
|
36
|
+
flags[arg.slice(2)] = args[index + 1]!
|
|
45
37
|
index += 1
|
|
46
38
|
} else {
|
|
47
39
|
positional.push(arg)
|
|
@@ -55,21 +47,23 @@ export function parseCli(argv: string[]): CliCommand {
|
|
|
55
47
|
const { positional, flags } = options(rest)
|
|
56
48
|
const joined = positional.join(' ').trim()
|
|
57
49
|
switch (command) {
|
|
58
|
-
case 'entrypoint':
|
|
59
|
-
return { kind: 'call', call: 'wiki.entrypoint', params: {} }
|
|
60
50
|
case 'search':
|
|
61
51
|
if (!joined) return { kind: 'help' }
|
|
62
|
-
return { kind: 'call', call: 'wiki.search', params: { query: joined, ...(flags.
|
|
52
|
+
return { kind: 'call', call: 'wiki.search', params: { query: joined, ...(flags.project ? { project: flags.project } : {}) } }
|
|
63
53
|
case 'read':
|
|
64
54
|
if (!joined) return { kind: 'help' }
|
|
65
|
-
return { kind: 'call', call: 'wiki.read', params: {
|
|
55
|
+
return { kind: 'call', call: 'wiki.read', params: { path: joined } }
|
|
66
56
|
case 'list':
|
|
67
|
-
return { kind: 'call', call: 'wiki.list', params:
|
|
57
|
+
return { kind: 'call', call: 'wiki.list', params: joined ? { project: joined } : {} }
|
|
58
|
+
case 'write': {
|
|
59
|
+
const [path, ...words] = positional
|
|
60
|
+
if (!path) return { kind: 'help' }
|
|
61
|
+
if (words.join(' ').trim() === '-') return { kind: 'stdin-write', path }
|
|
62
|
+
if (!words.length) return { kind: 'help' }
|
|
63
|
+
return { kind: 'call', call: 'wiki.write', params: { path, text: words.join(' ') } }
|
|
64
|
+
}
|
|
68
65
|
case 'skill':
|
|
69
|
-
return { kind: 'call', call: '
|
|
70
|
-
case 'facts':
|
|
71
|
-
case 'memory':
|
|
72
|
-
return { kind: 'call', call: 'wiki.memory', params: { ...(joined ? { query: joined } : {}), ...(flags.namespace ? { namespace: flags.namespace } : {}) } }
|
|
66
|
+
return { kind: 'call', call: 'skill.load', params: joined ? { name: joined } : {} }
|
|
73
67
|
case 'history':
|
|
74
68
|
case 'recall':
|
|
75
69
|
if (!joined) return { kind: 'help' }
|
|
@@ -78,24 +72,6 @@ export function parseCli(argv: string[]): CliCommand {
|
|
|
78
72
|
case 'recall-read':
|
|
79
73
|
if (!joined) return { kind: 'help' }
|
|
80
74
|
return { kind: 'call', call: 'recall.read', params: { run: joined } }
|
|
81
|
-
case 'block': {
|
|
82
|
-
const [action, label, ...words] = positional
|
|
83
|
-
const body = words.join(' ').trim()
|
|
84
|
-
if (action === 'append' && label && body) return { kind: 'call', call: 'memory.append', params: { block: label, text: body } }
|
|
85
|
-
if (action === 'rethink' && label && body) return { kind: 'call', call: 'memory.rethink', params: { block: label, body } }
|
|
86
|
-
if (action === 'replace' && label && flags.old !== undefined && flags.new !== undefined) return { kind: 'call', call: 'memory.replace', params: { block: label, old: flags.old, new: flags.new } }
|
|
87
|
-
return { kind: 'help' }
|
|
88
|
-
}
|
|
89
|
-
case 'propose': {
|
|
90
|
-
if (joined === '-') return { kind: 'stdin-propose' }
|
|
91
|
-
try {
|
|
92
|
-
const params = JSON.parse(joined) as unknown
|
|
93
|
-
if (params && typeof params === 'object' && !Array.isArray(params)) return { kind: 'call', call: 'wiki.propose', params: params as Record<string, unknown> }
|
|
94
|
-
} catch {
|
|
95
|
-
// Falls through to help.
|
|
96
|
-
}
|
|
97
|
-
return { kind: 'help' }
|
|
98
|
-
}
|
|
99
75
|
default:
|
|
100
76
|
return { kind: 'help' }
|
|
101
77
|
}
|
|
@@ -119,8 +95,8 @@ export async function runWikiCli(argv: string[]): Promise<number> {
|
|
|
119
95
|
try {
|
|
120
96
|
const connection = connectionFromEnv()
|
|
121
97
|
const result =
|
|
122
|
-
command.kind === 'stdin-
|
|
123
|
-
? await socketCall<string>(connection, 'wiki.
|
|
98
|
+
command.kind === 'stdin-write'
|
|
99
|
+
? await socketCall<string>(connection, 'wiki.write', { path: command.path, text: await readStdin() })
|
|
124
100
|
: await socketCall<string>(connection, command.call, command.params)
|
|
125
101
|
process.stdout.write(`${typeof result === 'string' ? result : JSON.stringify(result)}\n`)
|
|
126
102
|
return 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { LOG_READ_CHUNK_BYTES, readHarnessLogSnapshot } from './logs'
|
|
4
|
+
|
|
5
|
+
describe('terminal diagnostic log snapshots', () => {
|
|
6
|
+
it('reads the complete fixed-size snapshot in bounded byte chunks', async () => {
|
|
7
|
+
const original = `${'x'.repeat(LOG_READ_CHUNK_BYTES - 1)}🧠and the rest`
|
|
8
|
+
const bytes = Buffer.from(original)
|
|
9
|
+
const info = vi.fn(async () => ({ size: bytes.length }))
|
|
10
|
+
const chunk = vi.fn(async (snapshotSize: number, offset: number) => {
|
|
11
|
+
expect(snapshotSize).toBe(bytes.length)
|
|
12
|
+
const nextOffset = Math.min(bytes.length, offset + LOG_READ_CHUNK_BYTES)
|
|
13
|
+
return { data: bytes.subarray(offset, nextOffset).toString('base64'), nextOffset }
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
await expect(readHarnessLogSnapshot(info, chunk)).resolves.toBe(original)
|
|
17
|
+
expect(chunk).toHaveBeenCalledTimes(2)
|
|
18
|
+
expect(chunk.mock.calls.map(([, offset]) => offset)).toEqual([0, LOG_READ_CHUNK_BYTES])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns an empty snapshot when this host has not captured process diagnostics', async () => {
|
|
22
|
+
const chunk = vi.fn()
|
|
23
|
+
await expect(readHarnessLogSnapshot(async () => ({ size: null }), chunk)).resolves.toBe('')
|
|
24
|
+
expect(chunk).not.toHaveBeenCalled()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const LOG_READ_CHUNK_BYTES = 32 * 1024
|
|
5
|
+
|
|
6
|
+
export interface HarnessLogInfo {
|
|
7
|
+
size: number | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HarnessLogChunk {
|
|
11
|
+
data: string
|
|
12
|
+
nextOffset: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Captures the byte length of the host-owned diagnostics file; callers never supply a path. */
|
|
16
|
+
export function harnessLogInfo(stateDir: string): HarnessLogInfo {
|
|
17
|
+
try {
|
|
18
|
+
const stat = statSync(join(stateDir, 'harness.log'))
|
|
19
|
+
if (!stat.isFile()) throw new Error('The Harness diagnostics path is not a regular file.')
|
|
20
|
+
return { size: stat.size }
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { size: null }
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reads one bounded range from the file size captured by `harnessLogInfo`. */
|
|
28
|
+
export function readHarnessLogChunk(stateDir: string, snapshotSize: number, offset: number): HarnessLogChunk {
|
|
29
|
+
if (!Number.isSafeInteger(snapshotSize) || snapshotSize < 0 || !Number.isSafeInteger(offset) || offset < 0 || offset >= snapshotSize) {
|
|
30
|
+
throw new Error('The Harness diagnostics snapshot range is invalid.')
|
|
31
|
+
}
|
|
32
|
+
const path = join(stateDir, 'harness.log')
|
|
33
|
+
const current = statSync(path)
|
|
34
|
+
if (!current.isFile() || current.size < snapshotSize) throw new Error('The Harness diagnostics file changed while it was being read.')
|
|
35
|
+
const length = Math.min(LOG_READ_CHUNK_BYTES, snapshotSize - offset)
|
|
36
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
37
|
+
const descriptor = openSync(path, 'r')
|
|
38
|
+
try {
|
|
39
|
+
const bytesRead = readSync(descriptor, buffer, 0, length, offset)
|
|
40
|
+
if (bytesRead !== length) throw new Error('The Harness diagnostics file changed while it was being read.')
|
|
41
|
+
return { data: buffer.toString('base64'), nextOffset: offset + bytesRead }
|
|
42
|
+
} finally {
|
|
43
|
+
closeSync(descriptor)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Collects a fixed-size snapshot while keeping each socket reply bounded. */
|
|
48
|
+
export async function readHarnessLogSnapshot(
|
|
49
|
+
info: () => Promise<HarnessLogInfo>,
|
|
50
|
+
chunk: (snapshotSize: number, offset: number) => Promise<HarnessLogChunk>
|
|
51
|
+
): Promise<string> {
|
|
52
|
+
const { size } = await info()
|
|
53
|
+
if (size === null || size === 0) return ''
|
|
54
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error('The Harness returned an invalid diagnostics file size.')
|
|
55
|
+
const buffers: Buffer[] = []
|
|
56
|
+
let offset = 0
|
|
57
|
+
while (offset < size) {
|
|
58
|
+
const page = await chunk(size, offset)
|
|
59
|
+
const buffer = Buffer.from(page.data, 'base64')
|
|
60
|
+
if (!buffer.length || page.nextOffset !== offset + buffer.length || page.nextOffset > size) {
|
|
61
|
+
throw new Error('The Harness returned an invalid diagnostics file page.')
|
|
62
|
+
}
|
|
63
|
+
buffers.push(buffer)
|
|
64
|
+
offset = page.nextOffset
|
|
65
|
+
}
|
|
66
|
+
return Buffer.concat(buffers).toString('utf8')
|
|
67
|
+
}
|
|
@@ -51,7 +51,7 @@ describe('terminal bridge lifecycle', () => {
|
|
|
51
51
|
await terminalExtension(api, state)
|
|
52
52
|
const policyCalls = transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy').length
|
|
53
53
|
const result = await handlers.get('tool_call')!({ toolName: 'bash', toolCallId: 'call-1', input: { command: 'touch unsafe' } }, ctx)
|
|
54
|
-
expect(result).toMatchObject({ block: true, reason: expect.stringContaining('Plan mode is
|
|
54
|
+
expect(result).toMatchObject({ block: true, reason: expect.stringContaining('Plan mode is research-only') })
|
|
55
55
|
expect(transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy')).toHaveLength(policyCalls)
|
|
56
56
|
})
|
|
57
57
|
|
|
@@ -235,7 +235,7 @@ export default async function (pi: ExtensionAPI, planRun?: TerminalPlanRunState)
|
|
|
235
235
|
})
|
|
236
236
|
pi.on('tool_call', async (event) => {
|
|
237
237
|
if (planRun?.active && !planRun.allowedTools.has(event.toolName)) {
|
|
238
|
-
const reason = planRefusal(event.toolName, new Set()) ?? `Plan mode is
|
|
238
|
+
const reason = planRefusal(event.toolName, new Set()) ?? `Plan mode is research-only: ${event.toolName} is not available in this session.`
|
|
239
239
|
return { block: true, reason }
|
|
240
240
|
}
|
|
241
241
|
try {
|
|
@@ -294,10 +294,10 @@ export default async function (pi: ExtensionAPI, planRun?: TerminalPlanRunState)
|
|
|
294
294
|
pi.registerTool({
|
|
295
295
|
name: 'ask_user_question',
|
|
296
296
|
label: 'Ask a question',
|
|
297
|
-
description: 'Ask the user a question and wait for their answer.',
|
|
297
|
+
description: 'Ask the user a question and wait for their answer. Recommendations are visual only and never preselect answers; submitted choices keep their labels. Mark options with recommended: true, at most one for single-choice questions or several when multiSelect is true.',
|
|
298
298
|
parameters: Type.Object({
|
|
299
299
|
question: Type.String(),
|
|
300
|
-
options: Type.Optional(Type.Array(Type.Object({ label: Type.String(), description: Type.Optional(Type.String()) }))),
|
|
300
|
+
options: Type.Optional(Type.Array(Type.Object({ label: Type.String(), description: Type.Optional(Type.String()), recommended: Type.Optional(Type.Boolean()) }))),
|
|
301
301
|
multiSelect: Type.Optional(Type.Boolean())
|
|
302
302
|
}),
|
|
303
303
|
async execute(id, input) {
|
|
@@ -80,21 +80,20 @@ describe('plugin control from a terminal session', () => {
|
|
|
80
80
|
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
81
81
|
const call = <T>(method: string, params?: Record<string, unknown>) => socketCall<T>(connection, method, params ?? {}, 5_000, key)
|
|
82
82
|
const rows = menuRows(await call<Plugin[]>('plugins.list'), 'p1')
|
|
83
|
-
expect(rows.map((row) => row.label)).toEqual(['● review-kit · code not trusted', '○
|
|
83
|
+
expect(rows.map((row) => row.label)).toEqual(['● review-kit · code not trusted', '○ Budget guard · runs code', '○ Output trim · runs code'])
|
|
84
84
|
expect(rowActions(rows[0], 'p1')).toEqual(['Switch off for this project', 'Switch off everywhere', 'Trust its code', 'Back'])
|
|
85
85
|
expect(rowActions(rows[1], null)).toEqual(['Switch on everywhere', 'Back'])
|
|
86
86
|
// A part that ships with the harness needs no trust: it is the harness's own code.
|
|
87
|
-
expect(rowActions(rows[
|
|
87
|
+
expect(rowActions(rows[1], 'p1')).toEqual(['Switch on for this project', 'Switch on everywhere', 'Back'])
|
|
88
88
|
|
|
89
|
-
// Declining the trust question leaves the code untrusted; then they trust it
|
|
90
|
-
const { ui, shown, said } = person(['● review-kit · code not trusted', 'Trust its code', false, '● review-kit · code not trusted', 'Trust its code', true, '
|
|
89
|
+
// Declining the trust question leaves the code untrusted; then they trust it and leave.
|
|
90
|
+
const { ui, shown, said } = person(['● review-kit · code not trusted', 'Trust its code', false, '● review-kit · code not trusted', 'Trust its code', true, 'Done'])
|
|
91
91
|
await runPluginsMenu(ui, call, 'p1')
|
|
92
92
|
expect(shown.find((line) => line.startsWith('Trust this code?'))).toContain('extensions/guard.ts')
|
|
93
93
|
expect(shown.find((line) => line.startsWith('Trust this code?'))).toContain('can do anything this app can')
|
|
94
94
|
expect(said).toEqual(['Saved. It applies when this session restarts.'])
|
|
95
95
|
const after = await call<Plugin[]>('plugins.list')
|
|
96
96
|
expect(after.find((plugin) => plugin.id === 'package:review-kit')).toMatchObject({ trust: 'trusted' })
|
|
97
|
-
expect(after.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === 'yours/rules/tests')).toMatchObject({ state: 'on', scope: 'App' })
|
|
98
97
|
expect(harness.plugins.trust.list()).toHaveLength(1)
|
|
99
98
|
|
|
100
99
|
// Leaving at once changes nothing and says nothing.
|
|
@@ -113,7 +112,7 @@ describe('the plugins command', () => {
|
|
|
113
112
|
const last = (): string => lines.at(-1) ?? ''
|
|
114
113
|
|
|
115
114
|
expect(await run(['list'])).toBe(0)
|
|
116
|
-
expect(last()).toMatch(/^
|
|
115
|
+
expect(last()).toMatch(/^harness\s+required\s+Harness$/m)
|
|
117
116
|
expect(await run(['install', join(base, 'kit')])).toBe(0)
|
|
118
117
|
expect(last()).toContain('Installed, switched off.')
|
|
119
118
|
expect(last()).toContain('trust its code with "plugins trust <id>"')
|
|
@@ -134,8 +133,8 @@ describe('the plugins command', () => {
|
|
|
134
133
|
expect(last()).toContain('Code (trusted)')
|
|
135
134
|
expect(last()).toContain('● skill audit (package:review-kit/skill/audit)')
|
|
136
135
|
|
|
137
|
-
expect(await run(['detach', '
|
|
138
|
-
expect(last()).toContain('
|
|
136
|
+
expect(await run(['detach', 'harness'])).toBe(1)
|
|
137
|
+
expect(last()).toContain('Harness cannot be switched off.')
|
|
139
138
|
expect(await run(['untrust', 'package:review-kit'])).toBe(0)
|
|
140
139
|
expect(await run(['remove', 'package:review-kit'])).toBe(0)
|
|
141
140
|
expect(await run(['show', 'package:review-kit'])).toBe(1)
|
package/src/terminal/socket.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { log } from '../core/bus'
|
|
|
13
13
|
import { HarnessError, toErrorShape } from '../core/errors'
|
|
14
14
|
import { newId } from '../core/fs'
|
|
15
15
|
import type { BrokerSession } from '../mcp/service'
|
|
16
|
-
import type { CallerContext } from '../
|
|
16
|
+
import type { CallerContext } from '../core/caller'
|
|
17
17
|
|
|
18
18
|
/** What a token stands for. */
|
|
19
19
|
export interface SocketGrant {
|
|
@@ -27,7 +27,7 @@ export interface SocketGrant {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface SocketHandlers {
|
|
30
|
-
/** `wiki.*`, `
|
|
30
|
+
/** `wiki.*`, `skill.*`, `recall.*`, `trajectory.*`. */
|
|
31
31
|
memory(caller: CallerContext, call: string, params: Record<string, unknown>): string
|
|
32
32
|
/** `mcp.list`, `mcp.call`, `mcp.revision`, `tools.list`, `tools.call`. */
|
|
33
33
|
apps(session: BrokerSession, call: string, params: Record<string, unknown>): Promise<unknown>
|
|
@@ -147,7 +147,7 @@ export class SessionSocket {
|
|
|
147
147
|
}
|
|
148
148
|
const call = typeof request.call === 'string' ? request.call : ''
|
|
149
149
|
const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>
|
|
150
|
-
if (['wiki.', '
|
|
150
|
+
if (['wiki.', 'skill.', 'recall.', 'trajectory.'].some((prefix) => call.startsWith(prefix))) return { result: this.handlers.memory(grant.caller(), call, params) }
|
|
151
151
|
if (call === 'tools.list' && !grant.broker) return { result: { tools: [] } }
|
|
152
152
|
if (['mcp.', 'tools.'].some((prefix) => call.startsWith(prefix))) {
|
|
153
153
|
if (!grant.broker) throw new HarnessError('mcp_unauthorized', 'Connected apps are not available to this session.')
|
|
@@ -15,6 +15,7 @@ import { rootWithProject, writeFile } from '../../tests/helpers'
|
|
|
15
15
|
import { parseCli } from './cli'
|
|
16
16
|
import { SOCKET_ENV, TOKEN_ENV, socketCall, type SocketConnection } from './client'
|
|
17
17
|
import { SessionSocket } from './socket'
|
|
18
|
+
import { LOG_READ_CHUNK_BYTES } from './logs'
|
|
18
19
|
|
|
19
20
|
const open: Harness[] = []
|
|
20
21
|
afterEach(async () => {
|
|
@@ -106,11 +107,8 @@ describe('a pi terminal session', () => {
|
|
|
106
107
|
expect(`${result.stdout}${result.stderr}`).toMatch(/usage|options|pi coding agent/i)
|
|
107
108
|
})
|
|
108
109
|
|
|
109
|
-
it('passes what the harness found as paths, the
|
|
110
|
+
it('passes what the harness found as paths, the harness tree\'s skills and trusted plugins with them, and its own extension last', async () => {
|
|
110
111
|
const root = rootWithProject()
|
|
111
|
-
writeFile(join(root, 'registry/skills/release/SKILL.md'), '# Release\n')
|
|
112
|
-
writeFile(join(root, 'registry/prompts/review.md'), 'Review this.\n')
|
|
113
|
-
writeFile(join(root, 'config/registry-enabled.json'), JSON.stringify({ p1: ['skills/release', 'prompts/review'] }))
|
|
114
112
|
writeFile(join(root, '.pi/extensions/local.ts'), 'export default function () {}\n')
|
|
115
113
|
const item = (kind: Found['kind'], path: string, origin: Found['origin'], enabled = true): Found => ({ kind, path, origin, project: origin === 'project', packageRoot: null, source: null, enabled })
|
|
116
114
|
const found = [
|
|
@@ -125,12 +123,17 @@ describe('a pi terminal session', () => {
|
|
|
125
123
|
const harness = createHarness({ dataRoot: root, stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' }, resolver: async () => found })
|
|
126
124
|
open.push(harness)
|
|
127
125
|
const account = harness.accounts.create('Main', 'apiKey').accounts[0]
|
|
128
|
-
const
|
|
126
|
+
const tree = { 'systemprompt.md': 'Ship releases.\n', 'guardrails.md': '- When cutting a release, load the `release` skill.\n', 'memory.md': '', 'tool_guidance.md': '', 'skills/release/SKILL.md': '---\nname: release\ndescription: cutting a release\n---\nTag it.\n', 'plugins/guard.ts': 'export default function (pi) {}\n' }
|
|
127
|
+
const { commit, versionPath, head } = await import('../tree/store')
|
|
128
|
+
commit(root, 'term-agent', tree, { kind: 'seed' })
|
|
129
|
+
harness.trust.grant(tree['plugins/guard.ts'], 'plugins/guard.ts', 'term-agent')
|
|
130
|
+
const version = versionPath(root, 'term-agent', head(root, 'term-agent')!.fingerprint)
|
|
131
|
+
const prepare = async () => (await harness.terminals.prepare({ sessionId: 'term-1', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder: join(root, 'sessions', 'p1', 'term-1'), harness: { scope: 'term-agent' } })).args.slice(2)
|
|
129
132
|
|
|
130
133
|
const loaded = [
|
|
131
|
-
'--extension', '/shared/extensions/mine.ts', '--extension', 'builtin:mcp',
|
|
132
|
-
'--skill', join(
|
|
133
|
-
'--prompt-template',
|
|
134
|
+
'--extension', '/shared/extensions/mine.ts', '--extension', 'builtin:mcp', '--extension', join(version, 'plugins/guard.ts'),
|
|
135
|
+
'--skill', join(version, 'skills'), '--skill', '/shared/skills/alpha/SKILL.md',
|
|
136
|
+
'--prompt-template', '/shared/prompts/p.md',
|
|
134
137
|
'--theme', '/shared/themes/dark.json',
|
|
135
138
|
'--extension', '/opt/harness/terminal-pi.js', '--session-dir', join(root, 'sessions', 'p1', 'term-1', 'pi')
|
|
136
139
|
]
|
|
@@ -190,24 +193,6 @@ describe('a pi terminal session', () => {
|
|
|
190
193
|
expect(loadRun(root, 'reserved').status).toBe('interrupted')
|
|
191
194
|
})
|
|
192
195
|
|
|
193
|
-
it('sends a memory block that changed since the session started along with the next instruction', async () => {
|
|
194
|
-
const { tell, harness, connection } = await setup()
|
|
195
|
-
await tell('ready', {})
|
|
196
|
-
await socketCall(connection, 'memory.append', { block: 'project', text: 'Prefers pnpm.' })
|
|
197
|
-
const first = harness.terminals.send({ sessionId: 'term-1', text: 'Continue' })
|
|
198
|
-
let delivered = ''
|
|
199
|
-
await vi.waitFor(async () => {
|
|
200
|
-
const { command } = await tell('poll')
|
|
201
|
-
expect(command?.type).toBe('send')
|
|
202
|
-
delivered = command.text
|
|
203
|
-
})
|
|
204
|
-
await tell('begin', { text: delivered })
|
|
205
|
-
await first
|
|
206
|
-
expect(delivered.startsWith('[Memory updated since this session')).toBe(true)
|
|
207
|
-
expect(delivered).toContain('Prefers pnpm.')
|
|
208
|
-
expect(delivered.endsWith('\n\nContinue')).toBe(true)
|
|
209
|
-
})
|
|
210
|
-
|
|
211
196
|
it('will not send over a draft the person is typing, and fails a delivery the native session never acknowledges', async () => {
|
|
212
197
|
const { tell, harness, root } = await setup()
|
|
213
198
|
await tell('ready', {})
|
|
@@ -388,11 +373,11 @@ describe('a pi terminal session', () => {
|
|
|
388
373
|
expect(harness.terminals.state('term-1').ready).toBe(false)
|
|
389
374
|
})
|
|
390
375
|
|
|
391
|
-
it('serves
|
|
376
|
+
it('serves the Wiki, skills and connected apps to the session over the socket, until the terminal ends', async () => {
|
|
392
377
|
const { connection, harness, tell } = await setup()
|
|
393
378
|
await tell('ready', {})
|
|
394
|
-
expect(await socketCall<string>(connection, '
|
|
395
|
-
expect(await socketCall<string>(connection, 'wiki.list')).toContain('
|
|
379
|
+
expect(await socketCall<string>(connection, 'skill.load')).toBe('You have no skills.')
|
|
380
|
+
expect(await socketCall<string>(connection, 'wiki.list')).toContain('home')
|
|
396
381
|
const listed = await socketCall<{ tools: { name: string; parameters: { type: string } }[] }>(connection, 'tools.list')
|
|
397
382
|
expect(listed.tools.map((tool) => tool.name)).toEqual(['mcp_list_tools', 'mcp_call_tool'])
|
|
398
383
|
expect(listed.tools[0].parameters.type).toBe('object')
|
|
@@ -428,19 +413,17 @@ describe('the session socket', () => {
|
|
|
428
413
|
})
|
|
429
414
|
|
|
430
415
|
describe('xagent-wiki command', () => {
|
|
431
|
-
it('maps commands onto
|
|
432
|
-
expect(parseCli(['search', 'deploy', 'steps', '--
|
|
433
|
-
expect(parseCli(['read', '
|
|
416
|
+
it('maps commands onto Wiki, skill and history calls', () => {
|
|
417
|
+
expect(parseCli(['search', 'deploy', 'steps', '--project', 'app'])).toEqual({ kind: 'call', call: 'wiki.search', params: { query: 'deploy steps', project: 'app' } })
|
|
418
|
+
expect(parseCli(['read', 'projects/app/arch'])).toEqual({ kind: 'call', call: 'wiki.read', params: { path: 'projects/app/arch' } })
|
|
434
419
|
expect(parseCli(['list'])).toEqual({ kind: 'call', call: 'wiki.list', params: {} })
|
|
435
|
-
expect(parseCli(['
|
|
436
|
-
expect(parseCli(['
|
|
420
|
+
expect(parseCli(['list', 'library'])).toEqual({ kind: 'call', call: 'wiki.list', params: { project: 'library' } })
|
|
421
|
+
expect(parseCli(['write', 'library/notes', 'Use', 'pnpm.'])).toEqual({ kind: 'call', call: 'wiki.write', params: { path: 'library/notes', text: 'Use pnpm.' } })
|
|
422
|
+
expect(parseCli(['write', 'library/notes', '-'])).toEqual({ kind: 'stdin-write', path: 'library/notes' })
|
|
423
|
+
expect(parseCli(['skill', 'review'])).toEqual({ kind: 'call', call: 'skill.load', params: { name: 'review' } })
|
|
437
424
|
expect(parseCli(['recall', 'the', 'outage', '--scope', 'all'])).toEqual({ kind: 'call', call: 'recall.search', params: { query: 'the outage', scope: 'all' } })
|
|
438
|
-
expect(parseCli(['propose', '{"kind":"memory","title":"t","body":"b"}'])).toMatchObject({ kind: 'call', call: 'wiki.propose', params: { kind: 'memory' } })
|
|
439
|
-
expect(parseCli(['propose', '-'])).toEqual({ kind: 'stdin-propose' })
|
|
440
|
-
expect(parseCli(['block', 'append', 'project', 'Uses', 'pnpm.'])).toEqual({ kind: 'call', call: 'memory.append', params: { block: 'project', text: 'Uses pnpm.' } })
|
|
441
|
-
expect(parseCli(['block', 'replace', 'project', '--old', 'npm', '--new', 'pnpm'])).toEqual({ kind: 'call', call: 'memory.replace', params: { block: 'project', old: 'npm', new: 'pnpm' } })
|
|
442
425
|
expect(parseCli(['search'])).toEqual({ kind: 'help' })
|
|
443
|
-
expect(parseCli(['
|
|
426
|
+
expect(parseCli(['block', 'append', 'x', 'y'])).toEqual({ kind: 'help' })
|
|
444
427
|
})
|
|
445
428
|
})
|
|
446
429
|
|
|
@@ -467,6 +450,25 @@ describe('decisions in the Harness-owned frontend', () => {
|
|
|
467
450
|
await expect(socketCall(connection, 'terminal.trajectory.list', {}, 1000, key)).resolves.toEqual({ runs: [] })
|
|
468
451
|
})
|
|
469
452
|
|
|
453
|
+
it('serves a bounded diagnostic snapshot only through the private terminal control channel', async () => {
|
|
454
|
+
const { connection, harness } = await setup()
|
|
455
|
+
await expect(socketCall(connection, 'terminal.logs.info')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
456
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
457
|
+
await expect(socketCall(connection, 'terminal.logs.info', {}, 1000, key)).resolves.toEqual({ size: null })
|
|
458
|
+
await expect(socketCall(connection, 'terminal.logs.read', { snapshotSize: 1, offset: 0 }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
|
|
459
|
+
|
|
460
|
+
const text = `${'a'.repeat(LOG_READ_CHUNK_BYTES - 1)}🧠tail`
|
|
461
|
+
writeFileSync(join(harness.stateDir, 'harness.log'), text)
|
|
462
|
+
const { size } = await socketCall<{ size: number }>(connection, 'terminal.logs.info', {}, 1000, key)
|
|
463
|
+
expect(size).toBe(Buffer.byteLength(text))
|
|
464
|
+
await expect(socketCall(connection, 'terminal.logs.read', { snapshotSize: size, offset: size }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
|
|
465
|
+
const first = await socketCall<{ data: string; nextOffset: number }>(connection, 'terminal.logs.read', { snapshotSize: size, offset: 0 }, 1000, key)
|
|
466
|
+
const second = await socketCall<{ data: string; nextOffset: number }>(connection, 'terminal.logs.read', { snapshotSize: size, offset: first.nextOffset }, 1000, key)
|
|
467
|
+
expect(first.nextOffset).toBe(LOG_READ_CHUNK_BYTES)
|
|
468
|
+
expect(second.nextOffset).toBe(size)
|
|
469
|
+
expect(Buffer.concat([Buffer.from(first.data, 'base64'), Buffer.from(second.data, 'base64')]).toString('utf8')).toBe(text)
|
|
470
|
+
})
|
|
471
|
+
|
|
470
472
|
it('requires the private input-owner key for pending decisions and exact draft state', async () => {
|
|
471
473
|
const { connection, harness, tell } = await setup()
|
|
472
474
|
await tell('ready', { nativeSessionId: '/native.jsonl' })
|
|
@@ -24,25 +24,27 @@ import { HarnessError, invalid } from '../core/errors'
|
|
|
24
24
|
import { newId, now } from '../core/fs'
|
|
25
25
|
import { prependSearchPath, searchPath } from '../core/path'
|
|
26
26
|
import type { BrokerSession, ConnectedApps } from '../mcp/service'
|
|
27
|
-
import type { CallerContext } from '../
|
|
28
|
-
import { dispatch } from '../
|
|
27
|
+
import type { CallerContext } from '../core/caller'
|
|
28
|
+
import { dispatch } from '../tools/dispatch'
|
|
29
|
+
import { materialise, type HarnessChoice } from '../tree/materialise'
|
|
30
|
+
import type { TreeTrust } from '../tree/trust'
|
|
29
31
|
import type { AttachParams, PluginScope } from '../plugins/api'
|
|
30
32
|
import type { Loading, Plugins } from '../plugins/plugins'
|
|
31
33
|
import type { Plugin } from '../plugins/types'
|
|
32
34
|
import type { Hub } from '../hub/hub'
|
|
33
|
-
import { compile, copyToRun,
|
|
35
|
+
import { compile, copyToRun, record, render, saveForSession, type Launch } from '../prompt/compile'
|
|
34
36
|
import type { RunRecord } from '../sessions/contracts'
|
|
35
37
|
import { isActiveStatus } from '../sessions/contracts'
|
|
36
38
|
import type { ToolPolicy } from '../sessions/policy'
|
|
37
39
|
import type { ActiveRun, RunRegistry } from '../sessions/registry'
|
|
38
40
|
import { findRun, loadRun, saveRun, updateRun } from '../sessions/runStore'
|
|
39
|
-
import { resources } from '../skills/registry'
|
|
40
41
|
import type { ToolSpec } from '../tools/types'
|
|
41
42
|
import { APPS_COMMAND, WIKI_COMMAND } from './cli'
|
|
42
43
|
import { SOCKET_ENV, TOKEN_ENV } from './client'
|
|
43
44
|
import { TerminalController, type PolicyReply } from './controller'
|
|
44
45
|
import { resolvePiCommand, terminalCommandFiles } from './launch'
|
|
45
46
|
import { SessionSocket, socketPath } from './socket'
|
|
47
|
+
import { harnessLogInfo, readHarnessLogChunk } from './logs'
|
|
46
48
|
import { listTerminalTrajectoryRuns, readTerminalTrajectory } from './trajectory'
|
|
47
49
|
|
|
48
50
|
/** Identity of the exact resource decision that this terminal was prepared with. */
|
|
@@ -86,6 +88,8 @@ export interface TerminalPrepareInput {
|
|
|
86
88
|
readOnly?: boolean
|
|
87
89
|
/** What the application adds to the instructions, and which of its tools it describes. */
|
|
88
90
|
launch?: Launch
|
|
91
|
+
/** The harness tree the session runs; its version is pinned for the session. */
|
|
92
|
+
harness?: HarnessChoice | null
|
|
89
93
|
/** Give the session the connected-apps broker. Defaults to yes. */
|
|
90
94
|
connections?: boolean
|
|
91
95
|
/** Fields copied onto each run this session records. */
|
|
@@ -111,6 +115,8 @@ interface Live {
|
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
export interface TerminalsOptions {
|
|
118
|
+
/** Which plugin code in harness trees a person trusted. */
|
|
119
|
+
trust?: TreeTrust | null
|
|
114
120
|
subagents?: Subagents
|
|
115
121
|
resolveCapabilities?: (caller: CallerContext, tools: readonly string[], cwd: string) => Promise<CapabilitySet>
|
|
116
122
|
env: HarnessEnv
|
|
@@ -143,6 +149,8 @@ const READ_ONLY_TOOLS = new Set(['read', 'grep', 'find', 'ls'])
|
|
|
143
149
|
|
|
144
150
|
export class Terminals {
|
|
145
151
|
private readonly sessions = new Map<string, Live>()
|
|
152
|
+
/** The harness tree each live session runs, for loading its skills. */
|
|
153
|
+
private readonly treeDirs = new Map<string, string>()
|
|
146
154
|
private readonly socket: SessionSocket
|
|
147
155
|
private readonly assets: TerminalAssets
|
|
148
156
|
/** A session's `/accounts` switch, taken once its Pi process has ended. */
|
|
@@ -166,7 +174,7 @@ export class Terminals {
|
|
|
166
174
|
}
|
|
167
175
|
})
|
|
168
176
|
this.socket = new SessionSocket(socketPath(options.stateDir), {
|
|
169
|
-
memory: (caller, call, params) => dispatch(options.env, caller, call, params),
|
|
177
|
+
memory: (caller, call, params) => dispatch(options.env, caller, call, params, { treeDir: (caller.sessionId && this.treeDirs.get(caller.sessionId)) || null }),
|
|
170
178
|
apps: (session, call, params) => this.appsCall(session, call, params),
|
|
171
179
|
plugins: (grant, call, params, control) => this.pluginsCall(grant.sessionId, call, params, control),
|
|
172
180
|
accounts: (grant, call, params, control) => this.accountsCall(grant.sessionId, call, params, control)
|
|
@@ -512,8 +520,10 @@ export class Terminals {
|
|
|
512
520
|
const broker = input.connections === false ? null : apps.grant({ sessionId, runId: null, caller, readOnly: !!input.readOnly, depth: 0 })
|
|
513
521
|
// The session keeps these instructions; each of its runs records a copy, and a memory
|
|
514
522
|
// block that changes later is sent along as a note.
|
|
515
|
-
const
|
|
516
|
-
|
|
523
|
+
const tree = input.harness ? materialise(root, input.harness, { trust: this.options.trust ?? null }) : null
|
|
524
|
+
if (tree) this.treeDirs.set(sessionId, tree.dir)
|
|
525
|
+
const pack = compile(env, caller, input.launch ?? {}, tree)
|
|
526
|
+
record(root, pack)
|
|
517
527
|
mkdirSync(this.historyDir(input), { recursive: true })
|
|
518
528
|
saveForSession(input.sessionFolder, pack)
|
|
519
529
|
|
|
@@ -543,6 +553,16 @@ export class Terminals {
|
|
|
543
553
|
broker,
|
|
544
554
|
terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected(), control: async (call, params) => {
|
|
545
555
|
if (call.startsWith('terminal.subagents.')) return this.subagentsCall(live, call, params)
|
|
556
|
+
if (call === 'terminal.logs.info') return harnessLogInfo(this.options.stateDir)
|
|
557
|
+
if (call === 'terminal.logs.read') {
|
|
558
|
+
const { snapshotSize, offset } = params
|
|
559
|
+
if (typeof snapshotSize !== 'number' || !Number.isSafeInteger(snapshotSize) || snapshotSize < 0 || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset >= snapshotSize) {
|
|
560
|
+
throw invalid('A valid diagnostics snapshot size and byte offset are required.')
|
|
561
|
+
}
|
|
562
|
+
const current = harnessLogInfo(this.options.stateDir).size
|
|
563
|
+
if (current === null || snapshotSize > current) throw invalid('The diagnostics snapshot is no longer available.')
|
|
564
|
+
return readHarnessLogChunk(this.options.stateDir, snapshotSize, offset)
|
|
565
|
+
}
|
|
546
566
|
if (call.startsWith('terminal.trajectory.')) return this.trajectoryCall(live, call, params)
|
|
547
567
|
if (call === 'terminal.permissions.get') {
|
|
548
568
|
const policy = this.options.approvals.read()
|
|
@@ -573,10 +593,9 @@ export class Terminals {
|
|
|
573
593
|
if (loading) {
|
|
574
594
|
// Pi's own discovery is off: the terminal loads what the harness found and nothing else.
|
|
575
595
|
args.push('--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes')
|
|
576
|
-
const
|
|
577
|
-
for (const path of [...
|
|
578
|
-
for (const path of
|
|
579
|
-
for (const path of [...enabled.promptPaths, ...loading.promptPaths]) args.push('--prompt-template', path)
|
|
596
|
+
for (const path of [...loading.extensionPaths, ...loading.builtins, ...(tree?.extensionPaths ?? [])]) args.push('--extension', path)
|
|
597
|
+
for (const path of [...(tree?.skillPaths ?? []), ...loading.skillPaths]) args.push('--skill', path)
|
|
598
|
+
for (const path of loading.promptPaths) args.push('--prompt-template', path)
|
|
580
599
|
for (const path of loading.themePaths) args.push('--theme', path)
|
|
581
600
|
}
|
|
582
601
|
// Last, so its gate sees a tool call as every other extension left it.
|
|
@@ -615,10 +634,9 @@ export class Terminals {
|
|
|
615
634
|
const runId = input.runId || newId()
|
|
616
635
|
if (findRun(root, runId)) throw invalid('This run already exists.')
|
|
617
636
|
saveRun(root, this.runRecord(live, runId, input.text))
|
|
618
|
-
const note = memoryDelta(env, live.input.sessionFolder, { ...live.caller, runId })
|
|
619
637
|
copyToRun(root, live.input.sessionFolder, runId)
|
|
620
638
|
try {
|
|
621
|
-
await live.controller.send(runId,
|
|
639
|
+
await live.controller.send(runId, input.text)
|
|
622
640
|
} catch (error) {
|
|
623
641
|
updateRun(root, runId, (run) => {
|
|
624
642
|
if (!isActiveStatus(run.status)) return
|