pi-workspaces0.1.2
Multi-root workspace extension for the pi coding agent
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-workspaces
English | 中文
Multi-root workspaces for the pi coding agent. A workspace merges several directories ("roots") into one named unit, so a single pi session can read, write, search, and run shell commands across all of them - VS Code-style multi-root folders, but for your agent.
When no workspace is active the extension does nothing at all: every tool behaves exactly like stock pi.
Features
- Multi-root file tools -
read,write,edit,grep,find, andlsaccept a@root-name/path prefix that routes the call into any workspace root; result shape and rendering stay identical to the built-ins. - Cross-root shell -
bashgains an optionalcwdthat accepts@root-name/sub/dirand spawns the command inside that root (safe: pi spawns a fresh shell per call). - Prompt injection - while a workspace is active, a workspace section (root map, syntax rules) is appended to the system prompt, and each root's AGENTS.md / CLAUDE.md is injected once on first touch, falling back to the session root's file.
- Editor completion - typing
@offers root switchers plus current-root files;/workspacecompletes its subcommands, workspace and root names, and paths. - Footer statusline -
[ws] <workspace> (3 roots), degrading to[ws] <workspace> (2/3 roots) ! <root>missingwhen a root is missing on disk. - Durable session state - the active workspace is journaled, so
/resumerestores it. - Headless-friendly - all
/workspacecommands work in RPC mode, emitting notifications as JSON events for scripting.
Installation
The extension is plain TypeScript loaded directly by pi (no build step) and installs like any other pi package.
Global (personal, cross-project)
pi install npm:pi-workspaces
# or pinned to a git ref
pi install git:github.com/zhang-stephen/pi-workspaces@v0.1.0
Loads in every session, no matter which directory it starts in. Best for your own machine and personal workspaces.
Project-level (bound to one repository)
pi install -l npm:pi-workspaces
Writes the dependency to the repository's .pi/settings.json, so it travels with the repo: pi installs it automatically on startup once the project is trusted, and the extension loads only for sessions started inside that repository.
Try or develop without installing
Clone the repo anywhere and point pi -e at the checkout - the extension loads for the current run only:
git clone https://github.com/zhang-stephen/pi-workspaces.git
pi -e ./pi-workspaces
Trust note. Project-scoped installs (.pi/settings.json) load only after the project is trusted. pi resolves trust from saved trust.json decisions first, then its defaultProjectTrust setting decides whether it asks, trusts, or declines. Until the project is trusted the extension simply is not loaded.
[!WARNING] Never install it twice for the same sessions! If pi finds the extension via both user settings and project settings (or additionally via
pi -e), it loads two instances and the seven tool overrides double-register (bash,read,write, ...), which breaks tool dispatch and rendering. Keep exactly one install per machine-and-repo setup; delete the other first when migrating.
Install scope decides what you can see
The extension detects how it was loaded and scopes all config access accordingly:
| How it was loaded | Definition sources | Config file | /workspace create writes to |
|---|---|---|---|
Global install (pi install, user settings) |
global + project (merged by name) | read | global source |
Project install (pi install -l, project settings) |
project only | never read | project source |
pi -e <path> (dev loop) |
project only | never read | project source |
A project-scoped load never reads or writes anything under ~/.pi/agent/ - not the workspace definitions, not the config file. Your personal global workspaces stay invisible to a repo-shared extension install, and the development loop (pi -e ...) behaves the same way. To develop against global definitions, install the extension globally for real.
Quick start
/workspace create my-project # current dir becomes the first root
/workspace add-root frontend C:/repos/frontend
/workspace add-root backend C:/repos/backend
Or hand-write a definition file (see the schema below) into ~/.pi/agent/workspaces/ (global, personal) or .pi/workspaces/ inside your project (shareable via git). Then start pi inside any root - or use /workspace load my-project.
Workspace definitions
Definitions are JSON files, one workspace per file. A workspace is an unordered set of equal roots - there is no primary root:
- Global source:
~/.pi/agent/workspaces/<name>.json(visible to global installs only) - Project source:
.pi/workspaces/<name>.jsoninside the discovered project directory (visible to every install scope). The project directory is found by marker ascent: from the session directory, walk up at mostprojectRootAscendlevels (built-in default 3) and stop at the first directory containing a.pi,.git, or.agentsmarker. The nearest marker wins even without a.pi/workspacessubdirectory (the project source is then simply empty); discovery never ascends above your home directory, and with no marker inside the cap the session directory itself is used.
Schema
{
"name": "my-workspace",
"version": 1,
"roots": [
{ "name": "backend", "path": "C:/repos/backend" },
{ "name": "frontend", "path": "C:/repos/frontend" }
]
}
name: workspace name. Must match^[A-Za-z0-9_-]+$(letters, digits,-,_; no path separators).version: schema version, currently1. Files with an unknown version are skipped silently at startup (visible via/workspace list).roots: non-empty array of root objects. Each root has aname(same pattern, unique within the workspace) and an absolutepath. Illegal or duplicate root names reject the whole definition. On Windows, write paths with forward slashes (C:/repos/backend) - JSON does not accept\U-style escapes, so backslash paths are invalid.- Definitions carry no options - the schema is
name/version/rootsonly, and any other top-level key (including the removedoptions) rejects the file with the key named.
Validation rejects malformed files with an explanatory error; at startup a corrupt or incompatible file is skipped silently - it surfaces through /workspace list, which re-scans and reports (broken files must not pollute unrelated sessions). All writes back to definition files are atomic (temp file + rename).
Config and the resolution chain
The plugin config is one flat struct. The global file is ~/.pi/agent/pi-workspaces.json; a project can override individual keys via <project>/.pi/pi-workspaces.json:
{
"activation": "auto",
"warnOnUnrelatedLoad": true,
"projectRootAscend": 3
}
Each key resolves independently, project first, then the global file, then the built-in value; a missing or wrong-typed value silently falls through:
project config.<key> ?? global config.<key> ?? built-in default
Built-in defaults: activation: "auto", warnOnUnrelatedLoad: true, projectRootAscend: 3.
activation:"auto"loads the workspace silently when the session starts inside any of its roots;"prompt"asks first. When several workspaces contain the session directory, pi always asks (disambiguation), even if the config says"auto". Activation is a property of the project/directory context - it cannot be set per workspace.warnOnUnrelatedLoad: when/workspace loadactivates a workspace whose roots do not contain the session directory, warn that bare relative paths stay anchored at the session directory. The load proceeds either way.projectRootAscend(global-only): the ascent cap for project-source discovery. It lives only in the global config - it controls how definitions are found, so a project-level override would be circular (a project-level value is ignored), and project-scoped installs always use the built-in value.
Path syntax and bash cwd
With a workspace active, tool paths accept three forms:
@root-name/relative/path- resolved against that root's absolute path. The result must stay inside the root:@backend/../../etcis rejected with an error stating the containment rule. An unknown root name produces an error listing the available roots.- Bare relative path - resolves against the session start directory (
ctx.cwd), exactly like stock pi. This rule never changes. - Absolute path - works everywhere. Inside a root it is attributed to the longest-prefix matching root; outside all roots it passes through unchanged (stock pi behavior; writes outside all roots are allowed).
The bash tool's cwd parameter accepts the same three forms; when omitted, the command runs in the session start directory:
bash(command: "npm test", cwd: "@frontend")
bash(command: "git status", cwd: "C:/repos/backend")
bash(command: "pwd") # session start directory
Resolution failures are thrown as tool errors the model can see and self-correct from.
How workspaces activate
At session_start the extension scans the definition sources visible to its install scope and then:
- Auto-load: if exactly one workspace contains the session directory (inside any root) and the session config's
activationresolves to"auto", that workspace loads immediately. If the loaded workspace's name exists in both sources, an info line notes that the project definition wins. - Journal restore: if the session is a
/resume, the last recorded active workspace is re-activated. Repeatedsession_startevents (e.g. extension reloads) never duplicate the journal entry. - Ask: otherwise, if at least one workspace contains the session directory, pi prompts with exactly those workspaces plus a "Don't load" escape hatch - this covers
activation: "prompt"projects and multi-match disambiguation. A session started outside every workspace root is never prompted - loading from an unrelated directory is always an explicit/workspace load(which warns by default, seewarnOnUnrelatedLoad).
Broken definition files never produce startup warnings: diagnostics surface only when you run /workspace list, which re-scans and reports. The one exception tied to your directory: if the session directory matches only a shadowed definition (the losing side of a name collision), pi warns that the project source overrides it and loads nothing.
Only one workspace can be active at a time; loading another replaces the current one (with a notification).
Command reference
All commands are /workspace subcommands; output is shown via pi notifications.
| Command | Behavior |
|---|---|
/workspace (or status) |
Show the active workspace: name, origin, and every root with a (MISSING) marker when its directory is gone. |
/workspace list |
List all definitions from both sources, each with its origin (global or project). |
/workspace load <name> |
Activate a workspace by name. Warns when its roots do not contain the session directory (warnOnUnrelatedLoad); loads either way. |
/workspace unload |
Deactivate the active workspace; the statusline clears. |
/workspace create <name> |
Create a definition with the current directory as its sole root, and activate it. Saved to the source matching the install scope (global installs: global source; everything else: project source). |
/workspace add [name] <path> |
Add a root to the active workspace (alias: add-root). The name is optional (derived from the directory basename when omitted); a relative path anchors at the session directory. The definition is persisted back to its origin source. |
/workspace remove <name> |
Remove a root from the active workspace and persist (alias: remove-root). The last remaining root cannot be removed. |
/workspace config |
Show the effective config with provenance - for each key, whether its value comes from the project file, the global file, or the built-in default. |
/workspace config set <key> <value> [global|project] |
Validate and set one config key (activation, warnOnUnrelatedLoad, projectRootAscend). The level defaults to global; pass project to write the project override instead. projectRootAscend only accepts the global level. Writes preserve every other key in the file and are atomic. |
/workspace config unset <key> [global|project] |
Remove one key override so the resolution chain falls back to the next level; reports the new effective value. |
Interactive argument pickers (for example a fuzzy load picker) are post-MVP - missing arguments print usage text instead.
Headless usage (print / RPC mode)
All commands work headless, which is also how this extension is smoke-tested. Two things to know:
- Use RPC mode to observe command output. In print mode (
pi -p),ctx.ui.notifyis a no-op, so command output is invisible. In RPC mode (pi --mode rpc), every notification and statusline update is emitted as a JSON event:printf '%s\n' '{"type":"prompt","message":"/workspace list"}' | pi --mode rpc. - Windows / Git Bash argument mangling. When passing a slash command as an argument (e.g.
pi -p "/workspace list"), MSYS path conversion rewrites/workspaceintoC:/Program Files/Git/workspace. SetMSYS_NO_PATHCONV=1(orMSYS2_ARG_CONV_EXCL="*") first. - No prompts headless. The select-list activation step requires a UI; headless sessions rely on auto-load (start pi inside any root of an
activation: "auto"workspace) or journal restore (-c//resume).pi -cignores session files that contain no messages. - Piped RPC prompts are not serialized. pi does not await one piped prompt's command handler before starting the next, so mutating commands issued back-to-back in one batch can race on the definition file. Send mutating commands one at a time (interactive use is unaffected).
How definitions merge
A global install scans both sources at session_start and merges by name: a project definition overrides a global definition of the same name, and the project source wins per name. A wholesale override never happens - a project file cannot hide your unrelated global workspaces; they keep loading side by side. Project-scoped installs (project install or -e dev load) see only the project source, so no merging applies to them.
- Each merged definition records its
origin(globalorproject), shown by/workspace listand/workspace status. - Collision notices are relevance-gated: an info line ("the project definition wins") appears only when a collided workspace actually activates (auto-load, journal restore, prompt selection,
/workspace load); a directory matching only the shadowed global copy warns without loading; everything else stays silent.
Project-level install caveats
A project-level install has two consequences, both expected behavior rather than bugs:
- Discovery: pi discovers project extensions from the session's own directory, so the extension loads only when the session starts inside that repository. Sessions started elsewhere - including inside another root of the same workspace - have no auto-load, prompts, tool overrides, or statusline. Use a global install if you want the extension everywhere.
- Isolation: a project-level install (like an
-edev load) is project-scoped: it sees only the discovered project source (.pi/workspaces/under the nearest marker directory) and never reads or writes the global config under~/.pi/agent/. A repo-shared extension cannot peek at your personal workspaces.
Storage locations
| What | Where |
|---|---|
| Global definitions | ~/.pi/agent/workspaces/*.json (global installs only) |
| Project definitions | .pi/workspaces/*.json inside the discovered project directory (all install scopes) |
| Global default options | ~/.pi/agent/pi-workspaces.json (global installs only) |
| Active-workspace journal | inside the pi session file (custom entry pi-workspaces:active) |
Known limitations
pi-fff conflict. With
@ff-labs/pi-fff(or any fork carrying its mention provider) in its defaulttools-and-uimode, its own @ fuzzy search answers every@query and pi-workspaces' root completion never surfaces. Switch pi-fff to itstools-onlymode (/fff-mode tools-only) to restore pi-workspaces' @ completion.Explicit
cwddrops session env vars. Abashcall with an explicitcwdruns without thePI_*session environment variables (passing the runtime ctx through would override the resolved directory). Calls withoutcwd- the default branch - do inject them.Symlinked definition files are skipped. The source scan only accepts regular
.jsonfiles, so a workspace definition that is a symbolic link is silently ignored (use a real file or a link to the directory).Symlinked directories complete as files. Inside a root, the autocomplete provider classifies entries by the directory flag, so a symlinked subdirectory is offered without a trailing
/.
Development
# Load the extension in development mode from any directory
pi -e /path/to/pi-workspaces/index.ts
# Run tests
node --test "test/**/*.ts"
TypeScript loaded directly by pi via jiti - no build step, no third-party runtime dependencies (Node built-ins + pi exports only).
License
MIT - see LICENSE.