@hachej/boring-agent 0.1.17 → 0.1.18

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 (39) hide show
  1. package/dist/{sandbox-handle-store-hK76cTjn.d.ts → agentPluginEvents-zyIvVjsA.d.ts} +28 -32
  2. package/dist/{chunk-F3CE5CNW.js → chunk-B5JECXMG.js} +5 -7
  3. package/dist/front/index.d.ts +23 -7
  4. package/dist/front/index.js +764 -421
  5. package/dist/front/styles.css +61 -0
  6. package/dist/{tool-ui-DSmWuqGe.d.ts → harness-DRrTn_5T.d.ts} +43 -24
  7. package/dist/server/index.d.ts +79 -10
  8. package/dist/server/index.js +243 -54
  9. package/dist/shared/index.d.ts +5 -3
  10. package/dist/shared/index.js +3 -1
  11. package/dist/tool-ui-DIFNGwYd.d.ts +20 -0
  12. package/docs/ACCESSIBILITY.md +55 -0
  13. package/docs/API.md +64 -0
  14. package/docs/CSP.md +40 -0
  15. package/docs/ERROR_CODES.md +54 -0
  16. package/docs/KNOWN_LIMITATIONS.md +100 -0
  17. package/docs/MIGRATION.md +50 -0
  18. package/docs/PERFORMANCE.md +68 -0
  19. package/docs/PLUGINS.md +108 -0
  20. package/docs/README.md +17 -0
  21. package/docs/RISKS-MULTI-TAB.md +46 -0
  22. package/docs/STYLING.md +71 -0
  23. package/docs/UI-SHADCN.md +56 -0
  24. package/docs/VERCEL_COSTS.md +103 -0
  25. package/docs/plans/AGENT_EVAL_FRAMEWORK.md +466 -0
  26. package/docs/plans/agent-package-spec.md +1777 -0
  27. package/docs/plans/harness-followup-capabilities.md +440 -0
  28. package/docs/plans/harness-tool-ui-capabilities.md +144 -0
  29. package/docs/plans/pi-followup-history-projection.md +229 -0
  30. package/docs/plans/pi-tools-migration.md +1061 -0
  31. package/docs/plans/reviews/pi-followup-history-codex-review.md +11 -0
  32. package/docs/plans/reviews/pi-followup-history-gpt-review.md +64 -0
  33. package/docs/plans/reviews/pi-followup-history-opus-review.md +43 -0
  34. package/docs/plans/reviews/pi-followup-history-xai-review.md +43 -0
  35. package/docs/plans/vercel-base-snapshot-template-plan.md +527 -0
  36. package/docs/plans/vercel-persistent-sandbox-adapter.md +553 -0
  37. package/docs/runtime.md +56 -0
  38. package/docs/tools.md +75 -0
  39. package/package.json +4 -3
@@ -0,0 +1,1061 @@
1
+ # Plan — Lean on pi's tools; plug our backends per-mode via Operations
2
+
3
+ **Status:** draft v4 — codex r5 catch-up (2026-04-29). NOTE: the live code already moved past this plan's Phase 0/1 milestones — `standardCatalog.ts` no longer exists at `packages/agent/src/server/catalog/`; `createAgentApp.ts:91` already uses `buildHarnessAgentTools(bundle) + buildFilesystemAgentTools(bundle)` directly. Sections below describing migration FROM `standardCatalog` are historical record; READ as "this is how we got here." Phase 1.5 of PLUGIN_MODEL.md (workspace) is the consumer-side counterpart now in flight.
4
+ **Path:** `boring-ui-v2/packages/agent/`
5
+ **Sequencing:** ships first; PLUGIN_MODEL.md (workspace) lands after.
6
+
7
+ ## TL;DR
8
+
9
+ We currently hand-roll six tool implementations under pi-adjacent names
10
+ (`bash`, `read`, `write`, `edit`, `find`, `grep`) and shadow pi's
11
+ defaults via `customTools`. **Pi was designed to support exactly our use
12
+ case** — issue [#564](https://github.com/badlogic/pi-mono/issues/564)
13
+ explicitly added the `XxxOperations` seam "for remote delegation" with an
14
+ SSH example as its acceptance criteria. We should adopt pi's tools and plug
15
+ our backends through Operations, mirroring pi's `examples/extensions/ssh.ts`
16
+ canonical pattern.
17
+
18
+ Two phases:
19
+
20
+ - **Phase 0 — Clean up the existing tool files.** Pure refactor: extract
21
+ shared primitives, drop dead code, fix inconsistencies. ~150 LOC removed.
22
+ Stands alone even if Phase 1 is deferred.
23
+
24
+ - **Phase 1 — Per-mode tool wiring against pi's factories.** Each
25
+ `RuntimeModeAdapter` (`direct`/`local`/`vercel-sandbox`) declares its own
26
+ tool list. Most tools are pi defaults verbatim; modes only override
27
+ Operations where the underlying platform actually differs.
28
+
29
+ After both phases: **~570 LOC of code we own** vs ~1830 today — a **75%
30
+ reduction** on this surface. Plus we gain pi's polish for free (process-tree
31
+ kill on abort, macOS path-fuzzy-match, auto-fd/rg-download, smarter
32
+ truncation, .gitignore-aware globber).
33
+
34
+ ## Design principles (locked)
35
+
36
+ Three principles govern every decision in this plan. When in doubt, default
37
+ to the lower-numbered principle:
38
+
39
+ ### Principle 1 — Leverage pi's model-facing surface verbatim
40
+
41
+ Tool **names**, **schemas**, **prompt snippets**, and **guideline phrasing**
42
+ all come from pi unchanged. The model sees a stock pi agent. This means:
43
+
44
+ - Use pi's tool names (`find`, `grep`, `read`, `write`, `edit`, `bash`,
45
+ `ls`) — never our own variants.
46
+ - Use pi's TypeBox schemas as authored — never re-declare them in our
47
+ code.
48
+ - Use pi's `promptSnippet` wording — never wrap it with our own bullets
49
+ or guideline bullets.
50
+ - Future pi tool releases (e.g. a hypothetical `tree`) flow into our
51
+ catalog with zero shape work on our side.
52
+
53
+ The motivation: **cross-agent conventions**. Models trained on
54
+ pi/Claude Code/Cursor naming and schemas perform better with consistent
55
+ surface area across agents. Diverging is a tax we pay every turn.
56
+
57
+ ### Principle 2 — Replace internal behavior via Operations / spawnHooks
58
+
59
+ When we need different *behavior* (sandboxed exec, remote VM, audit
60
+ logging), we plug it in through pi's `XxxOperations` seam — never by
61
+ shadowing pi's tool with our own definition. This is the entire point of
62
+ pi-mono#564 and the canonical SSH/sandbox extension patterns.
63
+
64
+ Consequence: every line of code we own is in **`Operations` adapters,
65
+ `spawnHook`s, or shared resource bundles** — not in tool definitions
66
+ themselves. Adding a new sandbox provider (Modal, Docker, Apple
67
+ container) is "implement six Operations interfaces" — not "rewrite six
68
+ tools."
69
+
70
+ ### Principle 3 — Add custom AgentTools only when pi cannot be made to work
71
+
72
+ Custom AgentTools (registered via `customTools` through
73
+ `tool-adapter.ts`) exist for **two cases only**:
74
+
75
+ 1. **Pi has no equivalent.** Example: `executeIsolatedCodeTool` — pi
76
+ doesn't ship per-call ephemeral sandboxes.
77
+ 2. **Pi's tool architecture cannot accommodate our backend.** Example:
78
+ `vercelGrepTool` — pi's `GrepOperations` cannot redirect the
79
+ ripgrep spawn (only `isDirectory` + `readFile` for context lines),
80
+ so in vercel-sandbox mode we *must* roll our own that runs `rg`
81
+ inside the VM via `sandbox.exec`. **Even then we mirror pi's grep
82
+ schema 1:1** so the model's grep behavior is identical across modes
83
+ — Principle 1 still applies to the schema.
84
+
85
+ Any new addition to `customTools` must justify itself against these two
86
+ cases in its PR description. If neither applies, it's an Operations
87
+ adapter waiting to happen.
88
+
89
+ ### Anti-patterns these principles forbid
90
+
91
+ - Shadowing pi's tool names with re-implementations under the same name.
92
+ - Re-declaring pi's schemas (even "for clarity"). The schema lives in pi.
93
+ - Adding per-tool guideline bullets (`Prefer the X tool when...`) — pi's
94
+ catalog is sufficient signal; one line per tool is too many.
95
+ - Wrapping a pi tool's `promptSnippet` with our own prefix. Pi's wording
96
+ IS the wording.
97
+ - Adding a custom tool because "we have a slightly different parameter
98
+ shape we want." Reshape via Operations input/output translation
99
+ instead, or just adopt pi's shape (it's almost always richer).
100
+
101
+ ## Why
102
+
103
+ ### Original spec rationale was thin
104
+
105
+ `agent-package-spec.md` decision #4 originally said Boring would supply its
106
+ own `tools: [...]` instead of pi's defaults, with no further justification.
107
+ Decision #6 budgeted ~200 LOC for tool factories and deferred dedicated
108
+ search/list tools to shell usage. We've drifted: 1100+ LOC across 6 tools, 5×
109
+ budget, with search tool names shipped off-spec before the rename to `find`/`grep`.
110
+
111
+ ### Pi has the Operations seam expressly for this
112
+
113
+ Issue [#564 (closed)](https://github.com/badlogic/pi-mono/issues/564) — the
114
+ RFC that introduced `XxxOperations` interfaces — opens with: *"Make built-in
115
+ tool core operations pluggable so extensions can delegate to remote systems
116
+ (e.g., SSH)."* Acceptance criteria included shipping an SSH example. That
117
+ example (`examples/extensions/ssh.ts`) is the canonical reference for our
118
+ vercel-sandbox mode.
119
+
120
+ ### Pi's defaults are strictly better than ours where they apply
121
+
122
+ | Capability | Pi default | Ours |
123
+ |---|---|---|
124
+ | Process-tree kill on bash abort | ✅ via `killProcessTree(pid)` | ❌ child only |
125
+ | Auto-download `fd` / `rg` if missing | ✅ via `ensureTool()` | ❌ |
126
+ | macOS path NFD / curly-quote / AM-PM normalization on read | ✅ via `resolveReadPath` | ❌ |
127
+ | Smart output truncation (head/tail with first-line guard) | ✅ via `truncateHead` | ⚠️ basic |
128
+ | `.gitignore`-aware find with `--no-require-git` | ✅ via fd | ✅ via FileSearch |
129
+
130
+ ### Two pi extensions are de-facto reference templates
131
+
132
+ - `examples/extensions/ssh.ts` — remote backend per-tool override. Lift this
133
+ pattern verbatim for vercel-sandbox.
134
+ - `examples/extensions/sandbox/index.ts` — OS-level sandboxing via
135
+ `SandboxManager.wrapWithSandbox(command)` from
136
+ `@anthropic-ai/sandbox-runtime`. Lift the `spawnHook`/wrapped-bash pattern
137
+ for bwrap mode.
138
+
139
+ ### Community signals from pi-mono issues
140
+
141
+ - **#3782** — *"Default to using custom `grep` tool to prevent context
142
+ pollution and token waste"*: pi made grep a default tool because bare
143
+ `bash grep` over `node_modules` burned ~98k tokens in real sessions.
144
+ Implication: **don't drop dedicated grep — preserve smart-grep behavior
145
+ even in vercel-sandbox**.
146
+ - **#3500** — *"Discover skills on remote machine when routing tools through
147
+ SSH"*: SSH extension authors are actively extending the pattern. Pi is
148
+ iterating to make the remote story stronger.
149
+ - **#3320 / #2965** — remote context-file (AGENTS.md/CLAUDE.md) loading via
150
+ SSH extension. Pi is solving the full agent-runs-against-remote-system
151
+ case, not just bash forwarding.
152
+ - **No open issues** on ssh/remote → the pattern is mature; we're catching
153
+ up to where pi's community already is.
154
+
155
+ ## Phase 0 — Clean up the existing tool files (precursor)
156
+
157
+ Pure refactor. No behavior change. Independent of Phase 1 — even if Phase 1
158
+ were deferred indefinitely, this step stands alone.
159
+
160
+ ### Audit findings (cross-file duplication, inconsistencies, dead code)
161
+
162
+ #### Cross-file duplication
163
+
164
+ 1. `makeError(message)` declared identically in all 6 files.
165
+ 2. `FileChangeMetadata` declared 3 times with one divergent field.
166
+ 3. `bytesWritten()` identical in `editTool.ts:68-70` and `writeTool.ts:81-83`.
167
+ 4. `nowIso()` identical in `editTool.ts:72-74` and `writeTool.ts:85-87`.
168
+ 5. `normalizeLimit()` identical in `findFilesTool.ts:22-36` and
169
+ `grepFilesTool.ts:32-46`.
170
+ 6. Two separate `TextDecoder('utf-8', { fatal: false })` instances.
171
+ 7. `DEFAULT_LIMIT = 200`, `MAX_LIMIT = 5_000`, `MAX_*_LENGTH = 256`
172
+ repeated.
173
+
174
+ #### Inconsistencies
175
+
176
+ 8. `bashTool.ts:209-214` inlines its error object instead of using
177
+ `makeError`.
178
+ 9. `bashTool.ts` is the only file that doesn't check
179
+ `ctx.abortSignal.aborted` at the start.
180
+ 10. `additionalProperties: false` on the JSON schema only on
181
+ `findFilesTool` and `grepFilesTool`.
182
+ 11. Param descriptions only on `grepFilesTool`.
183
+ 12. `grepFilesTool.ts:64` uses `parseInt(..., 10)`; rest use
184
+ `Number.isInteger`.
185
+ 13. Param-typing approach varies (raw `params`, `XxxParams` interface,
186
+ `as Record<string, unknown>`).
187
+ 14. Error phrasing varies (`"X aborted"` vs `"X failed: msg"` vs
188
+ `"unknown X failure"`).
189
+
190
+ #### Dead / questionable code
191
+
192
+ 15. `readTool.ts:97` — stale TODO referencing a long-shipped milestone.
193
+ 16. `bashTool.ts:9-11` — single-use `decode()` helper.
194
+ 17. `editTool.ts:46-66` — two near-identical replace functions; can be one.
195
+ 18. `writeTool.ts:75-79` — `makeTmpPath` sanitizes `toolCallId` even
196
+ though pi guarantees UUIDs. Either real defense-in-depth (document) or
197
+ dead.
198
+ 19. `findFilesTool.ts:54` — description mentions `vercel-sandbox mode`
199
+ even though the tool has no sandbox knowledge.
200
+ 20. `bashTool.ts:23-194` — 170 lines of shell-parsing for
201
+ `inferBashFileChanges`. Wrong file. **Defer to a separate PR** —
202
+ independent of this plan.
203
+ 21. `bashTool.ts:234-244` — emits the same fields twice (text + details);
204
+ document why.
205
+
206
+ ### What `_shared.ts` exports
207
+
208
+ ```ts
209
+ // src/server/catalog/tools/_shared.ts
210
+ export function makeError(message: string): ToolResult
211
+ export function bytesWritten(content: string): number
212
+ export function nowIso(): string
213
+ export function normalizeLimit(
214
+ raw: unknown,
215
+ opts: { default: number; max: number },
216
+ ): { limit: number; error?: string }
217
+
218
+ export const decoder: TextDecoder
219
+ export function decode(bytes: Uint8Array): string
220
+
221
+ export type FileChangeOp = 'write' | 'edit' | 'unlink' | 'rename' | 'mkdir'
222
+ export interface FileChangeMetadata {
223
+ op: FileChangeOp
224
+ path: string
225
+ oldPath?: string
226
+ timestamp: string
227
+ size?: number
228
+ /** Distinguishes file:created from file:changed. Universal field
229
+ * (set even by edit ops, where it's always true). */
230
+ existsBefore?: boolean
231
+ }
232
+
233
+ export const DEFAULT_TOOL_LIMIT = 200
234
+ export const MAX_TOOL_LIMIT = 5_000
235
+ export const MAX_PATTERN_LENGTH = 256
236
+ ```
237
+
238
+ ### Phase 0 standardization decisions
239
+
240
+ 1. **`existsBefore` on `FileChangeMetadata` becomes universal.** `editTool`
241
+ starts emitting `existsBefore: true` so the workspace bridge sees a
242
+ consistent shape.
243
+ 2. **`additionalProperties: false` everywhere.** Tightens the model-facing
244
+ tool contract.
245
+ 3. **`toolCallId` sanitization in `writeTool`** stays as defense-in-depth
246
+ with a one-line comment.
247
+
248
+ ### Phase 0 verification
249
+
250
+ - `pnpm exec tsc --noEmit` clean.
251
+ - All 6 tool tests + new `_shared.test.ts` green.
252
+ - `bash scripts/check-invariants.sh packages/agent` passes.
253
+ - No tool file imports `makeError`/`bytesWritten`/`nowIso`/`normalizeLimit`
254
+ from anywhere except `_shared.ts`.
255
+
256
+ ### Phase 0 estimated scope
257
+
258
+ - New: `_shared.ts` (~80 LOC) + `_shared.test.ts` (~50 LOC).
259
+ - Touched: all 6 tool files (~30-40% smaller each).
260
+ - Net: ~ **-20 LOC** (mostly redistribution).
261
+ - Risk: very low.
262
+
263
+ ## Phase 1 — Per-mode tool wiring against pi's factories
264
+
265
+ The cleanest design is **bundle factories that produce mode-appropriate
266
+ tools**, called only from `createAgentApp`'s `standardCatalog` path.
267
+ Per PLUGIN_MODEL.md v7.0+: filesystemPlugin is UI-only (panels +
268
+ catalog) and does NOT carry `agentTools` — file tools are HARNESS
269
+ substrate. Each bundle factory takes a `RuntimeBundle` and returns
270
+ `AgentTool[]` constructed via pi's factories with mode-specific
271
+ Operations injected.
272
+
273
+ `RuntimeModeAdapter` does NOT carry a `tools` field. Modes own *resources*
274
+ (workspace, sandbox, fileSearch); they don't own tool registration. Tool
275
+ registration is split across two bundles, both per-mode-aware:
276
+
277
+ - `buildHarnessAgentTools(bundle)` — produces `[bash,
278
+ executeIsolatedCode]`. Always registered by `createAgentApp` directly
279
+ (per PLUGIN_MODEL.md line 122: bash + isolated-code stay harness-level).
280
+ - `buildFilesystemAgentTools(bundle)` — produces `[read, write, edit,
281
+ find, grep, ls]`. Registered by `createAgentApp`'s
282
+ `standardCatalog` by default; opt out via `disableDefaultFileTools:
283
+ true`. **NOT** registered through any plugin (PLUGIN_MODEL v7.0+:
284
+ filesystemPlugin is UI-only, no `agentTools` field).
285
+
286
+ This honors PLUGIN_MODEL.md v7.0's separation of concerns: file tools
287
+ are HARNESS substrate (controlled by `disableDefaultFileTools`); the
288
+ filesystem plugin contributes UI only (controlled by `excludeDefaults`).
289
+ Two switches at two layers.
290
+
291
+ ### Alignment with PLUGIN_MODEL.md
292
+
293
+ PLUGIN_MODEL.md (workspace) defines the plugin pipeline that consumes our
294
+ file-ops tools. Without any change to its contract, this plan slots in
295
+ cleanly via two adjustments:
296
+
297
+ 1. **`filesystemPlugin` is UI-only** (PLUGIN_MODEL v7.0+). It does
298
+ NOT carry `agentTools`; it's a plain module-scope const with
299
+ `panels` (FileTree, CodeEditor, MarkdownEditor) + `catalogs`
300
+ (Files, hitting `/api/v1/files/search`). File tools live with
301
+ the harness:
302
+ ```ts
303
+ // packages/workspace/src/plugin/defaults/filesystemPlugin.ts (v7.0+)
304
+ export const filesystemPlugin: Plugin = definePlugin({
305
+ id: 'filesystem',
306
+ label: 'Filesystem',
307
+ panels: [/* FileTree, CodeEditor, MarkdownEditor */],
308
+ catalogs: [filesCatalog],
309
+ // No agentTools — file tools are harness substrate.
310
+ })
311
+ ```
312
+ `createAgentApp` registers `buildFilesystemAgentTools(bundle)` +
313
+ `buildHarnessAgentTools(bundle)` directly via `standardCatalog`.
314
+ `createWorkspaceAgentApp` plain-wraps `createAgentApp` and runs
315
+ the plugin model bootstrap on top — no dual-registration dance,
316
+ no `disableDefaultFileTools: true` indirection.
317
+ 2. **Tool name update.** Search tool references in
318
+ PLUGIN_MODEL.md (lines 1145, 1550-1554) become `find`/`grep`. Doc-only
319
+ change; can co-land or follow up.
320
+
321
+ ### Five PLUGIN_MODEL guarantees we preserve
322
+
323
+ 1. Plugin contract `Plugin.agentTools: AgentTool[]` unchanged — array is
324
+ constructed at plugin instantiation.
325
+ 2. Late-wins-on-id works — operates on the array; doesn't care how
326
+ entries were constructed.
327
+ 3. `excludeDefaults: ['filesystem']` (workspace switch) removes the
328
+ filesystem UI (Files left-tab + code/markdown editor auto-routing).
329
+ It does NOT remove LLM file tools — those are harness substrate.
330
+ Use `disableDefaultFileTools: true` (harness switch) on
331
+ `createAgentApp` to remove file tools entirely. Two switches at
332
+ two layers, per PLUGIN_MODEL v7.0's separation of concerns.
333
+ 4. Standalone path stays default-on with `disableDefaultFileTools` opt-out
334
+ — `createAgentApp` calls `buildFilesystemAgentTools` directly via
335
+ `standardCatalog`. Plugin pipeline never sees file tools.
336
+ 5. Bash + isolated-code stay harness-level — they flow through
337
+ `buildHarnessAgentTools` which `createAgentApp` always registers
338
+ directly, never via plugin.
339
+
340
+ ### How pi resolves paths (architectural prerequisite)
341
+
342
+ Pi expects model-supplied paths and resolves them against `cwd` via
343
+ `resolveToCwd(filePath, cwd)` in `dist/core/tools/path-utils.js`. **Pi does
344
+ no bounding** — `../../../etc/passwd` resolves cleanly to its absolute path
345
+ and is passed to `Operations.readFile(absPath)`. Path-bounding is 100% the
346
+ Operations adapter's responsibility.
347
+
348
+ This matches `agent-package-spec.md` decision #7a (Workspace enforces
349
+ path-bounding via `validatePath` / `assertRealPathWithinWorkspace`). We move
350
+ the bounding from inside our hand-rolled tool to inside the Operations
351
+ adapter — same security posture, different layer.
352
+
353
+ ### `RuntimeModeAdapter` after this plan
354
+
355
+ Modes own only resources, not tools:
356
+
357
+ ```ts
358
+ // src/server/runtime/mode.ts (sketch)
359
+ export interface RuntimeBundle {
360
+ mode: 'direct' | 'local' | 'vercel-sandbox'
361
+ workspace: Workspace
362
+ sandbox: Sandbox
363
+ fileSearch: FileSearch
364
+ // No `tools` field — bundles produce tools, not modes.
365
+ }
366
+ ```
367
+
368
+ ### `buildHarnessAgentTools(bundle)` — bash + isolated-code
369
+
370
+ Always registered by `createAgentApp` directly. Never goes through the
371
+ plugin pipeline (per PLUGIN_MODEL.md non-goal line 122).
372
+
373
+ ```ts
374
+ // src/server/tools/harness/index.ts (sketch)
375
+ import { createBashTool, createLocalBashOperations } from '@mariozechner/pi-coding-agent'
376
+ import { executeIsolatedCodeTool } from '../executeIsolatedCodeTool'
377
+
378
+ export function buildHarnessAgentTools(bundle: RuntimeBundle): AgentTool[] {
379
+ const tools: AgentTool[] = []
380
+ tools.push(createBashTool(bundleCwd(bundle), bashOptionsForMode(bundle)))
381
+ if (bundle.sandbox.capabilities.includes('isolated-code')) {
382
+ tools.push(executeIsolatedCodeTool(bundle.sandbox))
383
+ }
384
+ return tools
385
+ }
386
+
387
+ function bashOptionsForMode(bundle: RuntimeBundle): BashToolOptions {
388
+ switch (bundle.mode) {
389
+ case 'direct':
390
+ return { operations: createLocalBashOperations() } // pi default verbatim
391
+ case 'local':
392
+ return {
393
+ operations: createLocalBashOperations(),
394
+ spawnHook: bwrapSpawnHook(bundle.workspace.root), // bwrap wrap
395
+ }
396
+ case 'vercel-sandbox':
397
+ return { operations: vercelBashOps(bundle.sandbox) }
398
+ }
399
+ }
400
+ ```
401
+
402
+ ### `buildFilesystemAgentTools(bundle)` — read/write/edit/find/grep/ls
403
+
404
+ Registered by `createAgentApp`'s `standardCatalog` by default; opt out
405
+ via `disableDefaultFileTools: true`. NOT registered via any plugin
406
+ (PLUGIN_MODEL v7.0+: filesystemPlugin is UI-only).
407
+
408
+ ```ts
409
+ // src/server/tools/filesystem/index.ts (sketch)
410
+ import {
411
+ createReadTool, createWriteTool, createEditTool,
412
+ createFindTool, createGrepTool, createLsTool,
413
+ } from '@mariozechner/pi-coding-agent'
414
+ import { boundFs } from '../operations/bound'
415
+ import { vercelReadOps, vercelWriteOps, /* … */ } from '../operations/vercel'
416
+ import { vercelGrepTool } from '../vercelGrepTool'
417
+
418
+ export function buildFilesystemAgentTools(bundle: RuntimeBundle): AgentTool[] {
419
+ const cwd = bundleCwd(bundle)
420
+ switch (bundle.mode) {
421
+ case 'direct':
422
+ case 'local':
423
+ // Workspace IS the host fs; pi's defaults are correct. Path-bound
424
+ // read/write/edit/ls; pi's fd/rg are .gitignore-aware on host fs.
425
+ return [
426
+ createReadTool(cwd, { operations: boundFs(cwd).read }),
427
+ createWriteTool(cwd, { operations: boundFs(cwd).write }),
428
+ createEditTool(cwd, { operations: boundFs(cwd).edit }),
429
+ createFindTool(cwd),
430
+ createGrepTool(cwd),
431
+ createLsTool(cwd, { operations: boundFs(cwd).ls }),
432
+ ]
433
+ case 'vercel-sandbox':
434
+ // Workspace is in the VM; route Operations to sandbox.*. Grep is a
435
+ // custom AgentTool because pi's GrepOperations can't redirect the
436
+ // actual rg spawn (see "Why grep is a custom AgentTool" below).
437
+ return [
438
+ createReadTool(cwd, { operations: vercelReadOps(bundle.sandbox) }),
439
+ createWriteTool(cwd, { operations: vercelWriteOps(bundle.sandbox) }),
440
+ createEditTool(cwd, { operations: vercelEditOps(bundle.sandbox) }),
441
+ createFindTool(cwd, { operations: vercelFindOps(bundle.sandbox) }),
442
+ vercelGrepTool(bundle.sandbox), // custom AgentTool, see below
443
+ createLsTool(cwd, { operations: vercelLsOps(bundle.sandbox) }),
444
+ ]
445
+ }
446
+ }
447
+ ```
448
+
449
+ ### Mode-by-mode summary of what's overridden
450
+
451
+ | Tool | direct | local (bwrap) | vercel-sandbox |
452
+ |---|---|---|---|
453
+ | `bash` | pi default | pi default + `spawnHook` (bwrap wrap) | custom Operations → `sandbox.exec` |
454
+ | `read` | pi default + path-bounding wrapper | same as direct | custom Operations → `sandbox.fs.read` |
455
+ | `write` | pi default + path-bounding wrapper | same as direct | custom Operations → `sandbox.writeFiles` |
456
+ | `edit` | pi default + path-bounding wrapper | same as direct | custom Operations → `sandbox.fs.read+write` |
457
+ | `find` | pi default (auto-downloads `fd`) | same as direct | custom Operations runs `fd` in VM via `sandbox.exec` |
458
+ | `grep` | pi default (auto-downloads `rg`) | same as direct | **custom AgentTool** (pi's GrepOperations can't redirect search) |
459
+ | `ls` | pi default + path-bounding wrapper | same as direct | custom Operations → `sandbox.fs.readdir` |
460
+
461
+ `bwrapSpawnHook(root)` and `boundFs(root)` (with `.read`/`.write`/`.edit`/`.ls`
462
+ sub-objects) are tiny helpers in `src/server/tools/operations/bound.ts`
463
+ (~30 LOC). Vercel Operations adapters live in
464
+ `src/server/tools/operations/vercel.ts` (~150 LOC).
465
+
466
+ ### `vercelGrepTool` design
467
+
468
+ Mirrors pi's grep schema 1:1 so the model's grep behavior is identical
469
+ across modes. Spawns `rg` inside the VM via `sandbox.exec` and parses
470
+ ripgrep's output the same way pi does.
471
+
472
+ ```ts
473
+ // src/server/tools/vercelGrepTool.ts (sketch)
474
+ import type { AgentTool } from '../../shared/tool'
475
+ import type { Sandbox } from '../../shared/sandbox'
476
+ import { decode, makeError, normalizeLimit, MAX_PATTERN_LENGTH } from './_shared'
477
+
478
+ const DEFAULT_LIMIT = 200
479
+
480
+ export function vercelGrepTool(sandbox: Sandbox): AgentTool {
481
+ return {
482
+ name: 'grep', // match pi's name
483
+ description:
484
+ 'Search file contents by pattern across the workspace. Returns ' +
485
+ 'file paths, line numbers, and matching text. Prefer this over ' +
486
+ 'shell grep loops to keep results bounded and .gitignore-respecting.',
487
+ parameters: {
488
+ type: 'object',
489
+ properties: {
490
+ pattern: { type: 'string', description: 'Regex pattern (or literal if literal=true).' },
491
+ path: { type: 'string', description: 'Sub-tree to search (default: workspace root).' },
492
+ glob: { type: 'string', description: 'File glob filter (e.g. "*.ts").' },
493
+ ignoreCase: { type: 'boolean' },
494
+ literal: { type: 'boolean', description: 'Treat pattern as literal string, not regex.' },
495
+ context: { type: 'integer', minimum: 0 },
496
+ limit: { type: 'integer', minimum: 1, maximum: 5000 },
497
+ },
498
+ required: ['pattern'],
499
+ additionalProperties: false,
500
+ },
501
+ async execute(input, ctx) {
502
+ // Validate + build rg args mirroring pi's `dist/core/tools/grep.js:130-180`
503
+ // (smart-case, --no-heading, -m limit, -g glob, -C context, --fixed-strings if literal).
504
+ // Run inside the VM:
505
+ // sandbox.exec(`rg ${args.join(' ')}`, { signal, timeoutMs })
506
+ // Parse stdout the same way pi does (file:line:text).
507
+ // Return matches via the same `details: { matches, truncated, ... }` shape
508
+ // pi's createGrepTool does so the renderer match its UI affordance.
509
+ ...
510
+ },
511
+ }
512
+ }
513
+ ```
514
+
515
+ Why a custom AgentTool, not a pi factory:
516
+
517
+ Pi's `GrepOperations` interface (`dist/core/tools/grep.d.ts:21-29`) only
518
+ exposes:
519
+
520
+ ```ts
521
+ interface GrepOperations {
522
+ isDirectory: (absolutePath: string) => Promise<boolean> | boolean
523
+ readFile: (absolutePath: string) => Promise<string> | string
524
+ }
525
+ ```
526
+
527
+ The actual ripgrep spawn is hardcoded local
528
+ (`grep.js:144: spawn(rgPath, args, { stdio: ... })`) — the Operations only
529
+ plug in the directory-existence check and the readFile-for-context-lines.
530
+ **There's no seam to redirect the search itself to a remote backend.**
531
+
532
+ Notably, pi's own `examples/extensions/ssh.ts` does **not** override
533
+ `grep` — which means in SSH mode that example produces wrong results
534
+ (searches the agent host, not the remote). The example is incomplete on
535
+ this point. Issue [#3782 (closed)](https://github.com/badlogic/pi-mono/issues/3782)
536
+ made dedicated `grep` a default because bare `bash grep` over `node_modules`
537
+ burned ~98k tokens per session. **We can't drop grep**.
538
+
539
+ So in vercel-sandbox we register our own `grep` AgentTool via `customTools`
540
+ that spawns `rg` inside the VM via `sandbox.exec` and parses output.
541
+ ~80 LOC. Pi's `customTools` adapter handles the prompt-snippet and
542
+ schema; the tool's `execute` does the work. **One custom tool**, not six.
543
+
544
+ #### What `vercelXxxOps` looks like
545
+
546
+ Mirrors `examples/extensions/ssh.ts:81-112`. For bash:
547
+
548
+ ```ts
549
+ // src/server/catalog/tools/operations/vercel.ts
550
+ export function vercelBashOps(sandbox: Sandbox): BashOperations {
551
+ return {
552
+ exec(command, cwd, { onData, signal, timeout }) {
553
+ return new Promise((resolve, reject) => {
554
+ const child = sandbox.spawn(['bash', '-c', command], { cwd, env: ... })
555
+ child.stdout.on('data', onData)
556
+ child.stderr.on('data', onData)
557
+ signal?.addEventListener('abort', () => child.kill(), { once: true })
558
+ const timer = timeout
559
+ ? setTimeout(() => child.kill(), timeout * 1000)
560
+ : undefined
561
+ child.on('exit', (code) => {
562
+ if (timer) clearTimeout(timer)
563
+ if (signal?.aborted) reject(new Error('aborted'))
564
+ else resolve({ exitCode: code })
565
+ })
566
+ })
567
+ },
568
+ }
569
+ }
570
+ ```
571
+
572
+ Read/write/edit/ls are similar: forward `(absPath, ...)` to
573
+ `sandbox.fs.read` / `sandbox.writeFiles` / etc. Find's `glob` Operations
574
+ runs `fd` inside the VM via `sandbox.exec` and parses stdout.
575
+
576
+ ### What we delete
577
+
578
+ - `src/server/catalog/tools/bashTool.ts`
579
+ - `src/server/catalog/tools/readTool.ts`
580
+ - `src/server/catalog/tools/writeTool.ts`
581
+ - `src/server/catalog/tools/editTool.ts`
582
+ - `src/server/catalog/tools/findFilesTool.ts`
583
+ - `src/server/catalog/tools/grepFilesTool.ts`
584
+ - All co-located test files for the above.
585
+ - Per-tool `promptGuidelines` and the double-dash `promptSnippet` in
586
+ `tool-adapter.ts` (no longer needed for standard tools — pi's factories
587
+ carry their own snippets).
588
+
589
+ `tool-adapter.ts` stays for genuinely-custom tools: `vercelGrepTool`,
590
+ `executeIsolatedCodeTool`, plugin tools, host-supplied `extraTools`.
591
+
592
+ ### Tool name change: search tools → `find`/`grep`
593
+
594
+ Pi's tool names are `find` and `grep`. Drop the `_files` suffix to align.
595
+ Callsites to update:
596
+
597
+ - `packages/agent/src/ui-shadcn/workspaceToolRenderers.tsx:30` (renderer
598
+ map keyed by name).
599
+ - `packages/workspace/src/components/chat/workspaceToolRenderers.tsx` if
600
+ distinct.
601
+ - Any test mocks asserting the old names.
602
+ - PLUGIN_MODEL.md (lines 1145, 1550-1554) — doc-only update; can be
603
+ co-landed or follow-up.
604
+ - README / AGENTS.md if mentioned.
605
+
606
+ The model is unaffected — pi's name shows up in the tool catalog each turn;
607
+ no persisted state references the old names.
608
+
609
+ ### Migration plan (incremental, mode-by-mode reversible)
610
+
611
+ Sequencing: build the seam first (step 1), then flip one mode at a time
612
+ (steps 2-4) so each mode can be reverted independently if it regresses.
613
+ Hand-rolled tools survive until step 5; they're the rollback target if
614
+ any single mode flip fails.
615
+
616
+ Each step is a single PR-sized chunk. After every step: `tsc --noEmit`
617
+ clean, test suite green, manual smoke against the playground in the
618
+ affected mode(s).
619
+
620
+ #### Step 1.0 — Add streaming callbacks to `Sandbox.exec` (precursor)
621
+
622
+ **New precursor.** Today `ExecOptions` has no `onStdout`/`onStderr`
623
+ callbacks; `Sandbox.exec` returns full output at end. Pi's
624
+ `BashOperations.exec` requires incremental `onData(buf)` calls. This step
625
+ extends our shared interface and all three adapters before any tool
626
+ migration begins.
627
+
628
+ - Extend `ExecOptions` in `packages/agent/src/shared/sandbox.ts` with
629
+ `onStdout?: (chunk: Uint8Array) => void` and
630
+ `onStderr?: (chunk: Uint8Array) => void`. Keep buffered `stdout`/`stderr`
631
+ in `ExecResult` for backward compat — adapter logic both streams AND
632
+ collects (the buffered fields stay populated for non-pi consumers).
633
+ - `DirectSandbox`: switch from `child_process.exec` (buffered) to
634
+ `spawn`; wire `child.stdout.on('data', ...)` to invoke `onStdout` and
635
+ also accumulate into the result buffer.
636
+ - `BwrapSandbox`: same pattern, wrap the bwrap-spawn child.
637
+ - `VercelSandboxExec`: replace `command.stdout() + command.stderr()` with
638
+ either `Command.logs()` async-generator or `runCommand({ stdout:
639
+ writable, stderr: writable })` pipe-to-Writable (SDK supports both;
640
+ `Writable` is simpler). Apply the existing `maxOutputBytes` cap as
641
+ bytes arrive; the buffer never holds more than the cap.
642
+ - Tests: each adapter receives ≥ 2 `onStdout` calls for `seq 1 100`,
643
+ AND the final `ExecResult.stdout` is the full collected output, AND
644
+ `maxOutputBytes` truncation fires correctly under streaming.
645
+
646
+ **Acceptance:** typecheck clean, all sandbox adapter tests green, no
647
+ behavior change for callers that don't pass `onStdout`/`onStderr`.
648
+
649
+ #### Step 1 — Build Operations adapters + bundle factories (pure additive)
650
+
651
+ - New file `src/server/tools/operations/bound.ts` exporting
652
+ `boundFs(root)` returning `{ read, write, edit, ls }` Operations
653
+ sub-objects. Each does abs-path-bounding via
654
+ `assertRealPathWithinWorkspace` (port the helper from
655
+ `packages/agent/src/server/workspace/paths.ts`) before delegating to
656
+ pi's default backend. Symlink resolution included — see R8.
657
+ - New file `src/server/tools/operations/vercel.ts` exporting
658
+ `vercelBashOps`, `vercelReadOps`, `vercelWriteOps`, `vercelEditOps`,
659
+ `vercelFindOps`, `vercelLsOps` — each routes to `sandbox.*` methods.
660
+ - New files `src/server/tools/harness/index.ts` and
661
+ `src/server/tools/filesystem/index.ts` exporting
662
+ `buildHarnessAgentTools(bundle)` and `buildFilesystemAgentTools(bundle)`.
663
+ - New file `src/server/tools/vercelGrepTool.ts` (matches pi's grep
664
+ schema 1:1; see "vercelGrepTool design" above).
665
+ - Tests in `__tests__/`:
666
+ - `boundFs` rejects paths outside workspace, including via symlink.
667
+ - Each `vercel*Ops` adapter mocks `sandbox` and asserts correct
668
+ forwarding.
669
+ - `buildHarnessAgentTools(bundle)` returns the right tools per mode.
670
+ - `buildFilesystemAgentTools(bundle)` returns the right tools per mode
671
+ (incl. `vercelGrepTool` instead of `createGrepTool` for vercel-sandbox).
672
+ - `vercelGrepTool` parses ripgrep output correctly against a fake
673
+ `sandbox.exec`.
674
+ - Nothing else changes. `standardCatalog.ts` still produces the hand-rolled
675
+ tools that the harness uses.
676
+
677
+ #### Step 2 — Flip `direct` mode
678
+
679
+ - In `createAgentApp.ts`, when `mode === 'direct'`, swap from
680
+ `standardCatalog(...)` to `[...buildHarnessAgentTools(bundle),
681
+ ...(disableDefaultFileTools ? [] : buildFilesystemAgentTools(bundle))]`.
682
+ - Other modes still use `standardCatalog` — they're unaffected.
683
+ - Smoke: playground (which auto-detects direct mode by default) — bash,
684
+ read, write, edit, find, grep all work; system prompt loses ~700 chars
685
+ of double-dash + per-tool-guideline noise.
686
+ - **Rollback:** revert this PR; hand-rolled tools come back into use for
687
+ direct mode.
688
+
689
+ #### Step 3 — Flip `local` mode (bwrap)
690
+
691
+ - Same swap as step 2 but for `mode === 'local'`. Adds the `bwrapSpawnHook`
692
+ to `bashOptionsForMode` for local.
693
+ - Smoke: spin up local mode (bwrap installed); verify bwrap appears in the
694
+ process tree during a `bash` turn; verify file ops are still
695
+ workspace-bounded.
696
+ - **Rollback:** independent revert.
697
+
698
+ #### Step 4 — Flip `vercel-sandbox` mode
699
+
700
+ - Same swap for `mode === 'vercel-sandbox'`. Adds vercel Operations to
701
+ `bashOptionsForMode` and `buildFilesystemAgentTools`'s vercel branch.
702
+ - Smoke: deploy a vercel-sandbox playground; send a turn that exercises
703
+ bash, read, write, edit, find, grep — all should hit `sandbox.*` (verify
704
+ via sandbox-side log).
705
+ - Update `packages/agent/src/ui-shadcn/workspaceToolRenderers.tsx:30` to
706
+ add a renderer for the (custom) `grep` tool keyed by name. Pi's
707
+ schema-driven render works for the other tools as-is.
708
+ - **Rollback:** independent revert.
709
+
710
+ #### Step 5 — Delete hand-rolled tools + standardCatalog
711
+
712
+ After all three modes have been flipped and burned in for a few days:
713
+
714
+ - Delete `src/server/catalog/tools/{bashTool,readTool,writeTool,editTool,findFilesTool,grepFilesTool}.ts`
715
+ and their co-located tests.
716
+ - Delete `src/server/catalog/standardCatalog.ts`.
717
+ - `tool-adapter.ts` no longer needs the double-dash `promptSnippet` or
718
+ per-tool `promptGuidelines` workarounds; final shape is ~30 LOC mapping
719
+ our `AgentTool` to pi's `ToolDefinition` for genuinely-custom tools
720
+ only (`vercelGrepTool`, `executeIsolatedCodeTool`, plugin tools,
721
+ host-supplied `extraTools`).
722
+
723
+ #### Step 6 — Tool-name renames downstream
724
+
725
+ - Rename legacy search-tool references to `find` and `grep` in:
726
+ - `packages/agent/src/ui-shadcn/workspaceToolRenderers.tsx:30` (renderer
727
+ map keyed by name).
728
+ - `packages/workspace/src/components/chat/workspaceToolRenderers.tsx`
729
+ if distinct.
730
+ - Any test mocks asserting old names.
731
+ - PLUGIN_MODEL.md (lines 1145, 1550-1554) — doc-only update.
732
+ - boring-macro-v2 if it asserts tool names anywhere.
733
+ - README / AGENTS.md if tool names mentioned.
734
+
735
+ Could fold into step 4 if testing burden allows; separate PR if the rename
736
+ turns out to touch many files.
737
+
738
+ #### Step 7 — Optional: `ls` + final cleanup
739
+
740
+ - Spec deferred ls; pi's `createLsTool` is a 5-LOC add per mode in
741
+ `buildFilesystemAgentTools`. Adds an explicit "list directory" affordance
742
+ and reduces `bash ls` usage. Independent decision.
743
+ - Update `agent-package-spec.md` decisions #4 and #6 with post-migration
744
+ reality (or mark superseded by this plan).
745
+
746
+ ## Risks and open questions
747
+
748
+ ### R1 — Pi schema drift from ours (concrete audit)
749
+
750
+ | Tool | Ours (today) | Pi | Compatibility |
751
+ |---|---|---|---|
752
+ | `read` | `{ path, lineOffset?, lineCount? }` | `{ path, offset?, limit? }` | **Breaking rename.** Field semantics identical (1-indexed line slice). Document in PR. |
753
+ | `write` | `{ path, content, createDirs? }` | `{ path, content }` (mkdir-recursive always) | **Breaking simplification.** Pi always creates parent dirs; we have a flag. Drop the flag in favor of pi's behavior — matches user expectation. |
754
+ | `edit` | `{ path, oldString, newString, replaceAll? }` | `{ path, edits: [{ oldText, newText }] }` | **Breaking restructure.** Pi's shape is strictly more powerful (multi-edit per call, atomic). Drop ours, gain batch-edit for free. Update tool renderers for the new render shape. |
755
+ | `bash` | `{ command }` | `{ command, timeout? }` | **Pure superset.** Add timeout support. |
756
+ | `find` | `{ glob, limit? }` before migration | `{ pattern, path?, limit? }` | **Breaking field rename + new field.** `glob -> pattern`, plus optional `path` arg for sub-tree search. Better. |
757
+ | `grep` | `{ pattern, glob?, limit? }` before migration | `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }` | **Pure superset.** Pi gains literal/case/context flags. **`vercelGrepTool` MUST mirror pi's full schema** so model behavior is consistent across modes. |
758
+ | `ls` (new) | (none) | `{ path?, limit? }` | New affordance, no migration. |
759
+
760
+ All schema breaks are model-facing — the AI SDK sends whatever JSON the
761
+ model emits; pi validates against its TypeBox schema and rejects malformed
762
+ calls. Models trained on pi's schemas (which is most of them — pi's names
763
+ are the cross-agent default) will do BETTER, not worse, after the swap.
764
+
765
+ **Mitigation:** capture the full schema diff per tool in the corresponding
766
+ step's PR description; tool renderers update co-landed (they're keyed by
767
+ tool name + render the input schema). One round of e2e tests per mode
768
+ confirms the model emits correct shapes against the new schemas.
769
+
770
+ ### R2 — Pi's tool names are `find`/`grep`
771
+
772
+ Already addressed above. Smoothest place to do the rename is Step 4 (the
773
+ delete-hand-rolled-files step) where every other tool-name reference is
774
+ already being touched.
775
+
776
+ ### R3 — `vercelGrepTool` doesn't share pi's grep render
777
+
778
+ Pi's `createGrepTool` ships a render component (`grep.d.ts:42` —
779
+ `ToolDefinition<...>` includes render). Our custom AgentTool gets the
780
+ generic fallback renderer. We can either:
781
+
782
+ - Accept the visual asymmetry between modes (vercel-sandbox grep looks
783
+ different from direct/local).
784
+ - File a feature request (`#3782`-style) to add a `search()` method to
785
+ `GrepOperations` so we could use pi's grep tool with a remote backend.
786
+
787
+ **Mitigation:** ship as-is, file the upstream FR; revisit if the asymmetry
788
+ proves annoying. A custom tool renderer in
789
+ `workspaceToolRenderers.tsx` keyed on `grep` could close the gap.
790
+
791
+ ### R4 — `tools: []` was load-bearing
792
+
793
+ Today's `tools: []` deliberately disables pi's `codingTools` to avoid name
794
+ collision with our `customTools`. After migration, we want
795
+ `tools: runtimeBundle.tools` (a list of pi-built factories). The empty-array
796
+ trick must be removed or we'll register zero tools.
797
+
798
+ **Mitigation:** mechanical change in `createHarness.ts`; PR review confirms
799
+ `tools` is not the empty array on completion.
800
+
801
+ ### R5 — Output truncation regressions + bash streaming
802
+
803
+ Pi's `BashOperations.exec` requires an `onData(buf)` callback that is
804
+ called incrementally as bytes arrive. Our current `Sandbox.exec` is
805
+ buffered — it returns `Promise<ExecResult>` with full `stdout`/`stderr`
806
+ collected at the end (`packages/agent/src/shared/sandbox.ts:5-36`).
807
+ Calling `onData(allBytes)` once at end works for correctness but blocks
808
+ pi's mid-command truncation logic, risking memory blowup on
809
+ unbounded-output commands (`find / -type f` style) — especially in
810
+ `vercel-sandbox` mode where the buffer accumulates in the VM.
811
+
812
+ **Verified resolution (smoke-tested 2026-04-28):** the `@vercel/sandbox`
813
+ SDK supports streaming via two APIs (`Command.logs()` async-generator
814
+ and `runCommand({ stdout, stderr: Writable })` pipe-to-stream). A smoke
815
+ test ran a 100-line command and observed **80 incremental `Writable.write()`
816
+ calls** spread across the 1.3-second run — first chunk at +421ms, not
817
+ buffered-and-dumped. Abort latency ~12ms. The streaming question is
818
+ empirically resolved.
819
+
820
+ Our current `createVercelSandboxExec.ts:91-97` uses the buffered
821
+ convenience accessors `command.stdout()` + `command.stderr()` by choice,
822
+ not necessity. `DirectSandbox` and `BwrapSandbox` use `child_process`
823
+ which has streamed via `child.stdout.on('data', ...)` since forever.
824
+
825
+ **Mitigation: bead `uhwx.5b` (a new precursor)** extends `ExecOptions`
826
+ with optional `onStdout`/`onStderr` callbacks and updates all three
827
+ sandbox adapters to invoke them as bytes arrive. ~4-6 hours total. Gates
828
+ `uhwx.6` (vercel*Ops) and `uhwx.7` (buildHarnessAgentTools) since both
829
+ need streaming bash.
830
+
831
+ After `uhwx.5b`, pi's bash tool plugs in cleanly:
832
+
833
+ ```ts
834
+ return {
835
+ exec(command, cwd, { onData, signal, timeout, env }) {
836
+ return bundle.sandbox.exec(command, {
837
+ cwd, env, signal, timeoutMs: timeout && timeout * 1000,
838
+ onStdout: (buf) => onData(Buffer.from(buf)),
839
+ onStderr: (buf) => onData(Buffer.from(buf)),
840
+ }).then(result => ({ exitCode: result.exitCode }))
841
+ }
842
+ }
843
+ ```
844
+
845
+ ### R6 — Pi version pin
846
+
847
+ Operations API exists in `pi-coding-agent@0.67.68` (current pin). Locked
848
+ floor.
849
+
850
+ **Mitigation:** none needed — we already exact-pin pi.
851
+
852
+ ### R7 — Path-bounding error message must reach the model usefully
853
+
854
+ Today, our hand-rolled tools return `{ isError: true, content: [{ type:
855
+ 'text', text: 'path is outside workspace' }] }` — the model sees a clear
856
+ text error. After migration, an Operations adapter that throws gets
857
+ formatted by pi's error path. We need to verify the model sees a
858
+ meaningful message (not "Error: ENOENT" or stack-trace noise).
859
+
860
+ **Mitigation:** in `boundFs`, throw with a specific message:
861
+ ```ts
862
+ throw new Error(`path "${rel}" is outside workspace`)
863
+ ```
864
+ Add a test that exercises this path through a full pi tool call and
865
+ asserts the resulting `text-delta` chunks contain a useful message.
866
+
867
+ ### R8 — Symlink escapes via `boundFs`
868
+
869
+ `assertRealPathWithinWorkspace` (per spec line 390) resolves symlinks
870
+ *before* bounding so a symlink inside the workspace pointing to
871
+ `/etc/passwd` is rejected. Pi's `resolveToCwd` does NOT do this — it just
872
+ joins paths. **`boundFs` must call `fs.realpath` (or equivalent)** before
873
+ the `isUnder` check.
874
+
875
+ **Mitigation:** port `assertRealPathWithinWorkspace` from
876
+ `packages/agent/src/server/workspace/paths.ts` into the `boundFs`
877
+ implementation (or call it directly). Add a test creating a symlink in
878
+ the workspace pointing outside it and asserting the read is rejected.
879
+
880
+ ### R9 — Streaming compatibility with our `useChat` UI
881
+
882
+ Pi's tool factories emit specific `tool_execution_*` events that our
883
+ `stream-adapter.ts` translates into AI SDK `tool-input-*` /
884
+ `tool-output-*` chunks (per `agent-package-spec.md` line 658-674). If
885
+ pi's events differ shape-wise from what our adapter expects, the UI may
886
+ render tool calls oddly or break.
887
+
888
+ **Mitigation:** the adapter already exists and works for our hand-rolled
889
+ tools (which emit pi-shaped events because they go through pi's tool
890
+ machinery via `customTools`). Pi's factory tools emit the same events.
891
+ No translation change needed. Verify via Step 2 smoke (direct mode)
892
+ before flipping other modes.
893
+
894
+ ### R10 — `excludeDefaults` vs `disableDefaultFileTools` (PLUGIN_MODEL v7.0+)
895
+
896
+ Per PLUGIN_MODEL v7.0's separation of concerns, these are two
897
+ **distinct** switches at two **distinct** layers:
898
+
899
+ | Switch | What it removes | Layer |
900
+ |---|---|---|
901
+ | `disableDefaultFileTools: true` on `createAgentApp` | LLM file tools (read/write/edit/find/grep/ls) | Harness |
902
+ | `excludeDefaults: ['filesystem']` on `<WorkspaceProvider>` / `createWorkspaceAgentApp` | Filesystem UI (Files left-tab + code/markdown editor auto-routing) | Workspace |
903
+
904
+ `createWorkspaceAgentApp` does **NOT** pass `disableDefaultFileTools`
905
+ to `createAgentApp` (v7.0+ simplification — no dual-registration to
906
+ coordinate). File tools always flow through the harness; UI is
907
+ gated separately.
908
+
909
+ **Mitigation:** explicit tests in `createWorkspaceAgentApp` that:
910
+ - Default config: file tools register once (via harness); Files
911
+ left-tab visible.
912
+ - With `excludeDefaults: ['filesystem']`: file tools STILL register
913
+ (harness owns them); Files left-tab NOT visible.
914
+ - With `disableDefaultFileTools: true` (forwarded option): zero file
915
+ tools register; Files left-tab still visible (harmless — tools are
916
+ separate from UI).
917
+ - With both: zero tools, no UI.
918
+
919
+ ## Test strategy
920
+
921
+ ### Unit tests (per step)
922
+
923
+ - Each Operations adapter: happy path + path-bounding rejection +
924
+ symlink-escape rejection + error-shape propagation.
925
+ - Each bundle factory: returns the right tools per mode (assert tool
926
+ `name` array per mode; assert `vercelGrepTool` appears in
927
+ vercel-sandbox bundle and pi's `createGrepTool` does NOT).
928
+ - `vercelGrepTool` parses ripgrep output correctly against a
929
+ fake/recorded `sandbox.exec` response.
930
+
931
+ ### Integration tests (per mode flip)
932
+
933
+ - Boot a Fastify app with `createAgentApp({ mode: '<mode>' })`,
934
+ inject a chat request, assert the response stream includes the
935
+ expected tool-call chunks for a known prompt. Spec line 762
936
+ describes this pattern.
937
+ - For vercel-sandbox: use a recorded `Sandbox` mock (we already have
938
+ some test fixtures); no real VM required for CI.
939
+
940
+ ### E2E smoke (manual, per mode flip)
941
+
942
+ For each of direct/local/vercel-sandbox after its flip step:
943
+
944
+ 1. Start the playground in that mode.
945
+ 2. Send: "list files in the repo" → exercises `find` (or `bash ls`).
946
+ 3. Send: "find all uses of `useEffect`" → exercises `grep`.
947
+ 4. Send: "read package.json" → exercises `read`.
948
+ 5. Send: "create a file foo.txt with content 'hi'" → exercises `write`.
949
+ 6. Send: "edit foo.txt to say 'hello'" → exercises `edit`.
950
+ 7. Send: "run `ls`" → exercises `bash`.
951
+ 8. Verify in devtools Network: chat POST returns SSE with
952
+ text-delta + tool-input-available + tool-output-available chunks
953
+ for each tool used; no errors.
954
+ 9. Verify in `GET /api/v1/agent/sessions/:id/system-prompt`: no
955
+ double-dash or duplicate-guideline noise; tool names match
956
+ `find`/`grep` (post-rename).
957
+
958
+ ### Regression guards
959
+
960
+ - Snapshot test of system-prompt size: assert post-Phase-1 baseline
961
+ is at least 700 chars smaller than the pre-Phase-1 baseline.
962
+ - Tool-renderer test: every tool name present in the bundle factories
963
+ has a renderer entry in
964
+ `packages/agent/src/ui-shadcn/workspaceToolRenderers.tsx`.
965
+ - A grep over `packages/` for legacy search-tool names returns zero
966
+ hits after step 6.
967
+
968
+ ## Verification checklist
969
+
970
+ ### After Phase 0
971
+
972
+ - [ ] `pnpm exec tsc --noEmit` clean across `packages/agent`.
973
+ - [ ] All 6 existing tool tests + new `_shared.test.ts` green.
974
+ - [ ] `bash scripts/check-invariants.sh packages/agent` passes.
975
+ - [ ] No tool file imports duplicated helpers from anywhere except
976
+ `_shared.ts`.
977
+
978
+ ### After Phase 1
979
+
980
+ - [ ] `pnpm exec tsc --noEmit` clean across `packages/agent`.
981
+ - [ ] Full `pnpm exec vitest run` green (modulo two pre-existing failures).
982
+ - [ ] `bash scripts/check-invariants.sh packages/agent` passes.
983
+ - [ ] **Direct mode smoke:** playground turn exercises bash/read/write/edit/find/grep;
984
+ no double-dash or duplicate-guideline noise in
985
+ `GET /api/v1/agent/sessions/:id/system-prompt`.
986
+ - [ ] **Local mode smoke:** same suite, bwrap visible in process tree
987
+ during bash exec.
988
+ - [ ] **Vercel-sandbox mode smoke:** same suite, all tool calls hit the VM
989
+ (verify via `sandbox.exec` log); custom `vercelGrepTool` returns
990
+ results from inside the VM.
991
+ - [ ] System prompt size for the playground baseline drops by at least
992
+ ~700 chars (per-tool guideline lines + double-dash slack).
993
+ - [ ] No source file under `src/server/catalog/tools/` defines `bash`,
994
+ `read`, `write`, `edit`, `find`, or `grep`. Only
995
+ `vercelGrepTool`, `executeIsolatedCodeTool`, and `_shared.ts` should
996
+ remain.
997
+
998
+ ## Estimated scope
999
+
1000
+ ### Phase 0
1001
+
1002
+ - New: `_shared.ts` (~80 LOC) + `_shared.test.ts` (~50 LOC).
1003
+ - Touched: 6 tool files (~30-40% smaller each).
1004
+ - Net: ~ **-20 LOC**.
1005
+
1006
+ ### Phase 1
1007
+
1008
+ - New:
1009
+ - `operations/index.ts` (`boundFs` + vercel ops) ~180 LOC.
1010
+ - `operations.test.ts` ~150 LOC.
1011
+ - `vercelGrepTool.ts` ~80 LOC + tests ~80 LOC.
1012
+ - Per-mode tool lists ~40 LOC × 3 modes = ~120 LOC.
1013
+ - Deleted:
1014
+ - 6 hand-rolled tool files ~1100 LOC.
1015
+ - 6 test files ~600 LOC.
1016
+ - `standardCatalog.ts` ~100 LOC.
1017
+ - Half of `tool-adapter.ts` ~25 LOC.
1018
+ - Net: ~ **-1240 LOC**.
1019
+
1020
+ ### Combined inventory after Phase 0 + Phase 1
1021
+
1022
+ | What we own | LOC |
1023
+ |---|---:|
1024
+ | `_shared.ts` | ~80 |
1025
+ | `tools/operations/index.ts` (boundFs + vercelOps) | ~180 |
1026
+ | `tools/operations.test.ts` | ~150 |
1027
+ | `tools/vercelGrepTool.ts` (+ tests) | ~160 |
1028
+ | `tools/executeIsolatedCodeTool.ts` (+ tests) | ~120 |
1029
+ | `tool-adapter.ts` (slimmed) | ~30 |
1030
+ | Per-mode tool lists | ~120 |
1031
+ | **Total** | **~840 LOC** |
1032
+
1033
+ vs current ~1830 LOC across the same surface. **Net -990 LOC** with no
1034
+ behavior change for direct/local modes, and a more correct vercel-sandbox
1035
+ mode (today's hand-rolled tools don't actually route to the sandbox in the
1036
+ sense pi's Operations would).
1037
+
1038
+ ## Non-goals
1039
+
1040
+ - Not changing how `customTools` work for plugin tools / `extraTools`.
1041
+ - Not removing path-bounding or any security guarantee. The bound moves
1042
+ from inside our hand-rolled tool to inside the Operations adapter.
1043
+ - Not opting out of pi-extension tools (`web_search`, `code_search`, …).
1044
+ Tracked separately under "noExtensions knob".
1045
+ - Not adding `ls` in Phase 1 (Step 6 is optional follow-up).
1046
+ - Not changing the dual-registration story in PLUGIN_MODEL.md.
1047
+ PLUGIN_MODEL ships after this plan; Step 1b's bundle extraction picks up
1048
+ whatever shape `runtime/modes/*.ts` has by then — a pure file move.
1049
+ - Not moving `inferBashFileChanges` out of bash. Independent PR.
1050
+ - Not building a `defineTool(spec)` skeleton in Phase 0. Pi already
1051
+ provides this via TypeBox; rolling our own would die in Phase 1.
1052
+
1053
+ ## References
1054
+
1055
+ - Original RFC: [pi-mono#564 — feat(coding-agent): Add pluggable operations](https://github.com/badlogic/pi-mono/issues/564)
1056
+ - Canonical SSH-pattern reference: `examples/extensions/ssh.ts` in pi-coding-agent
1057
+ - Canonical bwrap-pattern reference: `examples/extensions/sandbox/index.ts` in pi-coding-agent
1058
+ - Why grep is default: [pi-mono#3782 — Default to using custom `grep` tool](https://github.com/badlogic/pi-mono/issues/3782)
1059
+ - Active SSH-extension iteration: [pi-mono#3500](https://github.com/badlogic/pi-mono/issues/3500), [#3320](https://github.com/badlogic/pi-mono/issues/3320), [#2965](https://github.com/badlogic/pi-mono/issues/2965)
1060
+ - Original spec: `packages/agent/docs/plans/agent-package-spec.md` (decisions #4, #6)
1061
+ - Companion plan (lands after this): `packages/workspace/docs/plans/archive/PLUGIN_MODEL.md`