@xingular/harness 2.1.0 → 2.1.2
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 +10 -0
- package/README.md +3 -1
- package/dist/cli.js +731 -189
- package/dist/terminal-pi.js +105 -26
- package/docs/terminal-tui.md +31 -13
- package/package.json +1 -1
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/sessions/contracts.ts +1 -1
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +8 -0
- package/src/sessions/sessions.test.ts +15 -7
- package/src/sessions/sessions.ts +3 -1
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +28 -1
- package/src/terminal/piExtension.ts +17 -3
- package/src/terminal/terminal.test.ts +20 -0
- package/src/terminal/terminals.ts +11 -0
- package/src/terminal/tui/application.test.ts +30 -34
- package/src/terminal/tui/application.ts +72 -21
- package/src/terminal/tui/commands.test.ts +26 -2
- package/src/terminal/tui/commands.ts +20 -7
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- 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 +24 -2
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/types.ts +2 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
|
@@ -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
|
+
}
|
|
@@ -11,7 +11,8 @@ vi.mock('./client', () => ({
|
|
|
11
11
|
terminalCall: transport.telemetry
|
|
12
12
|
}))
|
|
13
13
|
|
|
14
|
-
import terminalExtension from './piExtension'
|
|
14
|
+
import terminalExtension, { type TerminalPlanRunState } from './piExtension'
|
|
15
|
+
import { PLAN_INSTRUCTIONS } from '../sessions/planMode'
|
|
15
16
|
|
|
16
17
|
function extension() {
|
|
17
18
|
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
|
@@ -28,6 +29,32 @@ function extension() {
|
|
|
28
29
|
afterEach(() => vi.useRealTimers())
|
|
29
30
|
|
|
30
31
|
describe('terminal bridge lifecycle', () => {
|
|
32
|
+
it('adds plan instructions as a one-run context section and withdraws them on the next user request', async () => {
|
|
33
|
+
const state: TerminalPlanRunState = { active: true, allowedTools: new Set(['read']) }
|
|
34
|
+
const { api, ctx, handlers } = extension()
|
|
35
|
+
await terminalExtension(api, state)
|
|
36
|
+
const planned = { messageType: 'user', prompt: 'inspect this', systemPrompt: 'stable prefix', systemPromptOptions: { sections: { identity: 'identity' } } }
|
|
37
|
+
await handlers.get('before_agent_start')!(planned, ctx)
|
|
38
|
+
expect(planned.systemPromptOptions.sections).toEqual({ identity: 'identity', plan: PLAN_INSTRUCTIONS })
|
|
39
|
+
expect(planned.systemPrompt).toBe('stable prefix')
|
|
40
|
+
|
|
41
|
+
state.active = false
|
|
42
|
+
const ordinary = { messageType: 'user', prompt: 'now implement', systemPrompt: 'stable prefix', systemPromptOptions: { sections: { identity: 'identity', plan: PLAN_INSTRUCTIONS } } }
|
|
43
|
+
await handlers.get('before_agent_start')!(ordinary, ctx)
|
|
44
|
+
expect(ordinary.systemPromptOptions.sections).toEqual({ identity: 'identity' })
|
|
45
|
+
expect(ordinary.systemPrompt).toBe('stable prefix')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('blocks tools outside the active plan allowlist before consulting terminal policy', async () => {
|
|
49
|
+
const state: TerminalPlanRunState = { active: true, allowedTools: new Set(['read']) }
|
|
50
|
+
const { api, ctx, handlers } = extension()
|
|
51
|
+
await terminalExtension(api, state)
|
|
52
|
+
const policyCalls = transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy').length
|
|
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 research-only') })
|
|
55
|
+
expect(transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy')).toHaveLength(policyCalls)
|
|
56
|
+
})
|
|
57
|
+
|
|
31
58
|
it('keeps one private control claim while replacing native session pollers and restoring readiness', async () => {
|
|
32
59
|
vi.useFakeTimers()
|
|
33
60
|
const first = extension()
|
|
@@ -13,6 +13,7 @@ import { claimTerminalControl, controlCall } from './control'
|
|
|
13
13
|
import { SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentReceipt } from '../sessions/subagents'
|
|
14
14
|
import { askUserTool } from '../tools/askUser'
|
|
15
15
|
import type { QuestionAnswer } from '../sessions/registry'
|
|
16
|
+
import { PLAN_INSTRUCTIONS, planRefusal } from '../sessions/planMode'
|
|
16
17
|
|
|
17
18
|
interface SocketTool {
|
|
18
19
|
name: string
|
|
@@ -21,6 +22,11 @@ interface SocketTool {
|
|
|
21
22
|
parameters: Record<string, unknown>
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export interface TerminalPlanRunState {
|
|
26
|
+
active: boolean
|
|
27
|
+
allowedTools: ReadonlySet<string>
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
/** How much text a tool handed back to the model. */
|
|
25
31
|
function resultSize(result: unknown): number | null {
|
|
26
32
|
const content = (result as { content?: unknown } | null | undefined)?.content
|
|
@@ -35,7 +41,7 @@ async function withParentCancellation<T>(signal: AbortSignal | undefined, work:
|
|
|
35
41
|
try { return await work() } finally { signal?.removeEventListener('abort', stop) }
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
export default async function (pi: ExtensionAPI) {
|
|
44
|
+
export default async function (pi: ExtensionAPI, planRun?: TerminalPlanRunState) {
|
|
39
45
|
await terminalCall('startup', { phase: 'Pi extension loaded' })
|
|
40
46
|
let context: ExtensionContext | null = null
|
|
41
47
|
let nativeSubagents = false
|
|
@@ -167,6 +173,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
167
173
|
await controlCall('terminal.subagents.register', { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) })
|
|
168
174
|
subagentParent = true
|
|
169
175
|
}
|
|
176
|
+
if (planRun && event.messageType === 'user') {
|
|
177
|
+
if (planRun.active) event.systemPromptOptions.sections.plan = PLAN_INSTRUCTIONS
|
|
178
|
+
else delete event.systemPromptOptions.sections.plan
|
|
179
|
+
}
|
|
170
180
|
})
|
|
171
181
|
const deliverChildren = async (event: any, ctx: ExtensionContext, settle: boolean) => {
|
|
172
182
|
if (!nativeSubagents || (settle && event.outcome !== 'completed') || ctx.signal?.aborted) return
|
|
@@ -224,6 +234,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
224
234
|
requestStartedAt = Date.now()
|
|
225
235
|
})
|
|
226
236
|
pi.on('tool_call', async (event) => {
|
|
237
|
+
if (planRun?.active && !planRun.allowedTools.has(event.toolName)) {
|
|
238
|
+
const reason = planRefusal(event.toolName, new Set()) ?? `Plan mode is research-only: ${event.toolName} is not available in this session.`
|
|
239
|
+
return { block: true, reason }
|
|
240
|
+
}
|
|
227
241
|
try {
|
|
228
242
|
const policy = await controlCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('terminal.policy', { tool: event.toolName, args: event.input })
|
|
229
243
|
if (policy.requiresApproval) {
|
|
@@ -280,10 +294,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
280
294
|
pi.registerTool({
|
|
281
295
|
name: 'ask_user_question',
|
|
282
296
|
label: 'Ask a question',
|
|
283
|
-
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.',
|
|
284
298
|
parameters: Type.Object({
|
|
285
299
|
question: Type.String(),
|
|
286
|
-
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()) }))),
|
|
287
301
|
multiSelect: Type.Optional(Type.Boolean())
|
|
288
302
|
}),
|
|
289
303
|
async execute(id, input) {
|
|
@@ -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 () => {
|
|
@@ -467,6 +468,25 @@ describe('decisions in the Harness-owned frontend', () => {
|
|
|
467
468
|
await expect(socketCall(connection, 'terminal.trajectory.list', {}, 1000, key)).resolves.toEqual({ runs: [] })
|
|
468
469
|
})
|
|
469
470
|
|
|
471
|
+
it('serves a bounded diagnostic snapshot only through the private terminal control channel', async () => {
|
|
472
|
+
const { connection, harness } = await setup()
|
|
473
|
+
await expect(socketCall(connection, 'terminal.logs.info')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
474
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
475
|
+
await expect(socketCall(connection, 'terminal.logs.info', {}, 1000, key)).resolves.toEqual({ size: null })
|
|
476
|
+
await expect(socketCall(connection, 'terminal.logs.read', { snapshotSize: 1, offset: 0 }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
|
|
477
|
+
|
|
478
|
+
const text = `${'a'.repeat(LOG_READ_CHUNK_BYTES - 1)}🧠tail`
|
|
479
|
+
writeFileSync(join(harness.stateDir, 'harness.log'), text)
|
|
480
|
+
const { size } = await socketCall<{ size: number }>(connection, 'terminal.logs.info', {}, 1000, key)
|
|
481
|
+
expect(size).toBe(Buffer.byteLength(text))
|
|
482
|
+
await expect(socketCall(connection, 'terminal.logs.read', { snapshotSize: size, offset: size }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
|
|
483
|
+
const first = await socketCall<{ data: string; nextOffset: number }>(connection, 'terminal.logs.read', { snapshotSize: size, offset: 0 }, 1000, key)
|
|
484
|
+
const second = await socketCall<{ data: string; nextOffset: number }>(connection, 'terminal.logs.read', { snapshotSize: size, offset: first.nextOffset }, 1000, key)
|
|
485
|
+
expect(first.nextOffset).toBe(LOG_READ_CHUNK_BYTES)
|
|
486
|
+
expect(second.nextOffset).toBe(size)
|
|
487
|
+
expect(Buffer.concat([Buffer.from(first.data, 'base64'), Buffer.from(second.data, 'base64')]).toString('utf8')).toBe(text)
|
|
488
|
+
})
|
|
489
|
+
|
|
470
490
|
it('requires the private input-owner key for pending decisions and exact draft state', async () => {
|
|
471
491
|
const { connection, harness, tell } = await setup()
|
|
472
492
|
await tell('ready', { nativeSessionId: '/native.jsonl' })
|
|
@@ -43,6 +43,7 @@ import { SOCKET_ENV, TOKEN_ENV } from './client'
|
|
|
43
43
|
import { TerminalController, type PolicyReply } from './controller'
|
|
44
44
|
import { resolvePiCommand, terminalCommandFiles } from './launch'
|
|
45
45
|
import { SessionSocket, socketPath } from './socket'
|
|
46
|
+
import { harnessLogInfo, readHarnessLogChunk } from './logs'
|
|
46
47
|
import { listTerminalTrajectoryRuns, readTerminalTrajectory } from './trajectory'
|
|
47
48
|
|
|
48
49
|
/** Identity of the exact resource decision that this terminal was prepared with. */
|
|
@@ -543,6 +544,16 @@ export class Terminals {
|
|
|
543
544
|
broker,
|
|
544
545
|
terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected(), control: async (call, params) => {
|
|
545
546
|
if (call.startsWith('terminal.subagents.')) return this.subagentsCall(live, call, params)
|
|
547
|
+
if (call === 'terminal.logs.info') return harnessLogInfo(this.options.stateDir)
|
|
548
|
+
if (call === 'terminal.logs.read') {
|
|
549
|
+
const { snapshotSize, offset } = params
|
|
550
|
+
if (typeof snapshotSize !== 'number' || !Number.isSafeInteger(snapshotSize) || snapshotSize < 0 || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset >= snapshotSize) {
|
|
551
|
+
throw invalid('A valid diagnostics snapshot size and byte offset are required.')
|
|
552
|
+
}
|
|
553
|
+
const current = harnessLogInfo(this.options.stateDir).size
|
|
554
|
+
if (current === null || snapshotSize > current) throw invalid('The diagnostics snapshot is no longer available.')
|
|
555
|
+
return readHarnessLogChunk(this.options.stateDir, snapshotSize, offset)
|
|
556
|
+
}
|
|
546
557
|
if (call.startsWith('terminal.trajectory.')) return this.trajectoryCall(live, call, params)
|
|
547
558
|
if (call === 'terminal.permissions.get') {
|
|
548
559
|
const policy = this.options.approvals.read()
|
|
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
5
5
|
vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
|
|
6
6
|
import { controlCall } from '../control'
|
|
7
7
|
import { TerminalApplication } from './application'
|
|
8
|
-
import { createTerminalCommands
|
|
8
|
+
import { createTerminalCommands } from './commands'
|
|
9
9
|
import type { TerminalDecision } from './runtime'
|
|
10
10
|
import { terminalKeybindings } from './keybindings'
|
|
11
11
|
import { autocompleteRuleColor, createHarnessTheme } from './theme'
|
|
@@ -22,10 +22,8 @@ function setup() {
|
|
|
22
22
|
}
|
|
23
23
|
keybindings: ReturnType<typeof terminalKeybindings>
|
|
24
24
|
editor: CustomEditor
|
|
25
|
-
runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; session: object; snapshot(): { hasUserMessages: boolean; cwd?: string; model?: { name: string; reasoning: boolean; contextWindow: number }; thinkingLevel?: string; account?: { label: string } | null; context?: { percent: number | null; window: number }; costUsd?: number; subscription?: boolean } }
|
|
26
|
-
commands: ReturnType<typeof createTerminalCommands> | { execute: ReturnType<typeof vi.fn
|
|
27
|
-
planMode: boolean
|
|
28
|
-
setPlanMode(enabled: boolean): void
|
|
25
|
+
runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; submit: ReturnType<typeof vi.fn>; submitPlan: ReturnType<typeof vi.fn>; session: object; snapshot(): { hasUserMessages: boolean; cwd?: string; model?: { name: string; reasoning: boolean; contextWindow: number }; thinkingLevel?: string; account?: { label: string } | null; context?: { percent: number | null; window: number }; costUsd?: number; subscription?: boolean } }
|
|
26
|
+
commands: ReturnType<typeof createTerminalCommands> | { execute: ReturnType<typeof vi.fn>; expandSkill(text: string): string }
|
|
29
27
|
masthead: { render(width: number): string[] }
|
|
30
28
|
header?: { render(width: number): string[]; invalidate(): void }
|
|
31
29
|
branch: string | null
|
|
@@ -52,7 +50,7 @@ function setup() {
|
|
|
52
50
|
app.keybindings = terminalKeybindings('/unused/isolated-account')
|
|
53
51
|
app.initialized = true
|
|
54
52
|
const context = {}
|
|
55
|
-
app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), snapshot: () => ({
|
|
53
|
+
app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), submit: vi.fn(async () => {}), submitPlan: vi.fn(async () => {}), snapshot: () => ({
|
|
56
54
|
hasUserMessages: false, cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
|
|
57
55
|
thinkingLevel: 'high', account: { label: 'Tanny' }, context: { percent: null, window: 0 }, costUsd: 0, subscription: false
|
|
58
56
|
}), session: {
|
|
@@ -226,7 +224,7 @@ describe('the owned terminal application', () => {
|
|
|
226
224
|
const { application, app } = setup()
|
|
227
225
|
app.mountEditor()
|
|
228
226
|
let resolve!: (value: boolean) => void
|
|
229
|
-
app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })) }
|
|
227
|
+
app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })), expandSkill: text => text }
|
|
230
228
|
app.submit(' /settings')
|
|
231
229
|
await vi.waitFor(() => expect(app.commands.execute).toHaveBeenCalledWith(' /settings'))
|
|
232
230
|
app.submit('/model')
|
|
@@ -237,35 +235,33 @@ describe('the owned terminal application', () => {
|
|
|
237
235
|
await application.shutdown()
|
|
238
236
|
})
|
|
239
237
|
|
|
240
|
-
it('
|
|
238
|
+
it('keeps a queued plan isolated and sends later messages after it settles', async () => {
|
|
241
239
|
const { application, app } = setup()
|
|
242
|
-
const session = app.runtime.session as {
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
app.
|
|
247
|
-
|
|
248
|
-
runtime: () => ({} as never),
|
|
249
|
-
ui: app.ui,
|
|
250
|
-
cwd: () => '/work/project',
|
|
251
|
-
sessionDirectory: () => '/isolated/account/history',
|
|
252
|
-
refresh: () => {},
|
|
253
|
-
changed: () => {},
|
|
254
|
-
planMode: () => app.planMode,
|
|
255
|
-
setPlanMode: enabled => app.setPlanMode(enabled),
|
|
256
|
-
shutdown: () => {},
|
|
257
|
-
setEditorText: () => {},
|
|
258
|
-
copyText: async () => {},
|
|
259
|
-
trajectory: {} as TerminalCommandHost['trajectory']
|
|
260
|
-
})
|
|
240
|
+
const session = app.runtime.session as { isIdle: boolean }
|
|
241
|
+
Object.assign(session, { isIdle: false })
|
|
242
|
+
const sent: string[] = []
|
|
243
|
+
app.runtime.submitPlan = vi.fn(async brief => { sent.push(`plan:${brief}`) })
|
|
244
|
+
app.runtime.submit = vi.fn(async text => { sent.push(`prompt:${text}`) })
|
|
245
|
+
const enqueuePlan = (application as unknown as { enqueuePlan(brief: string): void }).enqueuePlan.bind(application)
|
|
261
246
|
app.mountEditor()
|
|
262
|
-
app.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
247
|
+
app.commands = {
|
|
248
|
+
execute: vi.fn(async text => {
|
|
249
|
+
if (text.trimStart().startsWith('/plan ')) { enqueuePlan(text.trimStart().slice('/plan '.length)); return true }
|
|
250
|
+
return false
|
|
251
|
+
}),
|
|
252
|
+
expandSkill: text => text
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
app.submit('/plan inspect the source')
|
|
256
|
+
await vi.waitFor(() => expect(app.commandBusy).toBe(false))
|
|
257
|
+
app.submit('then implement it')
|
|
258
|
+
await vi.waitFor(() => expect((application as unknown as { deferredInputs: unknown[] }).deferredInputs).toHaveLength(2))
|
|
259
|
+
expect(sent).toEqual([])
|
|
260
|
+
|
|
261
|
+
session.isIdle = true
|
|
262
|
+
app.accept({ type: 'agent_settled' } as AgentSessionEvent)
|
|
263
|
+
await vi.waitFor(() => expect(sent).toEqual(['plan:inspect the source', 'prompt:then implement it']))
|
|
264
|
+
expect(app.runtime.submitPlan).toHaveBeenCalledOnce()
|
|
269
265
|
await application.shutdown()
|
|
270
266
|
})
|
|
271
267
|
|
|
@@ -16,7 +16,6 @@ import { HARNESS_KEYBINDINGS, terminalKeybindings } from './keybindings'
|
|
|
16
16
|
import { loadPiAdapter, setPiTheme } from './piAdapter'
|
|
17
17
|
import { createTerminalRuntime, type TerminalDecision, type TerminalRuntime } from './runtime'
|
|
18
18
|
import { createDock, createTerminalScreen, mountTerminalLayout, readScreenPreferences, scrollbackLines, writeScrollback, type ScreenHooks, type ScreenPreferences, type TerminalScreen } from './screen'
|
|
19
|
-
import { planModeTools } from './planMode'
|
|
20
19
|
import { renderStatusBand, singleLine } from './status'
|
|
21
20
|
import { skillCompletions } from './skillInvocation'
|
|
22
21
|
import { createHarnessLightTheme, createHarnessTheme } from './theme'
|
|
@@ -26,10 +25,12 @@ import { chooseWorkingVerb, createCompletionStatus, getWorkingSpinnerFrames, SPI
|
|
|
26
25
|
import { HarnessWelcome } from './welcome'
|
|
27
26
|
import { answerTerminalQuestion } from './questions'
|
|
28
27
|
import type { QuestionAnswer } from '../../sessions/registry'
|
|
28
|
+
import { readHarnessLogSnapshot, type HarnessLogChunk, type HarnessLogInfo } from '../logs'
|
|
29
29
|
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
30
30
|
|
|
31
31
|
const exec = promisify(execFile)
|
|
32
32
|
type Mounted = Component & { dispose?(): void }
|
|
33
|
+
type DeferredTerminalInput = { kind: 'plan'; text: string } | { kind: 'prompt'; text: string; followUp: boolean }
|
|
33
34
|
type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
|
|
34
35
|
type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
|
|
35
36
|
type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
|
|
@@ -102,8 +103,11 @@ export class TerminalApplication {
|
|
|
102
103
|
private phase?: string
|
|
103
104
|
private viewport?: ScrollView
|
|
104
105
|
private toolsExpanded = false
|
|
105
|
-
private
|
|
106
|
-
private
|
|
106
|
+
private deferredInputs: DeferredTerminalInput[] = []
|
|
107
|
+
private planBarrierOpen = false
|
|
108
|
+
private planActive = false
|
|
109
|
+
private drainingDeferred = false
|
|
110
|
+
private activeDeferredKind?: DeferredTerminalInput['kind']
|
|
107
111
|
private stopped = false
|
|
108
112
|
private initialized = false
|
|
109
113
|
private closing?: Promise<void>
|
|
@@ -184,9 +188,13 @@ export class TerminalApplication {
|
|
|
184
188
|
session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
|
|
185
189
|
cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
|
|
186
190
|
refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
|
|
187
|
-
|
|
191
|
+
plan: brief => this.enqueuePlan(brief),
|
|
188
192
|
copyText: copyToClipboard, openExternal,
|
|
189
193
|
changed: this.change,
|
|
194
|
+
logs: { read: () => readHarnessLogSnapshot(
|
|
195
|
+
() => controlCall<HarnessLogInfo>('terminal.logs.info'),
|
|
196
|
+
(snapshotSize, offset) => controlCall<HarnessLogChunk>('terminal.logs.read', { snapshotSize, offset })
|
|
197
|
+
) },
|
|
190
198
|
trajectory: {
|
|
191
199
|
list: async () => (await controlCall<{ runs: TerminalTrajectoryRun[] }>('terminal.trajectory.list')).runs,
|
|
192
200
|
read: (runId, afterSequence) => controlCall<TerminalTrajectorySnapshot>('terminal.trajectory.events', { runId, ...(afterSequence === undefined ? {} : { afterSequence }) })
|
|
@@ -202,7 +210,7 @@ export class TerminalApplication {
|
|
|
202
210
|
})
|
|
203
211
|
this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
|
|
204
212
|
this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
|
|
205
|
-
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.
|
|
213
|
+
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.refresh(true) }))
|
|
206
214
|
this.initialized = true
|
|
207
215
|
this.phase = undefined
|
|
208
216
|
this.refresh(true)
|
|
@@ -307,20 +315,46 @@ export class TerminalApplication {
|
|
|
307
315
|
this.completionStatus = undefined
|
|
308
316
|
this.phase = undefined
|
|
309
317
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (!this.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
} else {
|
|
318
|
-
this.normalTools = this.session.getActiveToolNames()
|
|
319
|
-
this.session.setActiveToolsByName(planModeTools(this.normalTools))
|
|
320
|
-
}
|
|
321
|
-
this.planMode = enabled
|
|
322
|
-
this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Use /plan off to return to normal.' : 'Normal mode: all tools are available.')
|
|
318
|
+
private enqueuePlan(brief: string): void {
|
|
319
|
+
const text = brief.trim()
|
|
320
|
+
if (!text) { this.notify('Use /plan <plan brief>.', 'error'); return }
|
|
321
|
+
const waiting = !this.session.isIdle
|
|
322
|
+
this.planBarrierOpen = true
|
|
323
|
+
this.deferredInputs.push({ kind: 'plan', text })
|
|
324
|
+
this.notify(waiting ? 'Plan request queued to run by itself after the current response.' : 'Plan request queued as an isolated run.')
|
|
323
325
|
this.change()
|
|
326
|
+
this.drainDeferred()
|
|
327
|
+
}
|
|
328
|
+
private drainDeferred(): void {
|
|
329
|
+
if (this.drainingDeferred || !this.planBarrierOpen || !this.runtime || !this.session.isIdle || !this.deferredInputs.length) return
|
|
330
|
+
this.drainingDeferred = true
|
|
331
|
+
void (async () => {
|
|
332
|
+
try {
|
|
333
|
+
while (this.deferredInputs.length && !this.stopped && this.runtime && this.session.isIdle) {
|
|
334
|
+
const next = this.deferredInputs.shift()!
|
|
335
|
+
this.activeDeferredKind = next.kind
|
|
336
|
+
this.change()
|
|
337
|
+
try {
|
|
338
|
+
if (next.kind === 'plan') {
|
|
339
|
+
this.planActive = true
|
|
340
|
+
await this.runtime.submitPlan(next.text)
|
|
341
|
+
} else {
|
|
342
|
+
await this.runtime.submit(next.text, next.followUp)
|
|
343
|
+
}
|
|
344
|
+
} catch (error) {
|
|
345
|
+
this.notify(error instanceof Error ? error.message : String(error), 'error')
|
|
346
|
+
} finally {
|
|
347
|
+
if (next.kind === 'plan') this.planActive = false
|
|
348
|
+
this.activeDeferredKind = undefined
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} finally {
|
|
352
|
+
this.drainingDeferred = false
|
|
353
|
+
if (!this.deferredInputs.length && !this.planActive) this.planBarrierOpen = false
|
|
354
|
+
this.change()
|
|
355
|
+
if (this.deferredInputs.length && this.runtime?.session.isIdle) this.drainDeferred()
|
|
356
|
+
}
|
|
357
|
+
})()
|
|
324
358
|
}
|
|
325
359
|
private scrollToLatest(): void { this.viewport?.scrollToEnd() }
|
|
326
360
|
private async chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: 'approved' | 'denied'; alwaysAllow: boolean }> {
|
|
@@ -397,7 +431,9 @@ export class TerminalApplication {
|
|
|
397
431
|
private queuedMessages(width: number): string[] {
|
|
398
432
|
if (!this.ui) return []
|
|
399
433
|
const state = this.runtime?.snapshot()
|
|
400
|
-
|
|
434
|
+
const native = state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)] : []
|
|
435
|
+
const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `Plan: /plan ${input.text}` : `Queued: ${input.text}`)
|
|
436
|
+
return [...native, ...deferred].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width))
|
|
401
437
|
}
|
|
402
438
|
private statusLines(width: number): string[] {
|
|
403
439
|
return this.statuses.size ? wrapTextWithAnsi([...this.statuses.values()].join(' '), width) : []
|
|
@@ -427,7 +463,13 @@ export class TerminalApplication {
|
|
|
427
463
|
if (this.stopped) return
|
|
428
464
|
if (command && await this.commands.execute(text)) return
|
|
429
465
|
if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
|
|
430
|
-
|
|
466
|
+
const expanded = this.commands.expandSkill(text)
|
|
467
|
+
if (this.planBarrierOpen) {
|
|
468
|
+
this.deferredInputs.push({ kind: 'prompt', text: expanded, followUp })
|
|
469
|
+
this.change()
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
await this.runtime!.submit(expanded, followUp)
|
|
431
473
|
} finally { if (command) this.commandBusy = false }
|
|
432
474
|
})
|
|
433
475
|
}
|
|
@@ -455,7 +497,14 @@ export class TerminalApplication {
|
|
|
455
497
|
else if (is('app.tools.expand')) this.ui.setToolsExpanded(!this.toolsExpanded)
|
|
456
498
|
else if (is('app.thinking.toggle')) { const hidden = !this.session.settingsManager.getHideThinkingBlock(); this.session.settingsManager.setHideThinkingBlock(hidden); this.transcript.setThinking(hidden) }
|
|
457
499
|
else if (is('app.message.followUp')) this.submit(this.editor.getExpandedText?.() ?? this.editor.getText(), true)
|
|
458
|
-
else if (is('app.message.dequeue')) {
|
|
500
|
+
else if (is('app.message.dequeue')) {
|
|
501
|
+
const pending = this.session.clearQueue()
|
|
502
|
+
const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `/plan ${input.text}` : input.text)
|
|
503
|
+
this.deferredInputs = []
|
|
504
|
+
this.planBarrierOpen = this.planActive || this.activeDeferredKind === 'plan'
|
|
505
|
+
this.editor.setText([...pending.steering, ...pending.followUp, ...deferred, this.editor.getText()].filter(Boolean).join('\n'))
|
|
506
|
+
this.syncDraft(this.editor.getText())
|
|
507
|
+
}
|
|
459
508
|
else if (is('app.editor.external')) this.attempt(() => this.externalEditor())
|
|
460
509
|
else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
|
|
461
510
|
else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
|
|
@@ -498,6 +547,7 @@ export class TerminalApplication {
|
|
|
498
547
|
this.requestStartedAt = null
|
|
499
548
|
this.phase = undefined
|
|
500
549
|
this.terminal.setProgress(false)
|
|
550
|
+
queueMicrotask(() => this.drainDeferred())
|
|
501
551
|
}
|
|
502
552
|
if (event.type === 'compaction_start') this.phase = 'Compacting context…'
|
|
503
553
|
if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
|
|
@@ -527,6 +577,7 @@ export class TerminalApplication {
|
|
|
527
577
|
}
|
|
528
578
|
private resetPresentation(): void {
|
|
529
579
|
this.pendingAbort?.abort(); this.dismissedDecisions.clear()
|
|
580
|
+
this.deferredInputs = []; this.planBarrierOpen = false; this.planActive = false; this.activeDeferredKind = undefined
|
|
530
581
|
this.resetWorkingStatus()
|
|
531
582
|
this.requestStartedAt = null; this.lastTokensPerSecond = null
|
|
532
583
|
this.transcript.clearSession()
|
|
@@ -31,8 +31,9 @@ function setup() {
|
|
|
31
31
|
session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
|
|
32
32
|
cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
|
|
33
33
|
refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
|
|
34
|
-
|
|
34
|
+
plan: vi.fn(),
|
|
35
35
|
hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
|
|
36
|
+
logs: { read: vi.fn(async () => '[harness] diagnostic') },
|
|
36
37
|
trajectory: {
|
|
37
38
|
list: vi.fn(async () => [trajectoryRun]),
|
|
38
39
|
read: vi.fn(async () => ({ run: trajectoryRun, events: [] }))
|
|
@@ -59,12 +60,24 @@ describe('owned terminal commands', () => {
|
|
|
59
60
|
it('populates completion from builtins and loaded resources', () => {
|
|
60
61
|
const { commands } = setup()
|
|
61
62
|
const names = commands.completions().map(item => item.name)
|
|
62
|
-
expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'permissions']))
|
|
63
|
+
expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'log', 'permissions']))
|
|
63
64
|
expect(names).not.toContain('thinking')
|
|
64
65
|
expect(names).not.toContain('skill:diagnose')
|
|
65
66
|
expect(new Set(names).size).toBe(names.length)
|
|
66
67
|
})
|
|
67
68
|
|
|
69
|
+
it('runs one plan request from /plan <brief> and rejects toggle syntax', async () => {
|
|
70
|
+
const { commands, host, ui } = setup()
|
|
71
|
+
expect(await commands.execute('/plan inspect the current implementation')).toBe(true)
|
|
72
|
+
expect(host.plan).toHaveBeenCalledWith('inspect the current implementation')
|
|
73
|
+
expect(await commands.execute('/plan')).toBe(true)
|
|
74
|
+
expect(await commands.execute('/plan on')).toBe(true)
|
|
75
|
+
expect(await commands.execute('/plan toggle')).toBe(true)
|
|
76
|
+
expect(host.plan).toHaveBeenCalledOnce()
|
|
77
|
+
expect(ui.notify).toHaveBeenLastCalledWith('Use /plan <plan brief>.', 'error')
|
|
78
|
+
expect(commands.completions().find(item => item.name === 'plan')?.description).toContain('/plan <brief>')
|
|
79
|
+
})
|
|
80
|
+
|
|
68
81
|
it('invokes a loaded skill as $name, and leaves other text for the SDK', () => {
|
|
69
82
|
const { commands, session } = setup()
|
|
70
83
|
expect(commands.skills()).toEqual([{ name: 'diagnose', description: 'Diagnose a problem' }])
|
|
@@ -160,6 +173,17 @@ describe('owned terminal commands', () => {
|
|
|
160
173
|
expect(host.trajectory.read).toHaveBeenLastCalledWith('run-1')
|
|
161
174
|
})
|
|
162
175
|
|
|
176
|
+
it('opens a full-screen snapshot combining diagnostics and current-conversation run events', async () => {
|
|
177
|
+
const { commands, host, ui } = setup()
|
|
178
|
+
await commands.execute('/log')
|
|
179
|
+
expect(host.logs.read).toHaveBeenCalledOnce()
|
|
180
|
+
expect(host.trajectory.list).toHaveBeenCalledOnce()
|
|
181
|
+
expect(host.trajectory.read).toHaveBeenCalledWith('run-1')
|
|
182
|
+
expect(ui.custom).toHaveBeenCalledWith(expect.any(Function), {
|
|
183
|
+
overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' }
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
163
187
|
it('shows shared tool grants and confirms Full Permission before enabling it', async () => {
|
|
164
188
|
const { commands, host, runtime, ui } = setup()
|
|
165
189
|
vi.mocked(ui.custom).mockImplementationOnce(async () => 'tool:bash' as never)
|