@xingular/harness 2.0.0 → 2.1.1

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,57 @@
1
+ // Plan mode: a run that may only look, ask and recall, and ends with a proposed plan. The tool list
2
+ // the model sees is unchanged, so switching the mode does not change the cached prompt prefix; the
3
+ // gate refuses everything else, and the instruction arrives as a context update.
4
+ import { deliveredSnapshot, type HostSection } from '../prompt/compile'
5
+ import { ASK_USER } from '../tools/askUser'
6
+
7
+ /**
8
+ * Read-only inspection, questions and recall. Everything else is refused in plan mode: file
9
+ * changes, commands, memory writes, delegation and connected apps, since none of them can be
10
+ * judged read-only from their names. A provider marks its own read-only tools with `readOnly`.
11
+ */
12
+ export const PLAN_READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
13
+ 'read', 'grep', 'find', 'ls', ASK_USER, 'ask_user_question',
14
+ 'history_search', 'history_read', 'trajectory_search', 'trajectory_read',
15
+ 'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load', 'mcp_list_tools'
16
+ ])
17
+
18
+ export const PLAN_SECTION_ID = 'plan'
19
+ /** Between the application's role and the memory blocks. */
20
+ export const PLAN_SECTION_ORDER = 25
21
+ export const PLAN_OPEN_TAG = '<proposed_plan>'
22
+ export const PLAN_CLOSE_TAG = '</proposed_plan>'
23
+
24
+ export const PLAN_INSTRUCTIONS = `Plan mode is on for this request. Investigate and design; do not carry out the work.
25
+ - Read, search and recall freely. When a decision belongs to the user, ask with ${ASK_USER} instead of guessing.
26
+ - Do not change files, run commands, write memory, delegate, or call connected apps. Such calls are refused while plan mode is on.
27
+ - When the plan is ready, end the reply with exactly one block in this form, after any other text:
28
+ ${PLAN_OPEN_TAG}
29
+ # <Plan title>
30
+ <Markdown: the context, the steps with the files they change, and how to verify the result>
31
+ ${PLAN_CLOSE_TAG}
32
+ - If you need the user's input before a plan is possible, ask instead of proposing one.`
33
+
34
+ /** The tools a plan-mode run may call: the read-only set and the provider tools marked read-only. */
35
+ export function planModeTools(normal: readonly string[], readOnly: Iterable<string> = []): string[] {
36
+ const allowed = new Set([...PLAN_READ_ONLY_TOOLS, ...readOnly])
37
+ return normal.filter((name) => allowed.has(name))
38
+ }
39
+
40
+ /** Why a plan-mode run may not call this tool, or null when it may. */
41
+ export function planRefusal(tool: string, readOnly: ReadonlySet<string>): string | null {
42
+ if (PLAN_READ_ONLY_TOOLS.has(tool) || readOnly.has(tool)) return null
43
+ return `Plan mode is read-only: ${tool} is not available until the user leaves plan mode. Continue investigating, or finish with a ${PLAN_OPEN_TAG} block.`
44
+ }
45
+
46
+ /**
47
+ * The plan section for this request. While plan mode is on it carries the instructions; on the
48
+ * first request after it, the empty slot withdraws the earlier instructions from the conversation.
49
+ * Otherwise there is no section, so a conversation that never planned is delivered exactly as before.
50
+ */
51
+ export function planSection(plan: boolean, historyFile: string | null): HostSection | null {
52
+ if (plan) return { id: PLAN_SECTION_ID, title: 'Plan mode', body: PLAN_INSTRUCTIONS, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
53
+ const delivered = historyFile ? deliveredSnapshot(historyFile) : null
54
+ return delivered?.some((entry) => entry.id === PLAN_SECTION_ID)
55
+ ? { id: PLAN_SECTION_ID, title: 'Plan mode', body: null, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
56
+ : null
57
+ }
@@ -400,6 +400,29 @@ describe('session turns on Pi', () => {
400
400
  expect(contexts).toEqual([policyContext])
401
401
  })
402
402
 
403
+ it('refuses non-read-only calls in plan mode and delivers its instructions without changing the system prompt', async () => {
404
+ const seen: { system: string; update: string | null; reasons: (string | null)[] }[] = []
405
+ const { run, harness } = setup(async ({ gate, params }) => fakeSession(async () => {
406
+ const reasons = [await gate.check('bash', {}), await gate.check('write', {}), await gate.check('memory_append', {}), await gate.check('lookup', {}), await gate.check('change', {})]
407
+ seen.push({ system: params.system, update: params.update?.content ?? null, reasons })
408
+ }, gate))
409
+ harness.approvals.setAuto('chat', true)
410
+ harness.sessions.provide(() => [
411
+ { name: 'lookup', label: 'Lookup', description: 'Reads', readOnly: true, parameters: (Type) => Type.Object({}), execute: async () => 'ok' },
412
+ { name: 'change', label: 'Change', description: 'Writes', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }
413
+ ])
414
+ await run('plan-run', { plan: true, tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
415
+ await tick()
416
+ await run('normal-run', { tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
417
+ await tick()
418
+ const [planned, normal] = seen
419
+ expect(planned.reasons.map((reason) => reason?.startsWith('Plan mode is read-only') ?? null)).toEqual([true, true, true, null, true])
420
+ expect(normal.reasons).toEqual([null, null, null, null, null])
421
+ expect(planned.system).toBe(normal.system)
422
+ expect(planned.update).toContain('<proposed_plan>')
423
+ expect(normal.update ?? '').not.toContain('Plan mode is on')
424
+ })
425
+
403
426
  it('aborts the Pi session on interrupt', async () => {
404
427
  let session: (PiSessionLike & { aborted: boolean }) | null = null
405
428
  const { events, run, harness } = setup(async ({ gate }) => (session = fakeSession(() => new Promise((resolve) => setTimeout(resolve, 50)), gate)))
@@ -34,6 +34,7 @@ import type { ToolPolicy } from './policy'
34
34
  import type { ActiveRun, RunRegistry } from './registry'
35
35
  import { piSessionFactory, runTurn, summarize, type Effort, type PiSessionLike, type SessionFactory, type ToolGate, type TurnParams } from './runner'
36
36
  import { loadRun, runDir, saveRun, updateRun } from './runStore'
37
+ import { planRefusal, planSection } from './planMode'
37
38
 
38
39
  /** The fields of a run record the caller decides; the harness fills in the rest. */
39
40
  export type RunFields = Pick<RunRecord, 'id' | 'sessionId'> & Partial<Pick<RunRecord, 'mode' | 'harness' | 'sessionType' | 'projectId' | 'agentId' | 'trigger' | 'goal' | 'attachmentCount' | 'resumedFromRunId' | 'parentRunId' | 'relation' | 'worktreeId' | 'handoffId'>>
@@ -73,6 +74,11 @@ export interface SessionRunInput {
73
74
  subagents?: SubagentOptions | null
74
75
  /** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
75
76
  providers?: boolean
77
+ /**
78
+ * Plan mode: the run may only read, search, recall and ask, and is told to end with a
79
+ * `<proposed_plan>` block. Other calls are refused at the gate; the tool list is unchanged.
80
+ */
81
+ plan?: boolean
76
82
  }
77
83
 
78
84
  /** The connected-apps broker, as a session sees it. */
@@ -240,8 +246,10 @@ export class Sessions {
240
246
  try {
241
247
  if (!existsSync(input.sessionDir)) mkdirSync(input.sessionDir, { recursive: true })
242
248
  const launch = input.launch ?? {}
249
+ const plan = planSection(input.plan === true, historyFile)
243
250
  const effectiveLaunch = {
244
251
  ...launch,
252
+ sections: plan ? [...(launch.sections ?? []).filter((section) => section.id !== plan.id), plan] : launch.sections,
245
253
  tools: capabilities.tools,
246
254
  connectionTools: (launch.connectionTools ?? []).filter((name) => capabilities.tools.includes(name)),
247
255
  toolGroups: launch.toolGroups?.map((group) => ({ ...group, tools: group.tools.filter((name) => capabilities.tools.includes(name)) })).filter((group) => group.tools.length > 0)
@@ -269,6 +277,7 @@ export class Sessions {
269
277
  ...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
270
278
  ]
271
279
  if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
280
+ const readOnly = new Set(tools.filter((tool) => tool.readOnly).map((tool) => tool.name))
272
281
  const params: TurnParams = {
273
282
  runId,
274
283
  restrictTools: input.run.relation === 'subagent',
@@ -294,7 +303,14 @@ export class Sessions {
294
303
  allow,
295
304
  gate: (() => {
296
305
  const gate = this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name))))
297
- return { ...gate, check: (name: string, args: unknown) => input.run.relation === 'subagent' && !capabilities.tools.includes(name) ? Promise.resolve('This tool is outside the child capability snapshot.') : gate.check(name, args) }
306
+ return {
307
+ ...gate,
308
+ check: (name: string, args: unknown) => {
309
+ if (input.run.relation === 'subagent' && !capabilities.tools.includes(name)) return Promise.resolve('This tool is outside the child capability snapshot.')
310
+ const refusal = input.plan ? planRefusal(name, readOnly) : null
311
+ return refusal ? Promise.resolve(refusal) : gate.check(name, args)
312
+ }
313
+ }
298
314
  })(),
299
315
  tools,
300
316
  runtime: () => this.options.accounts.runtime(context),
@@ -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 read-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 read-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) {
@@ -5,6 +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 } from './commands'
8
9
  import type { TerminalDecision } from './runtime'
9
10
  import { terminalKeybindings } from './keybindings'
10
11
  import { autocompleteRuleColor, createHarnessTheme } from './theme'
@@ -21,8 +22,8 @@ function setup() {
21
22
  }
22
23
  keybindings: ReturnType<typeof terminalKeybindings>
23
24
  editor: CustomEditor
24
- 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 } }
25
- commands: { execute: ReturnType<typeof vi.fn> }
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 }
26
27
  masthead: { render(width: number): string[] }
27
28
  header?: { render(width: number): string[]; invalidate(): void }
28
29
  branch: string | null
@@ -49,7 +50,7 @@ function setup() {
49
50
  app.keybindings = terminalKeybindings('/unused/isolated-account')
50
51
  app.initialized = true
51
52
  const context = {}
52
- 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: () => ({
53
54
  hasUserMessages: false, cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
54
55
  thinkingLevel: 'high', account: { label: 'Tanny' }, context: { percent: null, window: 0 }, costUsd: 0, subscription: false
55
56
  }), session: {
@@ -223,7 +224,7 @@ describe('the owned terminal application', () => {
223
224
  const { application, app } = setup()
224
225
  app.mountEditor()
225
226
  let resolve!: (value: boolean) => void
226
- 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 }
227
228
  app.submit(' /settings')
228
229
  await vi.waitFor(() => expect(app.commands.execute).toHaveBeenCalledWith(' /settings'))
229
230
  app.submit('/model')
@@ -234,17 +235,33 @@ describe('the owned terminal application', () => {
234
235
  await application.shutdown()
235
236
  })
236
237
 
237
- it('toggles plan mode on Shift+Tab, narrowing to read-only tools and leaving thinking alone', async () => {
238
+ it('keeps a queued plan isolated and sends later messages after it settles', async () => {
238
239
  const { application, app } = setup()
239
- const session = app.runtime.session as { getActiveToolNames(): string[]; setActiveToolsByName(names: string[]): void; cycleThinkingLevel(): void }
240
- const setActiveToolsByName = vi.fn()
241
- const cycleThinkingLevel = vi.fn()
242
- Object.assign(session, { getActiveToolNames: () => ['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'], setActiveToolsByName, cycleThinkingLevel })
243
- expect(app.dispatchInput('\x1b[Z')).toEqual({ consume: true })
244
- expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'grep'])
245
- expect(app.dispatchInput('\x1b[Z')).toEqual({ consume: true })
246
- expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'])
247
- expect(cycleThinkingLevel).not.toHaveBeenCalled()
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)
246
+ app.mountEditor()
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()
248
265
  await application.shutdown()
249
266
  })
250
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'
@@ -30,6 +29,7 @@ import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../traje
30
29
 
31
30
  const exec = promisify(execFile)
32
31
  type Mounted = Component & { dispose?(): void }
32
+ type DeferredTerminalInput = { kind: 'plan'; text: string } | { kind: 'prompt'; text: string; followUp: boolean }
33
33
  type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
34
34
  type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
35
35
  type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
@@ -102,8 +102,11 @@ export class TerminalApplication {
102
102
  private phase?: string
103
103
  private viewport?: ScrollView
104
104
  private toolsExpanded = false
105
- private planMode = false
106
- private normalTools?: string[]
105
+ private deferredInputs: DeferredTerminalInput[] = []
106
+ private planBarrierOpen = false
107
+ private planActive = false
108
+ private drainingDeferred = false
109
+ private activeDeferredKind?: DeferredTerminalInput['kind']
107
110
  private stopped = false
108
111
  private initialized = false
109
112
  private closing?: Promise<void>
@@ -184,6 +187,7 @@ export class TerminalApplication {
184
187
  session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
185
188
  cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
186
189
  refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
190
+ plan: brief => this.enqueuePlan(brief),
187
191
  copyText: copyToClipboard, openExternal,
188
192
  changed: this.change,
189
193
  trajectory: {
@@ -201,7 +205,7 @@ export class TerminalApplication {
201
205
  })
202
206
  this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
203
207
  this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
204
- this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.planMode = false; this.normalTools = undefined; this.refresh(true) }))
208
+ this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.refresh(true) }))
205
209
  this.initialized = true
206
210
  this.phase = undefined
207
211
  this.refresh(true)
@@ -306,19 +310,46 @@ export class TerminalApplication {
306
310
  this.completionStatus = undefined
307
311
  this.phase = undefined
308
312
  }
309
- /** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
310
- private togglePlanMode(): void {
311
- if (!this.runtime) return
312
- if (this.planMode) {
313
- this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames())
314
- this.normalTools = undefined
315
- } else {
316
- this.normalTools = this.session.getActiveToolNames()
317
- this.session.setActiveToolsByName(planModeTools(this.normalTools))
318
- }
319
- this.planMode = !this.planMode
320
- this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Shift+Tab returns to normal.' : 'Normal mode: all tools are available.')
313
+ private enqueuePlan(brief: string): void {
314
+ const text = brief.trim()
315
+ if (!text) { this.notify('Use /plan <plan brief>.', 'error'); return }
316
+ const waiting = !this.session.isIdle
317
+ this.planBarrierOpen = true
318
+ this.deferredInputs.push({ kind: 'plan', text })
319
+ this.notify(waiting ? 'Plan request queued to run by itself after the current response.' : 'Plan request queued as an isolated run.')
321
320
  this.change()
321
+ this.drainDeferred()
322
+ }
323
+ private drainDeferred(): void {
324
+ if (this.drainingDeferred || !this.planBarrierOpen || !this.runtime || !this.session.isIdle || !this.deferredInputs.length) return
325
+ this.drainingDeferred = true
326
+ void (async () => {
327
+ try {
328
+ while (this.deferredInputs.length && !this.stopped && this.runtime && this.session.isIdle) {
329
+ const next = this.deferredInputs.shift()!
330
+ this.activeDeferredKind = next.kind
331
+ this.change()
332
+ try {
333
+ if (next.kind === 'plan') {
334
+ this.planActive = true
335
+ await this.runtime.submitPlan(next.text)
336
+ } else {
337
+ await this.runtime.submit(next.text, next.followUp)
338
+ }
339
+ } catch (error) {
340
+ this.notify(error instanceof Error ? error.message : String(error), 'error')
341
+ } finally {
342
+ if (next.kind === 'plan') this.planActive = false
343
+ this.activeDeferredKind = undefined
344
+ }
345
+ }
346
+ } finally {
347
+ this.drainingDeferred = false
348
+ if (!this.deferredInputs.length && !this.planActive) this.planBarrierOpen = false
349
+ this.change()
350
+ if (this.deferredInputs.length && this.runtime?.session.isIdle) this.drainDeferred()
351
+ }
352
+ })()
322
353
  }
323
354
  private scrollToLatest(): void { this.viewport?.scrollToEnd() }
324
355
  private async chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: 'approved' | 'denied'; alwaysAllow: boolean }> {
@@ -395,7 +426,9 @@ export class TerminalApplication {
395
426
  private queuedMessages(width: number): string[] {
396
427
  if (!this.ui) return []
397
428
  const state = this.runtime?.snapshot()
398
- 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)) : []
429
+ const native = state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)] : []
430
+ const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `Plan: /plan ${input.text}` : `Queued: ${input.text}`)
431
+ return [...native, ...deferred].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width))
399
432
  }
400
433
  private statusLines(width: number): string[] {
401
434
  return this.statuses.size ? wrapTextWithAnsi([...this.statuses.values()].join(' '), width) : []
@@ -425,7 +458,13 @@ export class TerminalApplication {
425
458
  if (this.stopped) return
426
459
  if (command && await this.commands.execute(text)) return
427
460
  if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
428
- await this.runtime!.submit(this.commands.expandSkill(text), followUp)
461
+ const expanded = this.commands.expandSkill(text)
462
+ if (this.planBarrierOpen) {
463
+ this.deferredInputs.push({ kind: 'prompt', text: expanded, followUp })
464
+ this.change()
465
+ return
466
+ }
467
+ await this.runtime!.submit(expanded, followUp)
429
468
  } finally { if (command) this.commandBusy = false }
430
469
  })
431
470
  }
@@ -446,7 +485,6 @@ export class TerminalApplication {
446
485
  else void this.shutdown()
447
486
  } else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
448
487
  else if (is('app.thinking.cycle')) this.session.cycleThinkingLevel()
449
- else if (is('app.plan.toggle')) this.togglePlanMode()
450
488
  else if (is('app.thinking.increase') || is('app.thinking.decrease')) this.stepThinking(is('app.thinking.increase') ? 1 : -1)
451
489
  else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
452
490
  else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
@@ -454,7 +492,14 @@ export class TerminalApplication {
454
492
  else if (is('app.tools.expand')) this.ui.setToolsExpanded(!this.toolsExpanded)
455
493
  else if (is('app.thinking.toggle')) { const hidden = !this.session.settingsManager.getHideThinkingBlock(); this.session.settingsManager.setHideThinkingBlock(hidden); this.transcript.setThinking(hidden) }
456
494
  else if (is('app.message.followUp')) this.submit(this.editor.getExpandedText?.() ?? this.editor.getText(), true)
457
- 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()) }
495
+ else if (is('app.message.dequeue')) {
496
+ const pending = this.session.clearQueue()
497
+ const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `/plan ${input.text}` : input.text)
498
+ this.deferredInputs = []
499
+ this.planBarrierOpen = this.planActive || this.activeDeferredKind === 'plan'
500
+ this.editor.setText([...pending.steering, ...pending.followUp, ...deferred, this.editor.getText()].filter(Boolean).join('\n'))
501
+ this.syncDraft(this.editor.getText())
502
+ }
458
503
  else if (is('app.editor.external')) this.attempt(() => this.externalEditor())
459
504
  else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
460
505
  else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
@@ -497,6 +542,7 @@ export class TerminalApplication {
497
542
  this.requestStartedAt = null
498
543
  this.phase = undefined
499
544
  this.terminal.setProgress(false)
545
+ queueMicrotask(() => this.drainDeferred())
500
546
  }
501
547
  if (event.type === 'compaction_start') this.phase = 'Compacting context…'
502
548
  if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
@@ -513,12 +559,20 @@ export class TerminalApplication {
513
559
  const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
514
560
  if (text) this.editor.addToHistory?.(text)
515
561
  }
516
- this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd), this.commands.skills)
562
+ const commands = this.commands.completions()
563
+ if (this.editor instanceof HarnessEditor) {
564
+ this.editor.setValidCommands([
565
+ ...commands.map(command => command.name),
566
+ ...this.commands.skills().map(skill => 'skill:' + skill.name)
567
+ ])
568
+ }
569
+ this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(commands, this.runtime.cwd), this.commands.skills)
517
570
  this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
518
571
  this.change()
519
572
  }
520
573
  private resetPresentation(): void {
521
574
  this.pendingAbort?.abort(); this.dismissedDecisions.clear()
575
+ this.deferredInputs = []; this.planBarrierOpen = false; this.planActive = false; this.activeDeferredKind = undefined
522
576
  this.resetWorkingStatus()
523
577
  this.requestStartedAt = null; this.lastTokensPerSecond = null
524
578
  this.transcript.clearSession()
@@ -31,6 +31,7 @@ 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
+ plan: vi.fn(),
34
35
  hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
35
36
  trajectory: {
36
37
  list: vi.fn(async () => [trajectoryRun]),
@@ -64,6 +65,18 @@ describe('owned terminal commands', () => {
64
65
  expect(new Set(names).size).toBe(names.length)
65
66
  })
66
67
 
68
+ it('runs one plan request from /plan <brief> and rejects toggle syntax', async () => {
69
+ const { commands, host, ui } = setup()
70
+ expect(await commands.execute('/plan inspect the current implementation')).toBe(true)
71
+ expect(host.plan).toHaveBeenCalledWith('inspect the current implementation')
72
+ expect(await commands.execute('/plan')).toBe(true)
73
+ expect(await commands.execute('/plan on')).toBe(true)
74
+ expect(await commands.execute('/plan toggle')).toBe(true)
75
+ expect(host.plan).toHaveBeenCalledOnce()
76
+ expect(ui.notify).toHaveBeenLastCalledWith('Use /plan <plan brief>.', 'error')
77
+ expect(commands.completions().find(item => item.name === 'plan')?.description).toContain('/plan <brief>')
78
+ })
79
+
67
80
  it('invokes a loaded skill as $name, and leaves other text for the SDK', () => {
68
81
  const { commands, session } = setup()
69
82
  expect(commands.skills()).toEqual([{ name: 'diagnose', description: 'Diagnose a problem' }])
@@ -21,6 +21,7 @@ export interface TerminalCommandHost {
21
21
  sessionDirectory(): string
22
22
  refresh(): void
23
23
  changed(): void
24
+ plan(brief: string): void
24
25
  shutdown(): void
25
26
  setEditorText(text: string): void
26
27
  copyText(text: string): Promise<void>
@@ -39,6 +40,7 @@ export interface TerminalCommandCompletion { name: string; description?: string
39
40
 
40
41
  const BUILTINS: TerminalCommandCompletion[] = [
41
42
  { name: 'help', description: 'Available terminal commands' },
43
+ { name: 'plan', description: 'Run one read-only plan request: /plan <brief>' },
42
44
  { name: 'new', description: 'Start a new session' },
43
45
  { name: 'resume', description: 'Resume a session in this account and folder' },
44
46
  { name: 'tree', description: 'Navigate the session tree' },
@@ -286,6 +288,11 @@ export function createTerminalCommands(host: TerminalCommandHost) {
286
288
  const session = host.session()
287
289
  switch (name) {
288
290
  case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
291
+ case 'plan': {
292
+ if (!argument || ['on', 'off', 'toggle'].includes(argument.toLowerCase())) throw new Error('Use /plan <plan brief>.')
293
+ host.plan(argument)
294
+ break
295
+ }
289
296
  case 'quit': host.shutdown(); break
290
297
  case 'new': await host.runtime().newSession(); break
291
298
  case 'resume': {
@@ -15,6 +15,12 @@ export const COMPOSER_SUGGESTIONS = [
15
15
  'write a test for the next change'
16
16
  ]
17
17
 
18
+ function leadingSlashCommand(text: string, validCommands: ReadonlySet<string>): string | null {
19
+ const match = /^\s*\/([^\s/]+)(?=\s|$)/u.exec(text)
20
+ if (!match || !validCommands.has(match[1]!)) return null
21
+ return '/' + match[1]
22
+ }
23
+
18
24
  export interface ComposerView {
19
25
  band(width: number, hiddenAbove: number, hiddenBelow: number): string
20
26
  theme(): Theme
@@ -27,11 +33,17 @@ export class HarnessEditor extends CustomEditor {
27
33
  private contentRows = 1
28
34
  private completionRows = 0
29
35
  private gutterWidth = 2
36
+ private validCommands = new Set<string>()
30
37
 
31
38
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
32
39
  super(tui, theme, keybindings, { paddingX: 0 })
33
40
  }
34
41
 
42
+ setValidCommands(commands: readonly string[]): void {
43
+ this.validCommands = new Set(commands)
44
+ this.tui.requestRender()
45
+ }
46
+
35
47
  override setPaddingX(_padding: number): void {
36
48
  super.setPaddingX(0)
37
49
  }
@@ -58,10 +70,13 @@ export class HarnessEditor extends CustomEditor {
58
70
  this.contentRows = content.length
59
71
  const theme = this.view.theme()
60
72
  const placeholder = this.getText() === '' ? this.view.placeholder() : undefined
73
+ const command = leadingSlashCommand(this.getText(), this.validCommands)
74
+ const commandLine = command ? content.findIndex(line => line.includes(command)) : -1
61
75
  const prompt = content.map((line, index) => {
62
76
  const cue = theme.fg('dim', index === 0 ? '❯ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth))
63
77
  if (index === 0 && placeholder) return cue + this.placeholderRow(placeholder, innerWidth, theme)
64
- return cue + line
78
+ const renderedLine = index === commandLine && command ? line.replace(command, theme.fg('mdLink', command)) : line
79
+ return cue + renderedLine
65
80
  })
66
81
  const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
67
82
  this.completionRows = completions.length
@@ -4,11 +4,10 @@ import { join } from 'node:path'
4
4
  import { expect, it } from 'vitest'
5
5
  import { terminalKeybindings } from './keybindings'
6
6
 
7
- it('steps thinking effort on shift arrows and gives shift+tab to plan mode', () => {
7
+ it('steps thinking effort on shift arrows', () => {
8
8
  const manager = terminalKeybindings('/unused/isolated-account')
9
9
  expect(manager.matches('\x1b[1;2A', 'app.thinking.increase')).toBe(true)
10
10
  expect(manager.matches('\x1b[1;2B', 'app.thinking.decrease')).toBe(true)
11
- expect(manager.matches('\x1b[Z', 'app.plan.toggle')).toBe(true)
12
11
  expect(manager.matches('\x1b[Z', 'app.thinking.cycle')).toBe(false)
13
12
  })
14
13
 
@@ -7,7 +7,6 @@ declare module '@earendil-works/pi-tui' {
7
7
  interface Keybindings {
8
8
  'app.thinking.increase': true
9
9
  'app.thinking.decrease': true
10
- 'app.plan.toggle': true
11
10
  }
12
11
  }
13
12
 
@@ -15,7 +14,7 @@ declare module '@earendil-works/pi-tui' {
15
14
  const actions: Record<string, string | string[]> = {
16
15
  'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
17
16
  'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
18
- 'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s', 'app.plan.toggle': 'shift+tab',
17
+ 'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s',
19
18
  'app.thinking.increase': 'shift+up', 'app.thinking.decrease': 'shift+down',
20
19
  'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
21
20
  'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',