@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.
- package/CHANGELOG.md +6 -0
- package/README.md +7 -3
- package/dist/cli.js +8639 -8147
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +3 -1
- package/docs/terminal-tui.md +14 -8
- package/package.json +1 -1
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/server/methods.ts +2 -0
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/sessions.test.ts +23 -0
- package/src/sessions/sessions.ts +17 -1
- package/src/terminal/tui/application.test.ts +25 -4
- package/src/terminal/tui/application.ts +13 -5
- package/src/terminal/tui/commands.test.ts +1 -0
- package/src/terminal/tui/commands.ts +10 -0
- package/src/terminal/tui/editor.ts +16 -1
- package/src/terminal/tui/keybindings.test.ts +1 -2
- package/src/terminal/tui/keybindings.ts +1 -2
- package/src/terminal/tui/planMode.ts +2 -12
- package/src/tools/types.ts +2 -0
|
@@ -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
|
|
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.
|
package/docs/terminal-tui.md
CHANGED
|
@@ -87,16 +87,22 @@ The decision and its consequences are recorded in
|
|
|
87
87
|
|
|
88
88
|
## Plan mode and thinking keys
|
|
89
89
|
|
|
90
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
integration
|
|
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.
|
|
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": {
|
package/src/hub/hub.test.ts
CHANGED
|
@@ -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).
|
|
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().
|
|
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 =
|
|
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
|
-
|
|
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 {
|
package/src/server/methods.ts
CHANGED
|
@@ -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)))
|
package/src/sessions/sessions.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|