@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.
@@ -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, type TerminalCommandHost } from './commands'
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('toggles plan mode with /plan, narrowing to read-only tools', async () => {
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 { getActiveToolNames(): string[]; setActiveToolsByName(names: string[]): void; cycleThinkingLevel(): void }
243
- const setActiveToolsByName = vi.fn()
244
- const cycleThinkingLevel = vi.fn()
245
- Object.assign(session, { getActiveToolNames: () => ['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'], setActiveToolsByName, cycleThinkingLevel })
246
- app.commands = createTerminalCommands({
247
- session: () => session as never,
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.submit('/plan')
263
- await vi.waitFor(() => expect(setActiveToolsByName).toHaveBeenCalledWith(['read', 'grep']))
264
- expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'grep'])
265
- app.submit('/plan off')
266
- await vi.waitFor(() => expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent']))
267
- expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'])
268
- expect(cycleThinkingLevel).not.toHaveBeenCalled()
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 planMode = false
106
- private normalTools?: string[]
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
- planMode: () => this.planMode, setPlanMode: enabled => this.setPlanMode(enabled),
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.planMode = false; this.normalTools = undefined; this.refresh(true) }))
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
- /** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
311
- private setPlanMode(enabled: boolean): void {
312
- if (!this.runtime) return
313
- if (this.planMode === enabled) return
314
- if (this.planMode) {
315
- this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames())
316
- this.normalTools = undefined
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
- return state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width)) : []
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
- await this.runtime!.submit(this.commands.expandSkill(text), followUp)
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')) { const pending = this.session.clearQueue(); this.editor.setText([...pending.steering, ...pending.followUp, this.editor.getText()].filter(Boolean).join('\n')); this.syncDraft(this.editor.getText()) }
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
- planMode: () => false, setPlanMode: vi.fn(),
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)