@xingular/harness 2.0.0 → 2.1.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.
@@ -59,10 +59,12 @@ recovery windows. Real scripted Pi tests in `conversation.test.ts` cover async d
59
59
  receipts and interruption during a child question. `runtime.socket.test.ts` exercises the real
60
60
  terminal socket, private controls, native execution and child decision routing. RPC and App host
61
61
  regressions cover protocol shapes and ancestry. Tests use disposable storage without external
62
- model requests. These are working-tree changes, not a publication or live-provider acceptance claim.
62
+ model requests. These changes shipped in `@xingular/harness` 2.0.0 (commit `44471bc`); release does not imply live-provider acceptance.
63
63
 
64
64
  Verification on 2026-10-18: Harness typecheck, 455 tests (two skipped), unchanged golden fixtures,
65
65
  build and standalone proof passed. App typecheck, layer/UI checks, sidecar build, 30 sidecar tests
66
66
  and 217 Rust tests passed (one ignored). The renderer suite passed 491 tests and retained its
67
67
  pre-existing `PendingRequestDock.test.ts` heading-text assertion failure. No live account or
68
68
  user-data-root testing was performed.
69
+
70
+ Release verification on 2026-10-18: npm 10 clean install, typecheck, 515 tests (two skipped), build and standalone proof passed. GitHub CI passed on Ubuntu and macOS, and the npm publish workflow released version 2.0.0 with provenance. No live provider requests were made during release verification.
@@ -87,16 +87,22 @@ The decision and its consequences are recorded in
87
87
 
88
88
  ## Plan mode and thinking keys
89
89
 
90
- Shift+Tab switches the terminal between normal and plan mode. The switch is announced in the
91
- transcript. Plan mode narrows the session to read-only inspection (`read`, `grep`, `find`, `ls`),
90
+ Use `/plan`, `/plan on`, or `/plan off` to switch between normal and plan mode. The switch is
91
+ announced in the transcript. Plan mode narrows the session to read-only inspection (`read`, `grep`, `find`, `ls`),
92
92
  questions (`ask_user`, `ask_user_question`) and recall (history and Wiki reads, `memory_facts`). Edits,
93
93
  writes, shell commands, memory writes, subagents and connected-app tools are withdrawn until plan mode
94
94
  is switched off, and switching back restores the tools the session had. Plan mode belongs to the open
95
95
  conversation: a new or resumed session starts in normal mode.
96
96
 
97
- The model is not yet told about the mode through the prompt; it only sees the narrower tool set. That
98
- instruction belongs in the prompt compiler, the one place model instructions are built. Shift+Up and
99
- Shift+Down step the thinking level within the levels the model supports, and do not wrap.
97
+ While typing, a recognized leading slash command uses the selected theme's link color. This
98
+ includes built-in commands, loaded extension and prompt commands, and enabled skill invocations
99
+ written as `/skill:name`. Unknown slash tokens keep the normal text color.
100
+
101
+ SDK session runs can receive plan instructions as a dynamic context update, leaving the stable
102
+ system prompt unchanged; their session gate refuses calls outside the read-only set. The standalone
103
+ terminal still narrows Pi's active tools directly and does not yet deliver plan instructions through
104
+ the prompt. Shift+Up and Shift+Down step the thinking level within the levels the model supports,
105
+ and do not wrap.
100
106
 
101
107
  ## Reference and reproduced presentation
102
108
 
@@ -302,9 +308,9 @@ The [live report and capture](evidence/terminal-2026-10-17-followup/live-codex/R
302
308
  this quota failure explicitly. Successful live streaming/tool acceptance remains open until
303
309
  account capacity is available. GitHub gist publication, actual OS clipboard/image transfer,
304
310
  Windows behavior and every terminal emulator remain unverified. Rich image/diff rendering and clipboard adapters have
305
- focused compatibility tests; that does not establish OS interaction. These working-tree changes
306
- are not a published release and the complete plan's acceptance remains open while required
307
- integration/provider evidence is outstanding.
311
+ focused compatibility tests; that does not establish OS interaction. These changes shipped in
312
+ `@xingular/harness` 2.0.0 (commit `44471bc`), while the complete plan's acceptance remains
313
+ open pending the required integration and provider evidence.
308
314
 
309
315
  To reproduce the controlled scenario after building:
310
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
6
6
  "repository": {
@@ -40,6 +40,8 @@ describe('Hub installation', () => {
40
40
  expect(existsSync(join(root, 'config', 'plugin-attachments.json'))).toBe(false)
41
41
  writeFileSync(join(root, 'hub', 'documents', receipt.document!), '{"edited":true}')
42
42
  expect(() => hub.readDocument('example/review', '1.0.0')).toThrow('differs')
43
+ expect((await hub.list()).installed[0]).toMatchObject({ state: 'changed' })
44
+ await expect(hub.install('example/review', '1.0.0')).rejects.toThrow('Uninstall it before installing')
43
45
  })
44
46
  it('imports complete skills disabled and refuses overwriting local files', async () => {
45
47
  const { hub, root } = fixture([entry('registry', skill())])
@@ -90,12 +92,58 @@ describe('Hub installation', () => {
90
92
  expect(preview.delivery.kind !== 'pi-package' && preview.delivery.bundled).toContain('data only')
91
93
  expect(existsSync(join(root, 'hub'))).toBe(false)
92
94
  })
95
+ it('returns bounded UTF-8-safe preview pages and reuses the verified download for install', async () => {
96
+ const content = `"${'é'.repeat(20_000)}"`
97
+ const item = entry('document', content)
98
+ if (item.delivery.kind !== 'pi-package') delete item.delivery.bundled
99
+ const fetcher = vi.fn(async () => new Response(content)) as typeof fetch
100
+ const { hub, root } = fixture([item], fetcher)
101
+ const pages = []
102
+ let offset = 0
103
+ while (true) {
104
+ const page = await hub.preview(item.id, item.version, offset)
105
+ expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(16 * 1024)
106
+ pages.push(page.content)
107
+ if (page.done) break
108
+ offset = page.nextOffset
109
+ }
110
+ expect(pages.join('')).toBe(content)
111
+ expect(fetcher).toHaveBeenCalledTimes(1)
112
+ await hub.install(item.id, item.version)
113
+ expect(fetcher).toHaveBeenCalledTimes(1)
114
+ expect(existsSync(join(root, 'hub', 'documents'))).toBe(true)
115
+ })
116
+ it('uninstalls only its receipt-owned document, including local edits', async () => {
117
+ const first = entry()
118
+ const second = { ...entry(), id: 'example/second', title: 'Second' }
119
+ const { hub, root } = fixture([first, second])
120
+ const owned = await hub.install(first.id, first.version)
121
+ const other = await hub.install(second.id, second.version)
122
+ writeFileSync(join(root, 'hub', 'documents', owned.document!), '{"local":true}')
123
+ await hub.uninstall(first.id, first.version)
124
+ expect(existsSync(join(root, 'hub', 'documents', owned.document!))).toBe(false)
125
+ expect(existsSync(join(root, 'hub', 'documents', other.document!))).toBe(true)
126
+ expect((await hub.list()).installed.map((receipt) => receipt.entry.id)).toEqual([second.id])
127
+ })
128
+ it('uninstalls only receipt-owned registry resources and leaves unrelated files', async () => {
129
+ const { hub, root } = fixture([entry('registry', skill())])
130
+ const receipt = await hub.install('example/review', '1.0.0')
131
+ const unrelated = join(root, 'registry', 'skills', 'unrelated', 'SKILL.md')
132
+ mkdirSync(join(root, 'registry', 'skills', 'unrelated'), { recursive: true })
133
+ writeFileSync(unrelated, '---\nname: unrelated\ndescription: Local.\n---\n')
134
+ await hub.uninstall('example/review', '1.0.0')
135
+ expect(existsSync(join(root, 'registry', 'skills', 'review'))).toBe(false)
136
+ expect(readFileSync(unrelated, 'utf8')).toContain('unrelated')
137
+ expect((await hub.list()).installed).toEqual([])
138
+ expect(receipt.resourceIds).toEqual(['skills/review'])
139
+ })
93
140
  it('can reinstall deleted resources and clears stale attachment grants', async () => {
94
141
  const { hub, root } = fixture([entry('registry', skill())])
95
142
  await hub.install('example/review', '1.0.0')
96
143
  registry.setEnabled(root, null, ['skills/review'])
97
144
  rmSync(join(root, 'registry', 'skills', 'review'), { recursive: true })
98
- expect((await hub.list()).installed).toEqual([])
145
+ expect((await hub.list()).installed[0]).toMatchObject({ state: 'incomplete' })
146
+ await hub.uninstall('example/review', '1.0.0')
99
147
  await hub.install('example/review', '1.0.0')
100
148
  expect(registry.enabledFor(root, null)).toEqual([])
101
149
  expect((await hub.list()).installed).toHaveLength(1)
package/src/hub/hub.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { watch } from 'node:fs'
5
5
 
@@ -12,7 +12,7 @@ import * as registry from '../skills/registry'
12
12
  import * as attachments from '../plugins/attachments'
13
13
  import { version as harnessVersion } from '../../package.json'
14
14
  import bundled from './catalog.json'
15
- import { HUB_CATALOG_URL, HUB_MAX_BYTES, hubCatalogSchema, hubEntrySchema, hubReceiptSchema, meetsMinimum, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './schema'
15
+ import { HUB_CATALOG_URL, HUB_MAX_BYTES, HUB_PREVIEW_PAGE_BYTES, hubCatalogSchema, hubEntrySchema, hubReceiptSchema, meetsMinimum, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './schema'
16
16
 
17
17
  export interface HubOptions {
18
18
  catalogUrl?: string
@@ -60,6 +60,7 @@ export class Hub {
60
60
  private watcher: ReturnType<typeof watch> | null = null
61
61
  private refresh: Promise<HubOverview> | null = null
62
62
  private installing = false
63
+ private cachedPayload: { key: string; bytes: Buffer; expiresAt: number } | null = null
63
64
  get busy(): boolean { return this.installing }
64
65
 
65
66
  constructor(private readonly harness: Harness, options: HubOptions = {}) {
@@ -80,12 +81,25 @@ export class Hub {
80
81
  const cached = readJsonOr<{ catalog: unknown; at: string } | null>(join(this.harness.stateDir, 'hub', 'catalog.json'), null)
81
82
  const parsed = hubCatalogSchema.safeParse(cached?.catalog)
82
83
  const catalog = parsed.success ? parsed.data : this.fallback
83
- const installed = this.receipts().filter((receipt) => this.present(receipt))
84
+ const installed = this.receipts().map((receipt) => ({ ...receipt, state: this.receiptState(receipt) }))
84
85
  const entries = new Map(catalog.entries.map((entry) => [`${entry.id}@${entry.version}`, entry]))
85
86
  for (const receipt of installed) if (!entries.has(`${receipt.entry.id}@${receipt.entry.version}`)) entries.set(`${receipt.entry.id}@${receipt.entry.version}`, receipt.entry)
86
87
  return { entries: [...entries.values()], installed, origin: parsed.success ? 'cached' : 'bundled', refreshedAt: parsed.success ? cached!.at : null, error: null, dataRoot: this.harness.dataRoot, stateDir: this.harness.stateDir }
87
88
  }
88
89
 
90
+ private receiptState(receipt: HubReceipt): 'installed' | 'changed' | 'incomplete' {
91
+ if (receipt.document && receipt.entry.delivery.kind === 'document') {
92
+ if (receipt.document !== `${receiptName(receipt.entry)}.json`) return 'incomplete'
93
+ try {
94
+ const path = join(this.harness.dataRoot!, 'hub', 'documents', receipt.document)
95
+ const stat = lstatSync(path)
96
+ if (!stat.isFile() || stat.size > HUB_MAX_BYTES) return 'incomplete'
97
+ return digest(readFileSync(path)) === receipt.entry.delivery.sha256 ? 'installed' : 'changed'
98
+ } catch { return 'incomplete' }
99
+ }
100
+ return this.present(receipt) ? 'installed' : 'incomplete'
101
+ }
102
+
89
103
  private present(receipt: HubReceipt): boolean {
90
104
  const root = this.harness.dataRoot
91
105
  if (!root) return false
@@ -169,6 +183,28 @@ export class Hub {
169
183
  return { ...entry, delivery: { ...entry.delivery, bundled: this.decode(bytes) } }
170
184
  }
171
185
 
186
+ private async verifiedPayload(entry: HubEntry): Promise<Buffer> {
187
+ if (entry.delivery.kind === 'pi-package') throw invalid('Packages do not have a JSON payload preview.')
188
+ const key = `${entry.id}@${entry.version}`
189
+ if (this.cachedPayload?.key === key && this.cachedPayload.expiresAt > Date.now()) return this.cachedPayload.bytes
190
+ const bytes = entry.delivery.bundled === undefined ? await this.bytes(entry.delivery.url) : Buffer.from(entry.delivery.bundled)
191
+ if (digest(bytes) !== entry.delivery.sha256) throw invalid('The downloaded item differs from the reviewed version. Nothing was installed.')
192
+ this.decode(bytes)
193
+ this.cachedPayload = { key, bytes, expiresAt: Date.now() + 5 * 60 * 1000 }
194
+ return bytes
195
+ }
196
+
197
+ async preview(id: string, version: string, offset = 0) {
198
+ if (!Number.isSafeInteger(offset) || offset < 0) throw invalid('Preview offset must be a non-negative byte position.')
199
+ const entry = this.find(id, version)
200
+ const bytes = await this.verifiedPayload(entry)
201
+ if (offset > bytes.length || offset < bytes.length && (bytes[offset] & 0xc0) === 0x80) throw invalid('Preview offset is outside the payload or splits a UTF-8 character.')
202
+ let end = Math.min(offset + HUB_PREVIEW_PAGE_BYTES, bytes.length)
203
+ while (end > offset && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1
204
+ const content = this.decode(bytes.subarray(offset, end))
205
+ return { entry, content, offset, nextOffset: end, totalBytes: bytes.length, done: end >= bytes.length }
206
+ }
207
+
172
208
  async install(id: string, version: string): Promise<HubReceipt> {
173
209
  const root = this.harness.env.root
174
210
  const entry = hubEntrySchema.parse(this.find(id, version))
@@ -181,6 +217,7 @@ export class Hub {
181
217
  const records = this.receipts()
182
218
  const existing = records.find((receipt) => receipt.entry.id === id && receipt.entry.version === version)
183
219
  if (existing && this.present(existing)) return existing
220
+ if (existing) throw invalid('This Hub receipt is changed or incomplete. Uninstall it before installing this version again.')
184
221
  const remaining = records.filter((record) => record !== existing)
185
222
  const receipt: HubReceipt = { entry, installedAt: now(), resourceIds: [], document: null }
186
223
  const choicesBefore = attachments.load(root)
@@ -195,13 +232,14 @@ export class Hub {
195
232
  if (!receipt.resourceIds.length) throw invalid('The package did not add an installable plugin. No hub installation was recorded.')
196
233
  } else {
197
234
  const delivery = entry.delivery
198
- const bytes = delivery.bundled === undefined ? await this.bytes(delivery.url) : Buffer.from(delivery.bundled)
199
- if (digest(bytes) !== delivery.sha256) throw invalid('The downloaded item differs from the reviewed version. Nothing was installed.')
235
+ const bytes = await this.verifiedPayload(entry)
200
236
  const text = this.decode(bytes)
201
237
  if (delivery.kind === 'document') {
202
238
  JSON.parse(text)
203
239
  receipt.document = `${receiptName(entry)}.json`
204
- atomicWrite(join(root, 'hub', 'documents', receipt.document), bytes)
240
+ const documentPath = join(root, 'hub', 'documents', receipt.document)
241
+ try { lstatSync(documentPath); throw invalid('A file already exists at the Hub document path. Review or remove it before installing.') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
242
+ atomicWrite(documentPath, bytes)
205
243
  } else {
206
244
  const payload = JSON.parse(text)
207
245
  const previous = new Set(registry.list(root).map((item) => item.id))
@@ -232,6 +270,84 @@ export class Hub {
232
270
  } finally { this.installing = false; releaseData?.(); releaseState() }
233
271
  }
234
272
 
273
+ async uninstall(id: string, version: string): Promise<HubReceipt> {
274
+ const root = this.harness.env.root
275
+ const releaseState = acquire(join(this.harness.stateDir, 'hub', 'install.lock'))
276
+ this.installing = true
277
+ let releaseData: (() => void) | null = null
278
+ try {
279
+ releaseData = acquire(join(root, 'hub', 'install.lock'))
280
+ const records = this.receipts()
281
+ const receipt = records.find((item) => item.entry.id === id && item.entry.version === version)
282
+ if (!receipt) throw invalid('This Hub installation receipt no longer exists.')
283
+ const staged = mkdtempSync(join(root, 'hub', '.uninstall-'))
284
+ const moves: Array<{ source: string; staged: string }> = []
285
+ let choicesBefore: ReturnType<typeof attachments.load> | null = null
286
+ let committed = false
287
+ let rollbackComplete = true
288
+ try {
289
+ const ownedPaths: string[] = []
290
+ if (receipt.document) {
291
+ if (receipt.entry.delivery.kind !== 'document' || receipt.document !== `${receiptName(receipt.entry)}.json`) throw invalid('The Hub receipt has an invalid document path.')
292
+ const path = join(root, 'hub', 'documents', receipt.document)
293
+ try {
294
+ const stat = lstatSync(path)
295
+ if (!stat.isFile()) throw invalid('The receipt-owned document path is not a regular file; it was left untouched.')
296
+ ownedPaths.push(path)
297
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
298
+ }
299
+ if (receipt.entry.delivery.kind === 'registry') {
300
+ const expectedKind = receipt.entry.delivery.registryKind
301
+ for (const id of receipt.resourceIds) {
302
+ if (!/^(skills|prompts|rules)\/[a-z0-9-]{1,64}$/.test(id)) throw invalid('The Hub receipt contains an invalid registry resource path.')
303
+ const [kind, name] = id.split('/')
304
+ if (kind !== expectedKind) throw invalid('The Hub receipt contains a resource outside its registry kind.')
305
+ const path = join(root, 'registry', kind, kind === 'skills' ? name : `${name}.md`)
306
+ try { lstatSync(path); ownedPaths.push(path) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
307
+ }
308
+ choicesBefore = attachments.load(root)
309
+ const choices = structuredClone(choicesBefore)
310
+ const owned = new Set(receipt.resourceIds)
311
+ for (const scope of [choices.global, ...Object.values(choices.agents), ...Object.values(choices.projects)]) {
312
+ scope.on = scope.on.filter((value) => !owned.has(value))
313
+ scope.off = scope.off.filter((value) => !owned.has(value))
314
+ }
315
+ for (let index = 0; index < ownedPaths.length; index += 1) {
316
+ const source = ownedPaths[index]
317
+ const destination = join(staged, String(index))
318
+ renameSync(source, destination)
319
+ moves.push({ source, staged: destination })
320
+ }
321
+ attachments.save(root, choices)
322
+ } else {
323
+ for (let index = 0; index < ownedPaths.length; index += 1) {
324
+ const source = ownedPaths[index]
325
+ const destination = join(staged, String(index))
326
+ renameSync(source, destination)
327
+ moves.push({ source, staged: destination })
328
+ }
329
+ }
330
+ if (receipt.entry.delivery.kind === 'pi-package') for (const pluginId of receipt.resourceIds) await packages.remove(this.harness, { pluginId })
331
+ writeJson(join(root, 'hub', 'installed.json'), records.filter((item) => item !== receipt))
332
+ committed = true
333
+ try { rmSync(staged, { recursive: true, force: true }) } catch { /* The receipt is removed; leave the staged copy for disk recovery. */ }
334
+ this.cachedPayload = this.cachedPayload?.key === `${id}@${version}` ? null : this.cachedPayload
335
+ this.harness.bus.emit({ type: 'hub.changed' })
336
+ this.harness.bus.emit({ type: 'plugins.changed' })
337
+ return receipt
338
+ } catch (error) {
339
+ if (!committed) {
340
+ if (choicesBefore) try { attachments.save(root, choicesBefore) } catch { /* Preserve the original failure and restore the owned files below. */ }
341
+ for (const move of moves.reverse()) {
342
+ try { lstatSync(move.staged); renameSync(move.staged, move.source) } catch { rollbackComplete = false }
343
+ }
344
+ if (rollbackComplete) try { rmSync(staged, { recursive: true, force: true }) } catch { /* Keep the staging directory for recovery. */ }
345
+ }
346
+ throw error
347
+ }
348
+ } finally { this.installing = false; releaseData?.(); releaseState() }
349
+ }
350
+
235
351
  readDocument(id: string, version: string): { entry: HubEntry; content: string } {
236
352
  const receipt = this.receipts().find((item) => item.entry.id === id && item.entry.version === version)
237
353
  if (!receipt?.document || receipt.document !== `${receiptName(receipt.entry)}.json`) throw invalid('Install this document before opening it.')
package/src/hub/schema.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
 
3
3
  export const HUB_CATALOG_URL = 'https://raw.githubusercontent.com/tanntnny/xingular-hub/main/catalog.json'
4
4
  export const HUB_MAX_BYTES = 16 * 1024 * 1024
5
+ export const HUB_PREVIEW_PAGE_BYTES = 16 * 1024
5
6
 
6
7
  const httpsUrl = z.string().max(2000).url().refine((value) => {
7
8
  const url = new URL(value)
@@ -53,7 +54,17 @@ export const hubReceiptSchema = z.object({
53
54
  entry: hubEntrySchema,
54
55
  installedAt: z.string(),
55
56
  resourceIds: z.array(z.string()),
56
- document: z.string().nullable()
57
+ document: z.string().nullable(),
58
+ state: z.enum(['installed', 'changed', 'incomplete']).optional()
59
+ }).strict()
60
+
61
+ export const hubPreviewPageSchema = z.object({
62
+ entry: hubEntrySchema,
63
+ content: z.string(),
64
+ offset: z.number().int().nonnegative(),
65
+ nextOffset: z.number().int().nonnegative(),
66
+ totalBytes: z.number().int().nonnegative(),
67
+ done: z.boolean()
57
68
  }).strict()
58
69
 
59
70
  export const hubOverviewSchema = z.object({
@@ -69,6 +80,7 @@ export const hubOverviewSchema = z.object({
69
80
  export type HubEntry = z.infer<typeof hubEntrySchema>
70
81
  export type HubCatalog = z.infer<typeof hubCatalogSchema>
71
82
  export type HubReceipt = z.infer<typeof hubReceiptSchema>
83
+ export type HubPreviewPage = z.infer<typeof hubPreviewPageSchema>
72
84
  export type HubOverview = z.infer<typeof hubOverviewSchema>
73
85
 
74
86
  export function meetsMinimum(actual: string, minimum: string): boolean {
@@ -83,6 +83,8 @@ export function methods(harness: Harness): Record<string, Handler> {
83
83
  'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
84
84
  'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
85
85
  'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
86
+ 'hub.preview': (params: Params) => harness.hub.preview(text(params, 'id'), text(params, 'version'), typeof params.offset === 'number' ? params.offset : 0),
87
+ 'hub.uninstall': (params: Params) => harness.hub.uninstall(text(params, 'id'), text(params, 'version')),
86
88
  'hub.readDocument': (params: Params) => harness.hub.readDocument(text(params, 'id'), text(params, 'version')),
87
89
 
88
90
  // ---- Accounts --------------------------------------------------------------------------------
@@ -0,0 +1,40 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY } from '../prompt/compile'
7
+ import { PLAN_INSTRUCTIONS, PLAN_SECTION_ID, planModeTools, planRefusal, planSection } from './planMode'
8
+
9
+ function history(sectionIds: string[]): string {
10
+ const file = join(mkdtempSync(join(tmpdir(), 'plan-history-')), 'session.jsonl')
11
+ const snapshot = sectionIds.map((id) => ({ id, title: id, body: 'x', sources: [], budget: 100, size: 1, cut: false }))
12
+ writeFileSync(file, [
13
+ JSON.stringify({ type: 'session', id: 'session' }),
14
+ JSON.stringify({ type: 'custom_message', id: 'c1', parentId: null, customType: CONTEXT_MESSAGE_TYPE, details: { [CONTEXT_SNAPSHOT_KEY]: snapshot } })
15
+ ].join('\n'))
16
+ return file
17
+ }
18
+
19
+ describe('plan mode', () => {
20
+ it('keeps read-only inspection, questions and recall, plus provider tools marked read-only', () => {
21
+ const normal = ['read', 'bash', 'edit', 'write', 'grep', 'ask_user', 'wiki_search', 'memory_append', 'spawn_subagent', 'files_read', 'files_write', 'skill_load']
22
+ expect(planModeTools(normal, ['files_read'])).toEqual(['read', 'grep', 'ask_user', 'wiki_search', 'files_read', 'skill_load'])
23
+ })
24
+
25
+ it('refuses changes, commands, memory writes and delegation with a reason the model can act on', () => {
26
+ const readOnly = new Set(['files_read'])
27
+ for (const tool of ['read', 'grep', 'wiki_read', 'files_read', 'ask_user']) expect(planRefusal(tool, readOnly), tool).toBeNull()
28
+ for (const tool of ['bash', 'write', 'edit', 'memory_append', 'wiki_propose', 'spawn_subagent', 'mcp_call_tool', 'files_write']) {
29
+ expect(planRefusal(tool, readOnly), tool).toContain('Plan mode is read-only')
30
+ }
31
+ })
32
+
33
+ it('adds its instructions while on, withdraws them once after, and is absent otherwise', () => {
34
+ expect(planSection(true, null)).toMatchObject({ id: PLAN_SECTION_ID, body: PLAN_INSTRUCTIONS, dynamic: true })
35
+ expect(PLAN_INSTRUCTIONS).toContain('<proposed_plan>')
36
+ expect(planSection(false, null)).toBeNull()
37
+ expect(planSection(false, history(['blocks']))).toBeNull()
38
+ expect(planSection(false, history(['blocks', PLAN_SECTION_ID]))).toMatchObject({ id: PLAN_SECTION_ID, body: null, dynamic: true })
39
+ })
40
+ })
@@ -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),
@@ -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, type TerminalCommandHost } from './commands'
8
9
  import type { TerminalDecision } from './runtime'
9
10
  import { terminalKeybindings } from './keybindings'
10
11
  import { autocompleteRuleColor, createHarnessTheme } from './theme'
@@ -22,7 +23,9 @@ function setup() {
22
23
  keybindings: ReturnType<typeof terminalKeybindings>
23
24
  editor: CustomEditor
24
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 } }
25
- commands: { execute: ReturnType<typeof vi.fn> }
26
+ commands: ReturnType<typeof createTerminalCommands> | { execute: ReturnType<typeof vi.fn> }
27
+ planMode: boolean
28
+ setPlanMode(enabled: boolean): void
26
29
  masthead: { render(width: number): string[] }
27
30
  header?: { render(width: number): string[]; invalidate(): void }
28
31
  branch: string | null
@@ -234,15 +237,33 @@ describe('the owned terminal application', () => {
234
237
  await application.shutdown()
235
238
  })
236
239
 
237
- it('toggles plan mode on Shift+Tab, narrowing to read-only tools and leaving thinking alone', async () => {
240
+ it('toggles plan mode with /plan, narrowing to read-only tools', async () => {
238
241
  const { application, app } = setup()
239
242
  const session = app.runtime.session as { getActiveToolNames(): string[]; setActiveToolsByName(names: string[]): void; cycleThinkingLevel(): void }
240
243
  const setActiveToolsByName = vi.fn()
241
244
  const cycleThinkingLevel = vi.fn()
242
245
  Object.assign(session, { getActiveToolNames: () => ['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'], setActiveToolsByName, cycleThinkingLevel })
243
- expect(app.dispatchInput('\x1b[Z')).toEqual({ consume: true })
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
+ })
261
+ app.mountEditor()
262
+ app.submit('/plan')
263
+ await vi.waitFor(() => expect(setActiveToolsByName).toHaveBeenCalledWith(['read', 'grep']))
244
264
  expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'grep'])
245
- expect(app.dispatchInput('\x1b[Z')).toEqual({ consume: true })
265
+ app.submit('/plan off')
266
+ await vi.waitFor(() => expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent']))
246
267
  expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'])
247
268
  expect(cycleThinkingLevel).not.toHaveBeenCalled()
248
269
  await application.shutdown()