@xingular/harness 0.1.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +100 -10
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -5,17 +5,22 @@ import { isAbsolute, join, relative, resolve } from 'node:path'
5
5
  import { promisify } from 'node:util'
6
6
  import { ModelSelectorComponent, TreeSelectorComponent, SessionManager, type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent'
7
7
  import { SettingsPanel, type TerminalSettingItem } from './settings'
8
+ import { DialogPanel, SearchableSelect } from './dialogs'
9
+ import { TrajectoryPanel } from './trajectoryPanel'
10
+ import { expandSkillInvocation, type SkillEntry } from './skillInvocation'
8
11
  import manifest from '../../../package.json'
9
12
  import type { TerminalRuntime } from './runtime'
13
+ import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
10
14
 
11
15
  export interface TerminalCommandHost {
12
16
  session(): AgentSession
13
17
  /** Navigation is scope checked by the Harness runtime adapter. */
14
- runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId'>
18
+ runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId' | 'permissions' | 'setFullPermission' | 'setGlobalTool'>
15
19
  ui: ExtensionUIContext
16
20
  cwd(): string
17
21
  sessionDirectory(): string
18
22
  refresh(): void
23
+ changed(): void
19
24
  shutdown(): void
20
25
  setEditorText(text: string): void
21
26
  copyText(text: string): Promise<void>
@@ -24,6 +29,10 @@ export interface TerminalCommandHost {
24
29
  suspend?(): Promise<void>
25
30
  hotkeys?(): string
26
31
  changelog?(): Promise<string>
32
+ trajectory: {
33
+ list(): Promise<TerminalTrajectoryRun[]>
34
+ read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
35
+ }
27
36
  }
28
37
 
29
38
  export interface TerminalCommandCompletion { name: string; description?: string }
@@ -35,12 +44,14 @@ const BUILTINS: TerminalCommandCompletion[] = [
35
44
  { name: 'tree', description: 'Navigate the session tree' },
36
45
  { name: 'fork', description: 'Fork from a user message' },
37
46
  { name: 'model', description: 'Choose a model' },
38
- { name: 'thinking', description: 'Choose thinking effort' },
47
+ { name: 'effort', description: 'Choose thinking effort' },
39
48
  { name: 'clone', description: 'Clone the current branch to a new session' },
40
49
  { name: 'scoped-models', description: 'Choose models for cycling' },
41
50
  { name: 'settings', description: 'Session and terminal settings' },
51
+ { name: 'permissions', description: 'Review and change terminal approval permissions' },
42
52
  { name: 'name', description: 'Name the current session' },
43
53
  { name: 'session', description: 'Current session statistics' },
54
+ { name: 'trajectory', description: 'Inspect recorded runs for this native session' },
44
55
  { name: 'compact', description: 'Compact the current context' },
45
56
  { name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
46
57
  { name: 'trust', description: 'Review plugin code and project trust' },
@@ -72,11 +83,16 @@ export function createTerminalCommands(host: TerminalCommandHost) {
72
83
  for (const prompt of session.resourceLoader.getPrompts().prompts) {
73
84
  if (!commands.has(prompt.name)) commands.set(prompt.name, { name: prompt.name, description: prompt.description })
74
85
  }
75
- if (session.settingsManager.getEnableSkillCommands()) {
76
- for (const skill of session.resourceLoader.getSkills().skills) commands.set(`skill:${skill.name}`, { name: `skill:${skill.name}`, description: skill.description })
77
- }
78
86
  return [...commands.values()]
79
87
  }
88
+ /** The skills this session may be asked to use, each invoked as `$name`. Off with the "Skill commands" setting. */
89
+ const skills = (): SkillEntry[] => {
90
+ const session = host.session()
91
+ if (!session.settingsManager.getEnableSkillCommands()) return []
92
+ return session.resourceLoader.getSkills().skills.map((skill) => ({ name: skill.name, description: skill.description }))
93
+ }
94
+ /** The text to send: a leading `$name` of a loaded skill becomes the `/skill:name` form Pi expands. */
95
+ const expandSkill = (text: string): string => expandSkillInvocation(text, new Set(skills().map((skill) => skill.name)))
80
96
 
81
97
  async function settings() {
82
98
  const session = host.session()
@@ -192,6 +208,68 @@ export function createTerminalCommands(host: TerminalCommandHost) {
192
208
  } finally { await rm(directory, { recursive: true, force: true }) }
193
209
  }
194
210
 
211
+ async function trajectory(runId: string) {
212
+ const runs = await host.trajectory.list()
213
+ if (!runs.length) { notify('No runs recorded for this native session yet'); return }
214
+ const initialIndex = runId ? runs.findIndex(run => run.id === runId) : 0
215
+ if (initialIndex < 0) throw new Error('Run not found in this native session')
216
+ const initial = await host.trajectory.read(runs[initialIndex]!.id)
217
+ await host.ui.custom<void>((tui, theme, _keys, done) => {
218
+ const content = new TrajectoryPanel({
219
+ tui, theme, runs, initialIndex, initial,
220
+ read: host.trajectory.read,
221
+ copy: host.copyText,
222
+ close: () => done(undefined),
223
+ changed: host.changed,
224
+ notify: (message, type) => host.ui.notify(message, type === 'error' || type === 'warning' ? type : 'info')
225
+ })
226
+ const dialog = new DialogPanel('Trajectory', content, () => theme, '←/→ runs · ↑/↓ events · PgUp/PgDn details · C copy · Esc close')
227
+ return Object.assign(dialog, { dispose: () => content.dispose() })
228
+ }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
229
+ }
230
+
231
+ async function permissions() {
232
+ for (;;) {
233
+ const runtime = host.runtime()
234
+ const state = await runtime.permissions()
235
+ const activeTools = host.session().getActiveToolNames()
236
+ const granted = new Set(state.globalTools)
237
+ const toolNames = [...new Set([...activeTools, ...granted])].sort((a, b) => a.localeCompare(b))
238
+ const entries = [
239
+ { id: 'full', label: `Full Permission: ${state.full ? 'on' : 'off'}` },
240
+ ...toolNames.map(tool => ({ id: `tool:${tool}`, label: `${tool}: allow everywhere ${granted.has(tool) ? 'on' : 'off'}` })),
241
+ { id: 'done', label: 'Done' }
242
+ ]
243
+ const description = [
244
+ 'Permissions apply to future Pi terminal approvals using this local Harness state.',
245
+ 'A tool grant allows every call with that tool name, regardless of its arguments.',
246
+ 'Full Permission approves every eligible future request. Hard denials and disabled tools still apply; human-only requests stay manual unless the host explicitly opts them in.',
247
+ 'Existing pending requests are never approved by changing these settings.'
248
+ ].join('\n')
249
+ const selected = await host.ui.custom<string | undefined>((tui, theme, _keys, done) => {
250
+ const list = new SearchableSelect(entries.map(entry => entry.label), tui, () => theme,
251
+ index => done(entries[index]?.id), () => done(undefined))
252
+ return new DialogPanel('Permissions', list, () => theme, 'Enter change · Type to search · Esc close', description)
253
+ }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
254
+ if (!selected || selected === 'done') return
255
+ if (selected === 'full') {
256
+ const desired = !state.full
257
+ const latest = await runtime.permissions()
258
+ if (latest.full !== desired) {
259
+ if (!desired || await host.ui.confirm('Enable Full Permission?', 'Automatically approve eligible future requests in every Pi terminal session sharing this local Harness state. Hard denials and disabled tools still apply; human-only requests stay manual unless the host explicitly opts them in.')) {
260
+ await runtime.setFullPermission(desired)
261
+ }
262
+ }
263
+ continue
264
+ }
265
+ const tool = selected.slice('tool:'.length)
266
+ if (!tool) continue
267
+ const desired = !granted.has(tool)
268
+ const latest = await runtime.permissions()
269
+ if (latest.globalTools.includes(tool) !== desired) await runtime.setGlobalTool(tool, desired)
270
+ }
271
+ }
272
+
195
273
  async function execute(text: string): Promise<boolean> {
196
274
  const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim())
197
275
  if (!match) return false
@@ -199,6 +277,8 @@ export function createTerminalCommands(host: TerminalCommandHost) {
199
277
  const argument = match[2]?.trim() ?? ''
200
278
  if (!BUILTINS.some(item => item.name === name)) {
201
279
  if (completions().some(item => item.name === name)) return false
280
+ // A skill is offered as $name; the typed /skill:name form still reaches the SDK, which expands it.
281
+ if (skills().some(skill => `skill:${skill.name}` === name)) return false
202
282
  host.ui.notify(`Unknown command: /${name}. Use /help to list commands.`, 'error')
203
283
  return true
204
284
  }
@@ -256,7 +336,7 @@ export function createTerminalCommands(host: TerminalCommandHost) {
256
336
  }
257
337
  break
258
338
  }
259
- case 'thinking': {
339
+ case 'effort': {
260
340
  const levels = session.getAvailableThinkingLevels()
261
341
  const level = argument || await host.ui.select('Thinking effort', levels)
262
342
  if (!level) break
@@ -292,12 +372,14 @@ export function createTerminalCommands(host: TerminalCommandHost) {
292
372
  break
293
373
  }
294
374
  case 'settings': await settings(); break
375
+ case 'permissions': await permissions(); break
295
376
  case 'name': {
296
377
  const name = argument || await host.ui.input('Session name', session.sessionName)
297
378
  if (name !== undefined) session.setSessionName(name)
298
379
  break
299
380
  }
300
381
  case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
382
+ case 'trajectory': await trajectory(argument); break
301
383
  case 'compact': await session.compact(argument || undefined); break
302
384
  case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
303
385
  case 'trust': {
@@ -357,5 +439,5 @@ export function createTerminalCommands(host: TerminalCommandHost) {
357
439
  } catch (error) { host.ui.notify(error instanceof Error ? error.message : String(error), 'error') }
358
440
  return true
359
441
  }
360
- return { execute, completions }
442
+ return { execute, completions, skills, expandSkill }
361
443
  }
@@ -1,50 +1,65 @@
1
1
  import { getSelectListTheme, initTheme, type KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
2
- import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, stripTerminalSequences, type TUI, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
2
+ import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, renderFakeCursor, stripTerminalSequences, type TUI, type TuiMouseEvent, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
3
3
  import { describe, expect, it } from 'vitest'
4
4
 
5
- import { HarnessEditor } from './editor'
5
+ import { COMPOSER_SUGGESTIONS, type ComposerView, HarnessEditor } from './editor'
6
6
  import { createHarnessTheme } from './theme'
7
7
 
8
8
  initTheme('dark', false)
9
9
  const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
10
- function setup() {
10
+ const bandText = (width: number) => '[band]'.padEnd(width)
11
+ function setup(view: Partial<ComposerView> = {}) {
11
12
  const editor = new HarnessEditor({ terminal: { rows: 30 }, requestRender() {} } as unknown as TUI,
12
13
  { borderColor: (text) => text, selectList: getSelectListTheme() }, new TuiKeybindingsManager(TUI_KEYBINDINGS) as KeybindingsManager, {
13
- band: (width, above, below) => `${above}:${below}`.padEnd(width, '─'),
14
- theme: () => theme, showHint: () => true, thinkingKey: '⇧⇥'
14
+ band: (width) => bandText(width),
15
+ theme: () => theme, placeholder: () => undefined, ...view
15
16
  })
16
17
  editor.focused = true
17
18
  return editor
18
19
  }
19
20
 
20
- describe('band composer over the native Pi editor', () => {
21
- it('uses exactly a band and a curved prompt row, preserving the real cursor beside the hint', () => {
21
+ describe('Claude-style composer frame over the native Pi editor', () => {
22
+ it('frames the prompt between two full-width rules, with the band below', () => {
22
23
  const editor = setup()
23
- const rows = editor.render(80)
24
- expect(rows).toHaveLength(2)
25
- expect(stripTerminalSequences(rows[1])).toMatch(/^╰─ .*⇧⇥ to change thinking effort$/)
24
+ const rows = editor.render(40)
25
+ expect(rows).toHaveLength(4)
26
+ expect(stripTerminalSequences(rows[0])).toBe('─'.repeat(40))
27
+ expect(rows[0]).toContain('38;2;136;136;136')
28
+ expect(stripTerminalSequences(rows[1])).toMatch(/^❯ /)
26
29
  expect(rows[1]).toContain(CURSOR_MARKER)
27
- expect(rows.every((row) => visibleWidth(row) <= 80)).toBe(true)
30
+ expect(stripTerminalSequences(rows[2])).toBe('─'.repeat(40))
31
+ expect(stripTerminalSequences(rows[3]).trimEnd()).toBe('[band]')
32
+ expect(rows.every((row) => visibleWidth(row) <= 40)).toBe(true)
28
33
  expect(editor.getText()).toBe('')
29
34
  })
30
35
 
31
- it('closes the fake cursor before the hint, so the rest of the row is not reverse video', () => {
32
- const editor = setup()
33
- // Mirrors pi-tui's resolveFakeCursors: the cursor opens reverse video and its end marker closes it.
34
- const resolved = editor.render(80)[1].replace(/\x1b_pi:fc\x07/g, '\x1b[7m').replace(/\x1b_pi:\/fc\x07/g, '\x1b[27m')
35
- const reverseOn = resolved.indexOf('\x1b[7m')
36
- const reverseOff = resolved.indexOf('\x1b[27m')
37
- expect(reverseOn).toBeGreaterThan(-1)
38
- expect(reverseOff).toBeGreaterThan(reverseOn)
39
- expect(reverseOff).toBeLessThan(resolved.indexOf('⇧⇥'))
36
+ it('shows the suggestion as a fake cursor on its first character, only while focused', () => {
37
+ const editor = setup({ placeholder: () => COMPOSER_SUGGESTIONS[0] })
38
+ const rows = editor.render(60)
39
+ expect(stripTerminalSequences(rows[1])).toContain('Try "fix the failing test"')
40
+ expect(rows[1]).toContain(CURSOR_MARKER + renderFakeCursor('T'))
41
+ editor.focused = false
42
+ const idle = editor.render(60)[1]
43
+ expect(idle).not.toContain(CURSOR_MARKER)
44
+ expect(stripTerminalSequences(idle)).toContain('Try "fix the failing test"')
45
+ })
46
+
47
+ it('drops the suggestion as soon as there is a draft', () => {
48
+ const editor = setup({ placeholder: () => 'fix the failing test' })
49
+ editor.handleInput('h')
50
+ const rows = editor.render(60)
51
+ expect(editor.getText()).toBe('h')
52
+ expect(stripTerminalSequences(rows[1])).toMatch(/^❯ h/)
53
+ expect(stripTerminalSequences(rows[1])).not.toContain('Try')
40
54
  })
41
55
 
42
- it('closes the cursor cell so the hint is not painted in reverse video', () => {
56
+ it('does not render a shortcuts or thinking hint row', () => {
43
57
  const editor = setup()
44
- // Why: pi-tui paints its fake cursor as reverse video when it resolves a frame.
45
- const prompt = editor.render(80)[1].replace(/\x1b_pi:fc\x07/g, '\x1b[7m').replace(/\x1b_pi:\/fc\x07/g, '\x1b[27m')
46
- expect(prompt.split('\x1b[7m')).toHaveLength(prompt.split('\x1b[27m').length)
47
- expect(prompt.indexOf('to change')).toBeGreaterThan(prompt.lastIndexOf('\x1b[27m'))
58
+ const rows = editor.render(80).map(stripTerminalSequences)
59
+ expect(rows.join('\n')).not.toContain('? for shortcuts')
60
+ expect(rows.join('\n')).not.toContain('to change thinking effort')
61
+ editor.handleInput('x')
62
+ expect(editor.render(80).map(stripTerminalSequences).join('\n')).not.toContain('to clear')
48
63
  })
49
64
 
50
65
  it('preserves editing, multiline wrapping, history, and cursor markers across resizes', () => {
@@ -67,7 +82,7 @@ describe('band composer over the native Pi editor', () => {
67
82
  expect(editor.getText()).toBe('previous prompt')
68
83
  })
69
84
 
70
- it('retains autocomplete rows and selection without mistaking them for the removed bottom border', async () => {
85
+ it('lists autocomplete above the composer with matching orange dividers and accepts a selection', async () => {
71
86
  const editor = setup()
72
87
  editor.setAutocompleteProvider({
73
88
  getSuggestions: async () => ({ items: [{ value: '/accounts', label: '/accounts' }, { value: '/plugins', label: '/plugins' }], prefix: '/' }),
@@ -76,9 +91,23 @@ describe('band composer over the native Pi editor', () => {
76
91
  editor.handleInput('/')
77
92
  await new Promise((resolve) => setTimeout(resolve, 30))
78
93
  const rows = editor.render(50)
79
- expect(rows.some((row) => stripTerminalSequences(row).includes('/accounts'))).toBe(true)
94
+ const band = rows.findIndex((row) => stripTerminalSequences(row).startsWith('[band]'))
95
+ const completion = rows.findIndex((row) => stripTerminalSequences(row).includes('/accounts'))
96
+ const composerDivider = rows.findIndex((row, index) => index > completion && stripTerminalSequences(row) === '─'.repeat(50))
97
+ expect(rows[0]).toContain('38;2;255;179;71')
98
+ expect(rows[composerDivider]).toContain('38;2;136;136;136')
99
+ expect(completion).toBeGreaterThan(0)
100
+ expect(composerDivider).toBeGreaterThan(completion)
101
+ expect(band).toBeGreaterThan(composerDivider)
80
102
  expect(rows.every((row) => visibleWidth(row) <= 50)).toBe(true)
81
103
  editor.handleInput('\t')
82
104
  expect(editor.getText()).toContain('/accounts')
83
105
  })
106
+
107
+ it('does not hand the band row to the editor as its own', () => {
108
+ const editor = setup()
109
+ editor.render(40)
110
+ const at = (y: number) => ({ x: 5, y, width: 40, height: 5 }) as TuiMouseEvent
111
+ expect(editor.handleMouse(at(3))).toBeUndefined()
112
+ })
84
113
  })
@@ -1,21 +1,32 @@
1
1
  // Harness owns composer geometry and actions; Pi provides the editing primitive.
2
2
  import { CustomEditor, type KeybindingsManager, type Theme } from '@earendil-works/pi-coding-agent'
3
- import { CURSOR_MARKER, type EditorTheme, type TUI, type TuiMouseEvent, sliceByColumn, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
3
+ import { CURSOR_MARKER, type EditorTheme, type TUI, type TuiMouseEvent, parseColor, renderFakeCursor, truncateToWidth } from '@earendil-works/pi-tui'
4
+
5
+ import { autocompleteRuleColor, composerRuleColor } from './theme'
4
6
 
5
7
  const BOTTOM = '\u0000harness-composer-bottom'
6
8
 
9
+ // Generic on purpose: the placeholder names no file, project or product.
10
+ export const COMPOSER_SUGGESTIONS = [
11
+ 'fix the failing test',
12
+ 'explain how this project is laid out',
13
+ 'find where the configuration is loaded',
14
+ 'summarize what changed since the last commit',
15
+ 'write a test for the next change'
16
+ ]
17
+
7
18
  export interface ComposerView {
8
19
  band(width: number, hiddenAbove: number, hiddenBelow: number): string
9
20
  theme(): Theme
10
- showHint(): boolean
11
- thinkingKey: string
21
+ placeholder(): string | undefined
12
22
  }
13
23
 
14
24
  export class HarnessEditor extends CustomEditor {
15
25
  private hiddenAbove = 0
16
26
  private hiddenBelow = 0
17
27
  private contentRows = 1
18
- private gutterWidth = 3
28
+ private completionRows = 0
29
+ private gutterWidth = 2
19
30
 
20
31
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
21
32
  super(tui, theme, keybindings, { paddingX: 0 })
@@ -37,7 +48,7 @@ export class HarnessEditor extends CustomEditor {
37
48
 
38
49
  override render(width: number): string[] {
39
50
  if (width < 1) return []
40
- this.gutterWidth = Math.min(3, Math.max(0, width - 1))
51
+ this.gutterWidth = Math.min(2, Math.max(0, width - 1))
41
52
  const innerWidth = Math.max(1, width - this.gutterWidth)
42
53
  // Pi 1.2 cannot lay out a two-cell grapheme in a one-cell text area.
43
54
  // Lay out safely, then clip the presentation without changing the draft.
@@ -46,33 +57,52 @@ export class HarnessEditor extends CustomEditor {
46
57
  const content = rows.slice(1, bottom)
47
58
  this.contentRows = content.length
48
59
  const theme = this.view.theme()
60
+ const placeholder = this.getText() === '' ? this.view.placeholder() : undefined
49
61
  const prompt = content.map((line, index) => {
50
- const cue = index === 0 ? '╰─ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth)
51
- let text = line
52
- if (index === 0 && this.getText() === '' && this.view.showHint()) {
53
- const hint = theme.fg('accent', this.view.thinkingKey) + ' ' + theme.italic(theme.fg('dim', 'to change thinking effort'))
54
- // Why: a one-cell slice cuts off the fake cursor's closing marker, so pi-tui
55
- // paints the rest of the row in reverse video. Two cells keep the marker.
56
- const prefix = sliceByColumn(line, 0, 2)
57
- const gap = innerWidth - visibleWidth(prefix) - visibleWidth(hint)
58
- if (gap >= 1) text = prefix + ' '.repeat(gap) + hint
59
- }
60
- return theme.fg('borderMuted', cue) + text
62
+ const cue = theme.fg('dim', index === 0 ? '❯ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth))
63
+ if (index === 0 && placeholder) return cue + this.placeholderRow(placeholder, innerWidth, theme)
64
+ return cue + line
61
65
  })
62
66
  const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
63
- const result = [this.view.band(width, this.hiddenAbove, this.hiddenBelow), ...prompt, ...completions].map((line) => truncateToWidth(line, width, ''))
67
+ this.completionRows = completions.length
68
+ const completionDivider = completions.length > 0 ? [this.rule(width, theme, autocompleteRuleColor(theme))] : []
69
+ const result = [
70
+ ...completionDivider, ...completions,
71
+ this.rule(width, theme), ...prompt, this.rule(width, theme), this.view.band(width, this.hiddenAbove, this.hiddenBelow)
72
+ ].map((line) => truncateToWidth(line, width, ''))
64
73
  if (this.focused && !result.some((line) => line.includes(CURSOR_MARKER))) {
65
- const index = Math.max(1, Math.min(result.length - 1, prompt.length))
74
+ const composerOffset = completions.length > 0 ? completions.length + 1 : 0
75
+ const index = composerOffset + Math.max(1, Math.min(result.length - composerOffset - 1, prompt.length))
66
76
  result[index] = truncateToWidth(result[index], Math.max(0, width - 1), '') + CURSOR_MARKER + ' '
67
77
  }
68
78
  return result
69
79
  }
70
80
 
81
+ private rule(width: number, theme: Theme, color = composerRuleColor(theme)): string {
82
+ return theme.style('─'.repeat(width), { fg: parseColor(color) })
83
+ }
84
+
85
+ private placeholderRow(suggestion: string, innerWidth: number, theme: Theme): string {
86
+ const text = truncateToWidth(`Try "${suggestion}"`, innerWidth, '')
87
+ if (!this.focused) return theme.fg('dim', text)
88
+ // The first character is the fake cursor, so an empty prompt reads as the caret it will type into.
89
+ const [first = '', ...rest] = Array.from(text)
90
+ return CURSOR_MARKER + renderFakeCursor(first) + theme.fg('dim', rest.join(''))
91
+ }
92
+
71
93
  override handleMouse(event: TuiMouseEvent) {
94
+ // Pi sees its own rows; the suggestion divider and status band are presentation rows.
95
+ const composerOffset = this.completionRows > 0 ? this.completionRows + 1 : 0
96
+ const band = composerOffset + this.contentRows + 2
97
+ if (event.y === band) return undefined
98
+ if (this.completionRows > 0 && event.y === 0) return undefined
99
+ const y = this.completionRows > 0 && event.y <= this.completionRows
100
+ ? this.contentRows + 1 + event.y
101
+ : event.y - composerOffset
72
102
  return super.handleMouse({
73
103
  ...event,
74
104
  x: event.x - this.gutterWidth,
75
- y: event.y > this.contentRows ? event.y + 1 : event.y,
105
+ y,
76
106
  width: Math.max(1, event.width - this.gutterWidth)
77
107
  })
78
108
  }
@@ -4,6 +4,14 @@ 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', () => {
8
+ const manager = terminalKeybindings('/unused/isolated-account')
9
+ expect(manager.matches('\x1b[1;2A', 'app.thinking.increase')).toBe(true)
10
+ expect(manager.matches('\x1b[1;2B', 'app.thinking.decrease')).toBe(true)
11
+ expect(manager.matches('\x1b[Z', 'app.plan.toggle')).toBe(true)
12
+ expect(manager.matches('\x1b[Z', 'app.thinking.cycle')).toBe(false)
13
+ })
14
+
7
15
  it('reloads edited isolated keybindings on the existing public manager', () => {
8
16
  const root = mkdtempSync(join(tmpdir(), 'harness-keybindings-'))
9
17
  try {
@@ -3,11 +3,20 @@ import { readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { KeybindingsManager, TUI_KEYBINDINGS, type KeybindingDefinitions, type KeybindingsConfig } from '@earendil-works/pi-tui'
5
5
 
6
+ declare module '@earendil-works/pi-tui' {
7
+ interface Keybindings {
8
+ 'app.thinking.increase': true
9
+ 'app.thinking.decrease': true
10
+ 'app.plan.toggle': true
11
+ }
12
+ }
13
+
6
14
  // Harness owns application actions. Editing and selection retain Pi's reusable bindings.
7
15
  const actions: Record<string, string | string[]> = {
8
16
  'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
9
17
  'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
10
- 'app.thinking.cycle': 'shift+tab', 'app.thinking.save': 'ctrl+s',
18
+ 'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s', 'app.plan.toggle': 'shift+tab',
19
+ 'app.thinking.increase': 'shift+up', 'app.thinking.decrease': 'shift+down',
11
20
  'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
12
21
  'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
13
22
  'app.editor.external': 'ctrl+g', 'app.message.copy': 'ctrl+x',
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { planModeTools } from './planMode'
4
+
5
+ describe('plan mode tools', () => {
6
+ it('keeps read-only inspection, questions and recall, and withdraws changes, commands and delegation', () => {
7
+ const normal = ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'ask_user_question', 'wiki_search', 'memory_append', 'spawn_subagent', 'app_tool', 'history_read']
8
+ expect(planModeTools(normal)).toEqual(['read', 'grep', 'find', 'ls', 'ask_user', 'ask_user_question', 'wiki_search', 'history_read'])
9
+ })
10
+ })
@@ -0,0 +1,12 @@
1
+ import { ASK_USER } from '../../tools/askUser'
2
+
3
+ // Read-only inspection, questions and recall. Everything else is withdrawn in plan mode: file changes, commands,
4
+ // memory writes, delegation and connected apps, since none of them can be judged read-only from their names.
5
+ const READ_ONLY_TOOLS = new Set([
6
+ 'read', 'grep', 'find', 'ls', ASK_USER, 'ask_user_question',
7
+ 'history_search', 'history_read', 'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts'
8
+ ])
9
+
10
+ export function planModeTools(normal: readonly string[]): string[] {
11
+ return normal.filter(name => READ_ONLY_TOOLS.has(name))
12
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
2
- import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
7
7
  import { createHarness, type Harness } from '../../harness'
8
8
  import { memorySecretStore } from '../../accounts/secrets'
9
9
  import { connectionFromEnv, socketCall, terminalCall } from '../client'
10
+ import type { TerminalPrepareInput } from '../terminals'
10
11
  import { controlCall } from '../control'
12
+ import { readAllRuns } from '../../sessions/runIndex'
11
13
  import { createTerminalRuntime, type TerminalRuntime } from './runtime'
12
14
 
13
15
  const opened: Array<{ harness: Harness; runtime?: TerminalRuntime; root: string }> = []
@@ -21,7 +23,7 @@ afterEach(async () => {
21
23
  vi.unstubAllEnvs()
22
24
  })
23
25
 
24
- async function setup() {
26
+ async function setup(input: Partial<TerminalPrepareInput> = {}) {
25
27
  const root = mkdtempSync(join(tmpdir(), 'harness-sdk-socket-'))
26
28
  const cwd = join(root, 'project')
27
29
  mkdirSync(cwd)
@@ -30,7 +32,7 @@ async function setup() {
30
32
  opened.push(entry)
31
33
  await harness.ready
32
34
  const account = harness.accounts.create('Offline test', 'apiKey').accounts[0]
33
- const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history') })
35
+ const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history'), ...input })
34
36
  for (const [name, value] of Object.entries(launch.env)) vi.stubEnv(name, value === null ? undefined : value)
35
37
  vi.spyOn(process, 'cwd').mockReturnValue(cwd)
36
38
  const ui = { select: vi.fn(async () => undefined), confirm: vi.fn(async () => false), notify: vi.fn(), getEditorText: () => '' } as unknown as ExtensionUIContext
@@ -40,6 +42,78 @@ async function setup() {
40
42
  }
41
43
 
42
44
  describe('a native SDK runtime on the actual Harness terminal socket', () => {
45
+ it('reads and changes shared terminal permissions only through the claimed private control key', async () => {
46
+ const { runtime } = await setup()
47
+ await expect(socketCall(connectionFromEnv(), 'terminal.permissions.get')).rejects.toThrow('Only a person')
48
+ expect(await runtime.permissions()).toEqual({ full: false, globalTools: [] })
49
+ await runtime.setGlobalTool('bash', true)
50
+ await runtime.setFullPermission(true)
51
+ expect(await runtime.permissions()).toEqual({ full: true, globalTools: ['bash'] })
52
+ await runtime.setGlobalTool('bash', false)
53
+ await runtime.setFullPermission(false)
54
+ expect(await runtime.permissions()).toEqual({ full: false, globalTools: [] })
55
+ })
56
+
57
+ it('runs asynchronous children through the protected bridge and joins their results before settling', async () => {
58
+ const { harness, runtime } = await setup()
59
+ const ai = await import('@earendil-works/pi-ai')
60
+ const faux = ai.fauxProvider()
61
+ runtime.services.modelRuntime.registerNativeProvider(faux.provider)
62
+ harness.accounts.runtime = async () => runtime.services.modelRuntime
63
+ await runtime.session.setModel(faux.getModel())
64
+ const route = (context: { messages: Array<{ role: string; content: unknown }> }) => {
65
+ const users = context.messages.filter(message => message.role === 'user').map(message => typeof message.content === 'string' ? message.content : (message.content as Array<{ text?: string }>).map(part => part.text ?? '').join(''))
66
+ if (users.includes('terminal-native-child')) return ai.fauxAssistantMessage('TERMINAL_CHILD_EVIDENCE')
67
+ if (JSON.stringify(context.messages).includes('TERMINAL_CHILD_EVIDENCE')) return ai.fauxAssistantMessage('Final with TERMINAL_CHILD_EVIDENCE')
68
+ if (!context.messages.some(message => message.role === 'toolResult')) return ai.fauxAssistantMessage([ai.fauxToolCall('spawn_subagent', { brief: 'terminal-native-child' })], { stopReason: 'toolUse' })
69
+ return ai.fauxAssistantMessage('Draft before joining')
70
+ }
71
+ faux.setResponses(Array(8).fill(route) as never)
72
+ await expect(socketCall(connectionFromEnv(), 'terminal.subagents.tools')).rejects.toThrow('Only a person')
73
+ await runtime.session.prompt('Terminal parent')
74
+ const runs = readAllRuns(harness.env.root)
75
+ const parent = runs.find(run => run.sessionId === 'offline-sdk')!
76
+ const child = runs.find(run => run.relation === 'subagent')!
77
+ expect(parent.status).toBe('completed')
78
+ expect(child).toMatchObject({ parentRunId: parent.id, accountId: parent.accountId, status: 'completed' })
79
+ expect(harness.runs.activeRunIds()).toEqual([])
80
+ const entries = readFileSync(runtime.session.sessionFile!, 'utf8').trim().split('\n').map(line => JSON.parse(line))
81
+ const receipts = entries.filter(entry => entry.customType === 'xingular-subagent-results')
82
+ expect(receipts).toHaveLength(1)
83
+ expect(receipts[0].content).toContain('TERMINAL_CHILD_EVIDENCE')
84
+ expect(harness.terminals.state('offline-sdk').active).toBe(false)
85
+ })
86
+
87
+ it('routes a child approval to its original run through the terminal decision controls', async () => {
88
+ const { harness, runtime } = await setup()
89
+ await terminalCall('begin', { text: 'Parent fixture' })
90
+ await controlCall('terminal.subagents.register', { tools: ['read', 'bash', 'spawn_subagent', 'wait_subagents'], history: { owners: [], receipts: [] } })
91
+ const parentId = harness.runs.activeForSession('offline-sdk')!.runId
92
+ const { loadRun, saveRun } = await import('../../sessions/runStore')
93
+ const parent = loadRun(harness.env.root, parentId)
94
+ saveRun(harness.env.root, { ...parent, id: 'approval-child', sessionId: 'opaque-child', parentRunId: parentId, relation: 'subagent', status: 'queued', endedAt: null })
95
+ const active = harness.runs.start('opaque-child', 'approval-child')
96
+ const answer = active.requestApproval({ target: 'bash', action: 'fixture', rememberable: false })
97
+ let pending: Awaited<ReturnType<typeof runtime.pending>> = []
98
+ await vi.waitFor(async () => { pending = await runtime.pending(); expect(pending).toHaveLength(1) })
99
+ await runtime.approve(pending[0].requestId, 'denied')
100
+ await expect(answer).resolves.toBe('denied')
101
+ expect(await runtime.pending()).toEqual([])
102
+ active.finish('interrupted')
103
+ })
104
+
105
+ it('offers native tools without connected apps and preserves a read-only child ceiling', async () => {
106
+ const { harness, runtime } = await setup({ connections: false, readOnly: true })
107
+ expect(runtime.session.getToolDefinition('spawn_subagent')).toBeDefined()
108
+ await terminalCall('begin', { text: 'Read-only parent' })
109
+ await controlCall('terminal.subagents.register', { tools: ['read', 'write', 'bash', 'spawn_subagent', 'wait_subagents'], history: { owners: [], receipts: [] } })
110
+ const parentRunId = harness.runs.activeForSession('offline-sdk')!.runId
111
+ await expect(harness.subagents.spawn({ parentRunId, brief: 'Cannot widen', tools: ['write'] })).rejects.toThrow('cannot have tools')
112
+ const { loadRun } = await import('../../capabilities/runSnapshot')
113
+ expect(loadRun(harness.env.root, parentRunId)?.tools).toEqual(['read', 'spawn_subagent', 'wait_subagents'])
114
+ await runtime.interrupt()
115
+ })
116
+
43
117
  it('keeps the same account, command control and readiness after new/reload with no provider request', async () => {
44
118
  const { harness, runtime, ui } = await setup()
45
119
  expect(harness.terminals.state('offline-sdk')).toEqual({ ready: true, dirty: false, active: false })
@@ -187,6 +187,16 @@ describe('the persistent terminal SDK adapter', () => {
187
187
  expect(runtime.session.sessionFile).toBe(join(realpathSync(history), 'external.jsonl'))
188
188
  })
189
189
 
190
+ it('starts a new conversation when the recorded history file was never written', async () => {
191
+ // Pi writes a session file only after its first message, so a restart before that has nothing to resume.
192
+ const f = fixture()
193
+ const unwritten = join(f.history, 'not-written-yet.jsonl')
194
+ const runtime = await createTerminalRuntime([...f.args, '--session', unwritten], f.host)
195
+ runtimes.push(runtime)
196
+ expect(runtime.session.sessionFile?.startsWith(realpathSync(f.history))).toBe(true)
197
+ expect(runtime.session.sessionFile).not.toBe(unwritten)
198
+ })
199
+
190
200
  it('requires the prepared isolated account and refuses malformed session headers', async () => {
191
201
  const f = fixture()
192
202
  vi.stubEnv('PI_CODING_AGENT_DIR', '')