@xingular/harness 0.1.7 → 2.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 +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +100 -10
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +6 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +2 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
package/src/memory/wiki.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs'
|
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
|
|
8
8
|
import { noteChange } from '../core/changes'
|
|
9
|
+
import { logLine } from '../core/bus'
|
|
9
10
|
import { HarnessError, notFound } from '../core/errors'
|
|
10
11
|
import * as frontmatter from '../core/frontmatter'
|
|
11
12
|
import { atomicWrite, now } from '../core/fs'
|
|
@@ -216,7 +217,7 @@ export function searchEntries(root: string, query: string, options: SearchOption
|
|
|
216
217
|
try {
|
|
217
218
|
ranked = wikiIndex.search(root, query, limit * 4, boost)
|
|
218
219
|
} catch (error) {
|
|
219
|
-
|
|
220
|
+
logLine(`[index] search fell back to files: ${error instanceof Error ? error.message : String(error)}`)
|
|
220
221
|
const needle = query.trim().toLowerCase()
|
|
221
222
|
if (!needle) return []
|
|
222
223
|
const rank = (entry: IndexedEntry): number => (entry.namespace === boost ? 0 : 2) + (entry.title.toLowerCase().includes(needle) ? 0 : 1)
|
package/src/memory/wikiIndex.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import type { DatabaseSync } from 'node:sqlite'
|
|
5
5
|
|
|
6
|
+
import { logLine } from '../core/bus'
|
|
6
7
|
import { all, dbPath, deleteIndex, ftsQuery, run, transaction, withIndex } from '../core/index'
|
|
7
8
|
import { readAllRuns, syncRuns, upsertRun } from '../sessions/runIndex'
|
|
8
9
|
import { scan } from './wiki'
|
|
@@ -55,7 +56,7 @@ export function reindexQuietly(root: string): void {
|
|
|
55
56
|
try {
|
|
56
57
|
reindexWiki(root)
|
|
57
58
|
} catch (error) {
|
|
58
|
-
|
|
59
|
+
logLine(`[index] reindex failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -81,7 +82,7 @@ export function ensure(root: string, after?: () => void): number {
|
|
|
81
82
|
try {
|
|
82
83
|
syncRuns(root)
|
|
83
84
|
} catch (error) {
|
|
84
|
-
|
|
85
|
+
logLine(`[index] ${error instanceof Error ? error.message : String(error)}`)
|
|
85
86
|
}
|
|
86
87
|
} else {
|
|
87
88
|
pages = rebuild(root)
|
package/src/plugins/builtin.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// exists; none of it is loaded through Pi's extension system.
|
|
3
3
|
import { CONNECTION_TOOLS, NATIVE_GROUP } from '../prompt/compile'
|
|
4
4
|
import { ASK_USER } from '../tools/askUser'
|
|
5
|
+
import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from '../sessions/subagents'
|
|
5
6
|
import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
|
|
6
|
-
import { MEMORY_TOOLS, RECALL_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
|
|
7
|
+
import { MEMORY_TOOLS, RECALL_TOOLS, TRAJECTORY_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
|
|
7
8
|
import { FIRST_PARTY_PLUGINS } from './firstParty'
|
|
8
9
|
import type { PluginDescriptor } from './types'
|
|
9
10
|
|
|
@@ -36,6 +37,7 @@ export const INCLUDED_PLUGIN: PluginDescriptor = { id: 'included', title: 'Inclu
|
|
|
36
37
|
|
|
37
38
|
export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
|
|
38
39
|
PI_PLUGIN,
|
|
40
|
+
{ id: 'subagents', title: 'Subagents', description: 'Isolated child runs of the same caller, bounded by parent capabilities and nesting depth.', required: 'Availability is selected by the session host; child runs retain the parent permission boundary.', tools: [{ name: SPAWN_SUBAGENT, description: 'Start a subagent' }, { name: WAIT_SUBAGENTS, description: 'Wait for subagents' }] },
|
|
39
41
|
{
|
|
40
42
|
id: 'memory',
|
|
41
43
|
title: 'Memory',
|
|
@@ -59,7 +61,7 @@ export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
|
|
|
59
61
|
],
|
|
60
62
|
toolGroups: ['Knowledge']
|
|
61
63
|
},
|
|
62
|
-
{ id: 'history', title: 'History', description: 'Search
|
|
64
|
+
{ id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]) },
|
|
63
65
|
{
|
|
64
66
|
id: 'plugin-proposals',
|
|
65
67
|
title: 'Plugin proposals',
|
|
@@ -73,7 +73,7 @@ describe('the plugin list', () => {
|
|
|
73
73
|
['app-tasks', 'application', ['tool:start_task:on']],
|
|
74
74
|
['web', 'application', ['tool:web_search:on', 'tool:web_fetch:on']]
|
|
75
75
|
])
|
|
76
|
-
expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'wiki'])
|
|
76
|
+
expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'subagents', 'wiki'])
|
|
77
77
|
|
|
78
78
|
const kit = plugins.find((plugin) => plugin.id === 'package:review-kit')!
|
|
79
79
|
// It was there when trust was first asked per package, so it was taken over as trusted.
|
package/src/server/methods.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { comparison, compile, CONTEXT_PROFILES, load, profiles, render, setProfi
|
|
|
33
33
|
import { selectHistory } from '../sessions/history'
|
|
34
34
|
import { runsSince } from '../sessions/runIndex'
|
|
35
35
|
import { findRun, listRuns, loadRun, pendingDecisions, readEvents, runFamily, snapshot } from '../sessions/runStore'
|
|
36
|
+
import type { SubagentInput, SubagentSelection } from '../sessions/subagents'
|
|
36
37
|
import type { SessionRunInput } from '../sessions/sessions'
|
|
37
38
|
import * as registry from '../skills/registry'
|
|
38
39
|
import type { TerminalPrepareInput } from '../terminal/terminals'
|
|
@@ -134,6 +135,7 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
134
135
|
'connections.setAgentScope': (params: Params) => apps.setAgentScope(params.creator ?? {}, text(params, 'agentId'), Array.isArray(params.ids) ? params.ids : []),
|
|
135
136
|
'connections.removeAgentScope': (params: Params) => apps.removeAgentScope(text(params, 'agentId')),
|
|
136
137
|
'connections.revision': () => apps.connections.revision(),
|
|
138
|
+
'connections.appClients': () => apps.appClients(),
|
|
137
139
|
'connections.validateScope': (params: Params) => validateScopeIds(apps.connections.list(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') }, Array.isArray(params.ids) ? params.ids : []),
|
|
138
140
|
|
|
139
141
|
// ---- Wiki: what a person browses and edits ---------------------------------------------------
|
|
@@ -336,6 +338,10 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
336
338
|
},
|
|
337
339
|
|
|
338
340
|
// ---- Sessions ----------------------------------------------------------------------------------
|
|
341
|
+
'subagents.spawn': (params: Params) => harness.subagents.spawn(params as SubagentInput),
|
|
342
|
+
'subagents.wait': (params: Params) => harness.subagents.wait(params as SubagentSelection),
|
|
343
|
+
'subagents.results': (params: Params) => harness.subagents.results(params as SubagentSelection),
|
|
344
|
+
'sessions.spawnSubagent': (params: Params) => sessions.spawnSubagent(params as SubagentInput),
|
|
339
345
|
'sessions.run': (params: Params) => sessions.run(params as SessionRunInput),
|
|
340
346
|
'sessions.history': (params: Params) => ({ sessionFile: selectHistory({ sessionDir: text(params, 'sessionDir'), resume: params.resume ?? null }) }),
|
|
341
347
|
|
|
@@ -59,6 +59,23 @@ function host(hostCalls: Record<string, (params: any) => unknown> = {}) {
|
|
|
59
59
|
const idle = (): PiSessionLike => ({ sessionId: 'native-1', subscribe: () => () => undefined, prompt: async () => undefined, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined })
|
|
60
60
|
|
|
61
61
|
describe('the harness served to a host', () => {
|
|
62
|
+
it('starts native subagents over RPC using the live parent launch', async () => {
|
|
63
|
+
const { transport, request } = host()
|
|
64
|
+
const harness = createHarness({
|
|
65
|
+
dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(),
|
|
66
|
+
sessionFactory: async ({ params }) => ({ ...idle(), ...(params.runId === 'rpc-parent' ? { prompt: async () => new Promise<void>(() => undefined) } : {}) })
|
|
67
|
+
})
|
|
68
|
+
open.push(harness)
|
|
69
|
+
serve(harness, transport)
|
|
70
|
+
harness.accounts.create('Main', 'apiKey')
|
|
71
|
+
await request('sessions.run', { run: { id: 'rpc-parent', sessionId: 'opaque-session' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'sessions'), text: 'Parent', caller: { agentId: 'caller' }, tools: { allow: ['read'] }, subagents: {} })
|
|
72
|
+
const child = await request('subagents.spawn', { parentRunId: 'rpc-parent', brief: 'Review', tools: ['read'] })
|
|
73
|
+
expect(await request('subagents.wait', { parentRunId: 'rpc-parent', runIds: [child.runId] })).toMatchObject([{ runId: child.runId, status: 'completed' }])
|
|
74
|
+
const { loadRun } = await import('../sessions/runStore')
|
|
75
|
+
expect(loadRun(harness.env.root, child.runId)).toMatchObject({ parentRunId: 'rpc-parent', relation: 'subagent', agentId: 'caller', status: 'completed' })
|
|
76
|
+
await request('runs.interrupt', { runId: 'rpc-parent' })
|
|
77
|
+
})
|
|
78
|
+
|
|
62
79
|
it('answers by method name, reports failures with their code, and forwards events and notices', async () => {
|
|
63
80
|
const { transport, request, events, notices } = host()
|
|
64
81
|
const harness = createHarness({ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), sessionFactory: async () => idle() })
|
|
@@ -67,7 +84,9 @@ describe('the harness served to a host', () => {
|
|
|
67
84
|
|
|
68
85
|
const status = await request('harness.status')
|
|
69
86
|
expect(status).toMatchObject({ dataRoot: harness.env.root, productName: 'Xingular Harness', activeRuns: [] })
|
|
70
|
-
expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'wiki.pages', 'terminal.prepare', 'skills.list', 'skills.import', 'connections.list', 'harness.shutdown']))
|
|
87
|
+
expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'sessions.spawnSubagent', 'subagents.spawn', 'subagents.wait', 'subagents.results', 'wiki.pages', 'terminal.prepare', 'skills.list', 'skills.import', 'connections.list', 'harness.shutdown']))
|
|
88
|
+
|
|
89
|
+
await expect(request('sessions.spawnSubagent', { parentRunId: 'missing', brief: 'Review' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
71
90
|
|
|
72
91
|
const overview = await request('accounts.create', { label: 'Main', authKind: 'apiKey' })
|
|
73
92
|
expect(overview.accounts).toHaveLength(1)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { Bus } from '../core/bus'
|
|
7
|
+
import { Approvals, grant } from './approvals'
|
|
8
|
+
|
|
9
|
+
const folders: string[] = []
|
|
10
|
+
afterEach(() => { for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true }) })
|
|
11
|
+
|
|
12
|
+
function state() {
|
|
13
|
+
const folder = mkdtempSync(join(tmpdir(), 'harness-approvals-'))
|
|
14
|
+
folders.push(folder)
|
|
15
|
+
const bus = new Bus()
|
|
16
|
+
return { folder, approvals: new Approvals(folder, bus) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('standing approval policy', () => {
|
|
20
|
+
it('loads old files with no global tool grants', () => {
|
|
21
|
+
const { folder, approvals } = state()
|
|
22
|
+
writeFileSync(join(folder, 'approvals.json'), JSON.stringify({ auto: { pi: true }, remembered: { old: ['write'] } }))
|
|
23
|
+
|
|
24
|
+
expect(approvals.read()).toEqual({ auto: { pi: true }, globalTools: {}, remembered: { old: ['write'] } })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('persists a profile-wide tool grant, while read-only and other profiles stay isolated', () => {
|
|
28
|
+
const { folder, approvals } = state()
|
|
29
|
+
approvals.allowTool('pi', 'bash')
|
|
30
|
+
const policy = approvals.read()
|
|
31
|
+
|
|
32
|
+
expect(policy.globalTools).toEqual({ pi: ['bash'] })
|
|
33
|
+
expect(grant(policy, 'pi', 'terminal-one', 'bash')).toBe('remembered')
|
|
34
|
+
expect(grant(policy, 'pi', 'terminal-two', 'bash')).toBe('remembered')
|
|
35
|
+
expect(grant(policy, 'chat', 'terminal-two', 'bash')).toBeNull()
|
|
36
|
+
expect(grant(policy, 'pi', 'terminal-two', 'bash', { readOnly: true })).toBeNull()
|
|
37
|
+
expect(JSON.parse(readFileSync(join(folder, 'approvals.json'), 'utf8'))).toMatchObject({ globalTools: { pi: ['bash'] } })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('revokes a global tool grant without changing the full switch or session grants', () => {
|
|
41
|
+
const { approvals } = state()
|
|
42
|
+
approvals.setAuto('pi', true)
|
|
43
|
+
approvals.remember('one-session', 'write')
|
|
44
|
+
approvals.allowTool('pi', 'bash')
|
|
45
|
+
|
|
46
|
+
expect(approvals.revokeTool('pi', 'bash')).toEqual({ auto: { pi: true }, globalTools: {}, remembered: { 'one-session': ['write'] } })
|
|
47
|
+
expect(approvals.read().globalTools).toEqual({})
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// What the user has chosen to stop being asked about: an "always approve" switch
|
|
2
|
-
//
|
|
1
|
+
// What the user has chosen to stop being asked about: an "always approve" switch, tools allowed
|
|
2
|
+
// across a harness state directory, and tools remembered for one session.
|
|
3
3
|
import { chmodSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
|
|
@@ -14,6 +14,8 @@ export const APPROVAL_KEYS = ['chat', 'pi'] as const
|
|
|
14
14
|
export interface ApprovalPolicy {
|
|
15
15
|
/** Switch key → every approval is granted without asking. */
|
|
16
16
|
auto: Record<string, boolean>
|
|
17
|
+
/** Approval key → tools allowed across sessions that share this local state directory. */
|
|
18
|
+
globalTools: Record<string, string[]>
|
|
17
19
|
/** Session id → approval targets always allowed there. */
|
|
18
20
|
remembered: Record<string, string[]>
|
|
19
21
|
}
|
|
@@ -34,13 +36,14 @@ export interface ApprovalFacts {
|
|
|
34
36
|
/** Why the standing choices grant this request, if they do. */
|
|
35
37
|
export function decide(policy: ApprovalPolicy, key: string, sessionId: string, target: string): AutomaticReason | null {
|
|
36
38
|
if (policy.auto[key]) return 'always'
|
|
39
|
+
if (policy.globalTools[key]?.includes(target)) return 'remembered'
|
|
37
40
|
return policy.remembered[sessionId]?.includes(target) ? 'remembered' : null
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function grant(policy: ApprovalPolicy, key: string, sessionId: string, target: string, facts: ApprovalFacts = {}): AutomaticReason | null {
|
|
41
44
|
if (facts.unattended) return null
|
|
42
45
|
// "Always approve" must not let a read-only session approve its way out of read-only mode.
|
|
43
|
-
return decide(facts.readOnly ? { auto: {}, remembered: policy.remembered } : policy, key, sessionId, target)
|
|
46
|
+
return decide(facts.readOnly ? { auto: {}, globalTools: {}, remembered: policy.remembered } : policy, key, sessionId, target)
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** A conversation's runs share one switch whatever model they use; other runs follow their own. */
|
|
@@ -55,7 +58,7 @@ export class Approvals {
|
|
|
55
58
|
|
|
56
59
|
read(): ApprovalPolicy {
|
|
57
60
|
const stored = readJson<Partial<ApprovalPolicy>>(this.path) ?? {}
|
|
58
|
-
return { auto: stored.auto ?? {}, remembered: stored.remembered ?? {} }
|
|
61
|
+
return { auto: stored.auto ?? {}, globalTools: stored.globalTools ?? {}, remembered: stored.remembered ?? {} }
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
private change(update: (policy: ApprovalPolicy) => void): ApprovalPolicy {
|
|
@@ -90,6 +93,25 @@ export class Approvals {
|
|
|
90
93
|
return this.change((policy) => void (policy.auto[key] = on))
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/** Always allow `target` for every session in this approval profile and state directory. */
|
|
97
|
+
allowTool(key: string, target: string): ApprovalPolicy {
|
|
98
|
+
if (!(APPROVAL_KEYS as readonly string[]).includes(key)) throw invalid('Unknown harness.')
|
|
99
|
+
if (!target.trim()) throw invalid('A tool is required.')
|
|
100
|
+
return this.change((policy) => {
|
|
101
|
+
const targets = (policy.globalTools[key] ??= [])
|
|
102
|
+
if (!targets.includes(target)) targets.push(target)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
revokeTool(key: string, target: string): ApprovalPolicy {
|
|
107
|
+
if (!(APPROVAL_KEYS as readonly string[]).includes(key)) throw invalid('Unknown harness.')
|
|
108
|
+
return this.change((policy) => {
|
|
109
|
+
const targets = policy.globalTools[key]?.filter((item) => item !== target) ?? []
|
|
110
|
+
if (targets.length) policy.globalTools[key] = targets
|
|
111
|
+
else delete policy.globalTools[key]
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
/** Always allow `target` in this session. */
|
|
94
116
|
remember(sessionId: string, target: string): ApprovalPolicy {
|
|
95
117
|
if (!target.trim()) throw invalid('A tool is required.')
|
|
@@ -56,6 +56,62 @@ async function setup() {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
describe('a whole reply, with a scripted model', () => {
|
|
59
|
+
it('joins async native children and persists one result receipt before the final reply', async () => {
|
|
60
|
+
const { harness, root, model, start, until, settled, of } = await setup()
|
|
61
|
+
const route = (context: { messages: Array<{ role: string; content: unknown }> }) => {
|
|
62
|
+
const users = context.messages.filter(message => message.role === 'user').map(message => typeof message.content === 'string' ? message.content : (message.content as Array<{ text?: string }>).map(part => part.text ?? '').join(''))
|
|
63
|
+
if (users.includes('native-child-brief')) return model.says('CHILD_EVIDENCE')
|
|
64
|
+
if (JSON.stringify(context.messages).includes('CHILD_EVIDENCE')) return model.says('Final with CHILD_EVIDENCE')
|
|
65
|
+
if (!context.messages.some(message => message.role === 'toolResult')) return model.calls(['spawn_subagent', { brief: 'native-child-brief' }])
|
|
66
|
+
return model.says('Draft before joining')
|
|
67
|
+
}
|
|
68
|
+
model.script(Array(8).fill(route))
|
|
69
|
+
const id = await start()
|
|
70
|
+
await until(() => settled(id))
|
|
71
|
+
expect(of(id).at(-1)?.type).toBe('completed')
|
|
72
|
+
expect(of(id).filter(event => event.type === 'message.completed').at(-1)?.payload).toMatchObject({ text: 'Final with CHILD_EVIDENCE' })
|
|
73
|
+
const { sessionFile } = await import('../prompt/compile')
|
|
74
|
+
const { readFileSync } = await import('node:fs')
|
|
75
|
+
const entries = readFileSync(sessionFile(join(root, 'sessions', 'a1'))!, 'utf8').trim().split('\n').map(line => JSON.parse(line))
|
|
76
|
+
expect(entries.filter(entry => entry.customType === 'xingular-subagent-results')).toHaveLength(1)
|
|
77
|
+
expect(model.requests()).toBeLessThanOrEqual(4)
|
|
78
|
+
expect(harness.runs.activeRunIds()).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('uses blocking native results without adding an unsolicited duplicate delivery', async () => {
|
|
82
|
+
const { root, model, start, until, settled, of } = await setup()
|
|
83
|
+
model.script([model.calls(['spawn_subagent', { brief: 'Find proof', wait: true }]), model.says('INLINE_CHILD_PROOF'), model.says('Final')])
|
|
84
|
+
const id = await start()
|
|
85
|
+
await until(() => settled(id))
|
|
86
|
+
expect(of(id).at(-1)?.type).toBe('completed')
|
|
87
|
+
expect(model.lastToolResults().join('')).toContain('INLINE_CHILD_PROOF')
|
|
88
|
+
const { sessionFile } = await import('../prompt/compile')
|
|
89
|
+
const { readFileSync } = await import('node:fs')
|
|
90
|
+
expect(readFileSync(sessionFile(join(root, 'sessions', 'a1'))!, 'utf8')).not.toContain('"customType":"xingular-subagent-results"')
|
|
91
|
+
expect(model.requests()).toBe(3)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('interrupts a parent waiting on a child question without deadlocking SDK abort', async () => {
|
|
95
|
+
const { harness, model, start, until, settled } = await setup()
|
|
96
|
+
const ai = await import('@earendil-works/pi-ai')
|
|
97
|
+
model.script([
|
|
98
|
+
model.calls(['spawn_subagent', { brief: 'Ask for a decision', wait: true }]),
|
|
99
|
+
ai.fauxAssistantMessage([ai.fauxToolCall('ask_user', { questions: [{ summary: 'A decision', question: 'Which approach?' }] })], { stopReason: 'toolUse' })
|
|
100
|
+
])
|
|
101
|
+
let childRunId: string | null = null
|
|
102
|
+
harness.runs.listen((event, run) => { if (event.type === 'question.requested' && run?.relation === 'subagent') childRunId = event.runId })
|
|
103
|
+
const id = await start()
|
|
104
|
+
await until(() => childRunId !== null || settled(id))
|
|
105
|
+
expect(childRunId).not.toBeNull()
|
|
106
|
+
await harness.runs.interrupt(id)
|
|
107
|
+
const { loadRun } = await import('./runStore')
|
|
108
|
+
expect(loadRun(harness.env.root, id).status).toBe('interrupted')
|
|
109
|
+
expect(loadRun(harness.env.root, childRunId!).status).toBe('interrupted')
|
|
110
|
+
const { readEvents } = await import('./runStore')
|
|
111
|
+
expect(readEvents(harness.env.root, childRunId!).some(event => event.type === 'question.answered')).toBe(true)
|
|
112
|
+
expect(harness.runs.activeRunIds()).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
|
|
59
115
|
it('dispatches a registered command with notes without making a provider request', async () => {
|
|
60
116
|
const { harness, model, start, until, settled, of } = await setup()
|
|
61
117
|
const account = harness.accounts.context(harness.accounts.resolve(null, null))
|
|
@@ -16,7 +16,7 @@ function directChildren(root: string, runId: string): RunSummary[] {
|
|
|
16
16
|
try {
|
|
17
17
|
return runFamily(root, runId).runs
|
|
18
18
|
.map(({ run }) => run)
|
|
19
|
-
.filter((run) => run.parentRunId === runId)
|
|
19
|
+
.filter((run) => run.parentRunId === runId && run.relation !== 'subagent')
|
|
20
20
|
.map((run) => ({ id: run.id, status: run.status, goal: run.goal, terminalReason: run.terminalReason }))
|
|
21
21
|
} catch {
|
|
22
22
|
return []
|
package/src/sessions/registry.ts
CHANGED
|
@@ -91,6 +91,7 @@ export class RunRegistry {
|
|
|
91
91
|
private readonly approvals = new Map<string, { runId: string; resolve: (decision: ApprovalDecision) => void }>()
|
|
92
92
|
private readonly questions = new Map<string, PendingQuestion>()
|
|
93
93
|
private readonly nativeCleared = new Map<string, string>()
|
|
94
|
+
private readonly waiters = new Map<string, Array<() => void>>()
|
|
94
95
|
private readonly stopping = new Set<string>()
|
|
95
96
|
private readonly listeners: RunListener[] = []
|
|
96
97
|
|
|
@@ -130,6 +131,7 @@ export class RunRegistry {
|
|
|
130
131
|
sequence = 0
|
|
131
132
|
}
|
|
132
133
|
let finished = false
|
|
134
|
+
let finishing = false
|
|
133
135
|
const interruptHandlers: Array<() => Promise<void> | void> = []
|
|
134
136
|
let steerHandler: ((text: string) => Promise<void> | void) | null = null
|
|
135
137
|
const waitingStatus = (): string =>
|
|
@@ -138,7 +140,7 @@ export class RunRegistry {
|
|
|
138
140
|
const requestQuestion = (input: QuestionInput, batch?: QuestionBatchMetadata, statusEvent = true): Promise<QuestionAnswer> => {
|
|
139
141
|
const { requestId, ...payload } = input
|
|
140
142
|
const questionId = requestId ?? randomUUID()
|
|
141
|
-
if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
|
|
143
|
+
if (finished || finishing || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
|
|
142
144
|
if (this.questions.has(questionId)) throw new HarnessError('invalid_request', 'This question id is already pending.')
|
|
143
145
|
return new Promise<QuestionAnswer>((resolve) => {
|
|
144
146
|
this.questions.set(questionId, {
|
|
@@ -186,7 +188,7 @@ export class RunRegistry {
|
|
|
186
188
|
// What the user already allowed is known here; if it cannot be read, ask as usual.
|
|
187
189
|
const record = findRun(this.options.root(), runId)
|
|
188
190
|
const automatic = record && this.options.automatic && standing !== 'none' ? await this.options.automatic(record, input.target, input.operation, standing).catch(() => null) : null
|
|
189
|
-
if (finished) return 'denied'
|
|
191
|
+
if (finished || finishing) return 'denied'
|
|
190
192
|
if (this.nativeCleared.delete(approvalId)) return 'native'
|
|
191
193
|
if (automatic) {
|
|
192
194
|
// Marked on the request too, so nothing treats it as waiting on someone.
|
|
@@ -219,22 +221,26 @@ export class RunRegistry {
|
|
|
219
221
|
if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some((id) => this.questions.has(id))) {
|
|
220
222
|
throw new HarnessError('invalid_request', 'Question ids in a batch must be unique and not already pending.')
|
|
221
223
|
}
|
|
222
|
-
if (finished) return Promise.resolve(inputs.map(() => ({ selected: [] })))
|
|
224
|
+
if (finished || finishing) return Promise.resolve(inputs.map(() => ({ selected: [] })))
|
|
223
225
|
const batchId = randomUUID()
|
|
224
226
|
const answers = inputs.map((input, index) => requestQuestion(input, { batchId, batchIndex: index + 1, batchCount: inputs.length }, false))
|
|
225
227
|
emit('status.changed', { status: 'waiting_for_input' })
|
|
226
228
|
return Promise.all(answers)
|
|
227
229
|
},
|
|
228
230
|
finish: (outcome, failure) => {
|
|
229
|
-
if (finished) return
|
|
231
|
+
if (finished || finishing) return
|
|
232
|
+
finishing = true
|
|
233
|
+
// Persist cancellation receipts while emit is still open, so every interface clears them.
|
|
234
|
+
for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
|
|
235
|
+
for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
|
|
230
236
|
if (outcome === 'failed') emit('failed', failure ?? { code: 'failed', message: 'The run failed.' })
|
|
231
237
|
else if (outcome === 'interrupted') emit('status.changed', { status: 'interrupted' })
|
|
232
238
|
else emit('completed', {})
|
|
233
239
|
finished = true
|
|
234
240
|
this.runs.delete(runId)
|
|
241
|
+
for (const resolve of [...(this.waiters.get(runId) ?? [])]) resolve()
|
|
242
|
+
this.waiters.delete(runId)
|
|
235
243
|
for (const [id, owner] of this.nativeCleared) if (owner === runId) this.nativeCleared.delete(id)
|
|
236
|
-
for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
|
|
237
|
-
for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
|
|
238
244
|
try {
|
|
239
245
|
this.options.ended?.(runId, sessionId)
|
|
240
246
|
} catch (error) {
|
|
@@ -245,6 +251,8 @@ export class RunRegistry {
|
|
|
245
251
|
return finished
|
|
246
252
|
},
|
|
247
253
|
interrupt: async () => {
|
|
254
|
+
// Release approvals/questions before SDK abort waits for their blocked tools.
|
|
255
|
+
run.finish('interrupted')
|
|
248
256
|
for (const handler of interruptHandlers) {
|
|
249
257
|
try {
|
|
250
258
|
await handler()
|
|
@@ -252,7 +260,6 @@ export class RunRegistry {
|
|
|
252
260
|
log(`an interrupt handler failed for run ${runId}`, error instanceof Error ? error.message : String(error))
|
|
253
261
|
}
|
|
254
262
|
}
|
|
255
|
-
run.finish('interrupted')
|
|
256
263
|
}
|
|
257
264
|
}
|
|
258
265
|
|
|
@@ -278,6 +285,30 @@ export class RunRegistry {
|
|
|
278
285
|
return false
|
|
279
286
|
}
|
|
280
287
|
|
|
288
|
+
/** Wait for a recorded run to settle without polling storage or retaining an event listener. */
|
|
289
|
+
wait(runId: string, signal?: AbortSignal): Promise<void> {
|
|
290
|
+
const record = findRun(this.options.root(), runId)
|
|
291
|
+
if (!record) throw new HarnessError('not_found', 'Run was not found.')
|
|
292
|
+
if (!isActiveStatus(record.status)) return Promise.resolve()
|
|
293
|
+
if (!this.runs.has(runId)) throw new HarnessError('run_not_active', 'This run is not active in this process.')
|
|
294
|
+
if (signal?.aborted) return Promise.reject(new HarnessError('run_not_active', 'The wait was interrupted.'))
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const cleanup = () => {
|
|
297
|
+
signal?.removeEventListener('abort', abort)
|
|
298
|
+
const pending = this.waiters.get(runId)
|
|
299
|
+
const index = pending?.indexOf(done) ?? -1
|
|
300
|
+
if (index >= 0) pending!.splice(index, 1)
|
|
301
|
+
if (!pending?.length) this.waiters.delete(runId)
|
|
302
|
+
}
|
|
303
|
+
const done = () => { cleanup(); resolve() }
|
|
304
|
+
const abort = () => { cleanup(); reject(new HarnessError('run_not_active', 'The wait was interrupted.')) }
|
|
305
|
+
const pending = this.waiters.get(runId) ?? []
|
|
306
|
+
pending.push(done)
|
|
307
|
+
this.waiters.set(runId, pending)
|
|
308
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
281
312
|
/** The live run with this id, for a tool or broker that reports into it. */
|
|
282
313
|
active(runId: string): ActiveRun | null {
|
|
283
314
|
return this.runs.get(runId) ?? null
|
package/src/sessions/runStore.ts
CHANGED
|
@@ -74,6 +74,20 @@ export function findRun(root: string, runId: string): RunRecord | null {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** A parent's reserved child IDs survive a crash before the spawn tool returns. */
|
|
78
|
+
export function recordSubagent(root: string, parentRunId: string, childRunId: string): void {
|
|
79
|
+
loadRun(root, parentRunId)
|
|
80
|
+
const ids = recordedSubagents(root, parentRunId)
|
|
81
|
+
if (!ids.includes(childRunId)) writeJson(join(runDir(root, parentRunId), 'subagents.json'), [...ids, childRunId])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function recordedSubagents(root: string, parentRunId: string): string[] {
|
|
85
|
+
const value = readJson(join(runDir(root, parentRunId), 'subagents.json'))
|
|
86
|
+
if (value === null) return []
|
|
87
|
+
if (!Array.isArray(value) || value.some(id => typeof id !== 'string')) throw new Error('Recorded subagent ownership is unreadable.')
|
|
88
|
+
return value as string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
/** Changes a run's record. A settled run keeps its outcome whatever the change says. */
|
|
78
92
|
export function updateRun(root: string, runId: string, update: (record: RunRecord) => void): void {
|
|
79
93
|
const record = readRecord(recordPath(root, runId))
|
package/src/sessions/runner.ts
CHANGED
|
@@ -23,6 +23,9 @@ export type Effort = (typeof EFFORTS)[number]
|
|
|
23
23
|
/** What one turn needs once its prompt is compiled and its tools are chosen. */
|
|
24
24
|
export interface TurnParams {
|
|
25
25
|
runId: string
|
|
26
|
+
/** Child runs may not gain tools registered by extensions outside their snapshot. */
|
|
27
|
+
restrictTools?: boolean
|
|
28
|
+
settle?: () => Promise<void>
|
|
26
29
|
cwd: string
|
|
27
30
|
sessionDir: string
|
|
28
31
|
/** The native history to open, or `null` to start a fresh one. */
|
|
@@ -77,9 +80,11 @@ export type PiEvent =
|
|
|
77
80
|
|
|
78
81
|
export interface PiSessionLike {
|
|
79
82
|
sessionId: string
|
|
83
|
+
sessionManager?: { getBranch(): unknown[] }
|
|
84
|
+
thinkingLevel?: Effort
|
|
80
85
|
systemPrompt?: string
|
|
81
86
|
extensionCommands?: readonly string[]
|
|
82
|
-
model?: { input: string[]; contextWindow?: number }
|
|
87
|
+
model?: { input: string[]; contextWindow?: number; id?: string; provider?: string }
|
|
83
88
|
getActiveToolNames?(): string[]
|
|
84
89
|
getAllTools?(): Array<{ name: string; description?: string; parameters?: unknown }>
|
|
85
90
|
subscribe(listener: (event: PiEvent) => void): () => void
|
|
@@ -460,6 +465,7 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
|
|
|
460
465
|
accepting = false
|
|
461
466
|
session.clearQueue?.()
|
|
462
467
|
}
|
|
468
|
+
if (!aborted && !lastError) await params.settle?.()
|
|
463
469
|
if (aborted) run.finish('interrupted')
|
|
464
470
|
else if (lastError) run.finish('failed', { code: 'provider_error', message: lastError })
|
|
465
471
|
else run.finish('completed')
|
|
@@ -540,7 +546,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
|
|
|
540
546
|
const loaded = registeredBy(resourceLoader)
|
|
541
547
|
// A tool an extension brought is offered to the model, and asks before every call: the person
|
|
542
548
|
// trusted the code to load, and still decides each thing it does through a tool.
|
|
543
|
-
const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name))
|
|
549
|
+
const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name) && !params.restrictTools)
|
|
544
550
|
gate.ask?.([...new Set(loaded.flatMap((extension) => extension.tools))])
|
|
545
551
|
|
|
546
552
|
let supportsImageInput = false
|
|
@@ -558,7 +564,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
|
|
|
558
564
|
if (output.image && !supportsImageInput) throw new HarnessError('image_input_unsupported', 'This model cannot inspect images. Choose a model that supports image input.')
|
|
559
565
|
return {
|
|
560
566
|
content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never,
|
|
561
|
-
details: output.attachments?.length ? { attachments: output.attachments } : {}
|
|
567
|
+
details: { ...output.details, ...(output.attachments?.length ? { attachments: output.attachments } : {}) }
|
|
562
568
|
}
|
|
563
569
|
}
|
|
564
570
|
})
|
|
@@ -423,7 +423,7 @@ describe('session turns on Pi', () => {
|
|
|
423
423
|
await tick()
|
|
424
424
|
expect(received!.allow.slice(0, 3)).toEqual(['react', 'ask_user', 'plugin_propose'])
|
|
425
425
|
expect(received!.allow).toEqual(expect.arrayContaining(['memory_append', 'history_search', 'wiki_search', 'wiki_read', 'wiki_propose']))
|
|
426
|
-
expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'ask_user', 'plugin_propose', 'react'])
|
|
426
|
+
expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'trajectory_search', 'trajectory_read', 'ask_user', 'plugin_propose', 'spawn_subagent', 'wait_subagents', 'react'])
|
|
427
427
|
expect(await received!.tools[0].execute({ block: 'notes', text: 'Remember this.' })).toContain('Updated the notes block')
|
|
428
428
|
expect(received!.params.system).toContain('# Xingular Harness context')
|
|
429
429
|
})
|
|
@@ -496,7 +496,7 @@ describe('subagents', () => {
|
|
|
496
496
|
if (params.runId === 'parent') {
|
|
497
497
|
const spawn = tools.find((tool) => tool.name === 'spawn_subagent')!
|
|
498
498
|
expect(await spawn.execute({ brief: 'Find the bug', tools: ['bash', 'web'] })).toMatch(/cannot have tools you do not have: web/)
|
|
499
|
-
parentResult = await spawn.execute({ brief: 'Find the bug', tools: ['read'] })
|
|
499
|
+
parentResult = await spawn.execute({ brief: 'Find the bug', tools: ['read'], wait: true })
|
|
500
500
|
return
|
|
501
501
|
}
|
|
502
502
|
emit({ type: 'message_start', message: { role: 'assistant' } })
|
|
@@ -505,7 +505,7 @@ describe('subagents', () => {
|
|
|
505
505
|
})
|
|
506
506
|
await run('parent', { subagents: { maxDepth: 1 } })
|
|
507
507
|
for (let attempt = 0; attempt < 50 && !parentResult; attempt++) await tick()
|
|
508
|
-
expect(parentResult).
|
|
508
|
+
expect(parentResult).toMatchObject({ text: expect.stringContaining('Found it in parser.ts'), details: expect.any(Object) })
|
|
509
509
|
expect(seen.get('parent')!.allow).toContain('spawn_subagent')
|
|
510
510
|
const [childId] = [...seen.keys()].filter((id) => id !== 'parent')
|
|
511
511
|
const child = seen.get(childId)!
|
|
@@ -516,14 +516,53 @@ describe('subagents', () => {
|
|
|
516
516
|
expect(loadRun(harness.env.root, childId)).toMatchObject({ parentRunId: 'parent', relation: 'subagent', agentId: 'agent-1', status: 'completed' })
|
|
517
517
|
})
|
|
518
518
|
|
|
519
|
-
it('
|
|
519
|
+
it('exposes native child runs, freezes the launch, respects nesting, and settles waiters on parent stop', async () => {
|
|
520
|
+
const seen = new Map<string, string[]>()
|
|
521
|
+
const { harness, run } = setup(async ({ gate, params, allow }) => {
|
|
522
|
+
seen.set(params.runId, [...allow])
|
|
523
|
+
return fakeSession(async () => new Promise<void>(() => undefined), gate)
|
|
524
|
+
})
|
|
525
|
+
const subagents = { maxDepth: 2 }
|
|
526
|
+
const starting = run('parent', { subagents })
|
|
527
|
+
subagents.maxDepth = 3
|
|
528
|
+
await starting
|
|
529
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Bad tools', tools: ['outside'] })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
530
|
+
const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Search' })
|
|
531
|
+
const grandchild = await harness.sessions.spawnSubagent({ parentRunId: child.runId, brief: 'Review' })
|
|
532
|
+
expect(seen.get(child.runId)).toContain('spawn_subagent')
|
|
533
|
+
expect(seen.get(grandchild.runId)).not.toContain('spawn_subagent')
|
|
534
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: grandchild.runId, brief: 'Too deep' })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
535
|
+
const narrowed = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Read only', tools: ['read'] })
|
|
536
|
+
expect(seen.get(narrowed.runId)).toEqual(['read'])
|
|
537
|
+
const settled = Promise.all([child, grandchild, narrowed].map(({ runId }) => harness.runs.wait(runId)))
|
|
538
|
+
await harness.runs.interrupt('parent')
|
|
539
|
+
await settled
|
|
540
|
+
for (const { runId } of [child, grandchild, narrowed]) expect(loadRun(harness.env.root, runId).status).toBe('interrupted')
|
|
541
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Too late' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
542
|
+
await harness.runs.wait(child.runId)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('settles a failed child and attributes the tool to the Harness plugin', async () => {
|
|
546
|
+
const { harness, run } = setup(async ({ gate, params }) => {
|
|
547
|
+
if (params.runId !== 'parent') throw new Error('No provider')
|
|
548
|
+
return fakeSession(async () => new Promise<void>(() => undefined), gate)
|
|
549
|
+
})
|
|
550
|
+
await run('parent', { subagents: {} })
|
|
551
|
+
const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Try' })
|
|
552
|
+
await harness.runs.wait(child.runId)
|
|
553
|
+
expect(loadRun(harness.env.root, child.runId)).toMatchObject({ status: 'failed', parentRunId: 'parent', terminalReason: 'No provider' })
|
|
554
|
+
const { HARNESS_PLUGINS } = await import('../plugins/builtin')
|
|
555
|
+
expect(HARNESS_PLUGINS.find((plugin) => plugin.id === 'subagents')?.tools).toContainEqual(expect.objectContaining({ name: 'spawn_subagent' }))
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('can explicitly disable subagents and run without the application\'s tool providers', async () => {
|
|
520
559
|
const offered: string[][] = []
|
|
521
560
|
const { harness, run } = setup(async ({ gate, tools }) => {
|
|
522
561
|
offered.push(tools.map((tool) => tool.name))
|
|
523
562
|
return fakeSession(async () => undefined, gate)
|
|
524
563
|
})
|
|
525
564
|
harness.sessions.provide(() => [{ name: 'app_tool', label: 'App', description: 'An app tool', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
|
|
526
|
-
await run('plain', { tools: { allow: ['app_tool'] } })
|
|
565
|
+
await run('plain', { tools: { allow: ['app_tool'] }, subagents: null })
|
|
527
566
|
await run('solo', { tools: { allow: ['app_tool'] }, providers: false })
|
|
528
567
|
await tick()
|
|
529
568
|
expect(offered[0]).toContain('app_tool')
|
|
@@ -533,7 +572,7 @@ describe('subagents', () => {
|
|
|
533
572
|
})
|
|
534
573
|
|
|
535
574
|
describe('standing approvals', () => {
|
|
536
|
-
const policy = { auto: { pi: true }, remembered: { 'pane-1': ['shell'] } }
|
|
575
|
+
const policy = { auto: { pi: true }, globalTools: {}, remembered: { 'pane-1': ['shell'] } }
|
|
537
576
|
const facts = (unattended: boolean, readOnly: boolean): ApprovalFacts => ({ unattended, readOnly })
|
|
538
577
|
|
|
539
578
|
it('the switch reaches attended sessions but not unattended or read-only ones', () => {
|
|
@@ -544,6 +583,6 @@ describe('standing approvals', () => {
|
|
|
544
583
|
expect(grant(policy, 'pi', 'pane-1', 'shell', facts(true, false))).toBeNull()
|
|
545
584
|
expect(grant(policy, 'chat', 'pane-1', 'edit files', facts(false, false))).toBeNull()
|
|
546
585
|
expect(grant(policy, 'pi', 'pane-2', 'shell', {})).toBe('always')
|
|
547
|
-
expect(grant({ auto: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
|
|
586
|
+
expect(grant({ auto: {}, globalTools: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
|
|
548
587
|
})
|
|
549
588
|
})
|