Skip to content

03. Tools and Permissions ​

Decisions applied: D003, D004, D006, D013, D015, D093, D114, D115, D181, D186, D636, D189, D190, D195 (ADR 0057), D315, D384 (ADR 0211), ADR 0087

0. Frozen policy summary ​

Parallel read batches ​

Read, Glob and Grep are the first parallel batch candidates. Task-only batches retain their existing parallel fan-out; a batch that mixes Task with Read, Glob or Grep is kept sequential: a read ordered before Task finishes before the delegate starts, and a read ordered after a writable Task waits for that delegate to settle before dispatching to Host. Task keeps its ADR 0089 contract inside that batch: it reports a started delegation and the parent converges with TaskWait. Any other sequential tool (including Write, Edit, Bash and unknown tools) makes its entire assistant batch sequential. This is a per-agent batch rule, not application-wide exclusion or a Shell safety lock. Only the selected builtin executor receives this allowlist and native path preparation. Same-name dynamic tools remain sequential. Delegates retain their startup catalog's policy and provenance across later parent catalog rebuilds.

The pinned pi-agent-core prepares all parallel calls in source order before launching their execution bodies. Path instruction resolution and prompt updates occur during that preparation, after existing parent mode/extension checks. Execution consumes the same normalized arguments. Direct internal invocations retain preflight. Child preflight updates the child's system transcript only; it never replaces parent instructions. Sibling instruction chains replace one another according to preparation order, regardless of completion order.

Permissions, Host capacities, mutation recovery, Hashline validation and tool result IDs remain authoritative. Completion events may arrive out of order; model tool results remain in source order. Alongside the mixed Task/read batch barrier, PathMutex coordinates shared reads against exclusive Write/Edit calls on the same normalized workspace path so a parent Read does not overlap an in-flight delegate mutation on that file. Stop skips unstarted calls; already started file RPCs drain through their existing Host lifecycle. Host file cancellation and resource coordination are separate follow-up work.

See the ADR.

TopicDecision
Default modeAgent
Agent toolsRead / Glob / Grep / Write / Edit / Bash + registered plugin tools + optional Jev classifier
Plan toolsRead / Glob / Grep / BrowserPreview / Bash / SubmitPlan + plugin tools that declare plan-safe actions
Goal toolsRead / Glob / Grep / BrowserPreview / Bash / SubmitGoal + plugin tools that declare plan-safe actions
Plan and Goal hard denyWrite / Edit / plugin tools without planSafeActions / unknown tools / the other kind's submit tool
Plugin planSafeActionsNon-empty array of action strings; runtime hides plugin tools without one in Plan/Goal, host admits listed tools, plugin-runtime rejects any action outside the list (ADR 0211)
Local permission approvalNo automatic deadline; explicit decision or cancellation required
allow-session scopetoolName
Bash stylenon-interactive; selected host catalog shell with streamed output
Edit contractline-anchored ops + whole-file tag; no old_string/new_string (ADR 0087)
asktoolinteractive multi-question tool; no validity deadline; skipped answers become empty output fields

1. Goal ​

Let the agent get things done, but stay under control by default.

2. MVP Built-in Tools ​

ToolRiskDescription
ReadlowRead files within the workspace; returns line-numbered content and a [path#TAG] header
new_contextlowStart a new context window at the next turn boundary; takes no parameters and changes no environment state
GloblowList files by pattern
GreplowContent search; uses system rg when installed, else in-process; mints a per-file tag
BrowserPreviewlowOpen a workspace-relative preview in the bundled Browser plugin (fails if pi.browser is disabled)
EnterPlanModelowMove the same Agent from Agent to Plan after host validation
SubmitPlanlowPreserve exact Markdown bytes in a new .pi/plan/*.md artifact and request approval
EnterGoalModelowMove the same Agent from Agent to Goal after host validation
SubmitGoallowPreserve exact Markdown bytes in a new .pi/goal/*.md artifact and request approval
WritehighCreate/overwrite files; returns the post-write tag
EdithighModify files through line-anchored ops against a verified tag (18)
BashhighExecute commands
asktoollowAsk one or more user questions and return the submitted answers as tool output
TodoWritelowReplace the current Agent session checklist; host validates and persists the full ordered snapshot

Names may be fine-tuned during implementation, but semantics stay consistent.

2.1 Deferred ancillary tools (D185, ADR 0048) ​

The first Agent request includes Read, Bash, Edit, Write, Glob, and Grep. Keeping workspace listing and content search in the initial schema avoids a discovery round trip for routine project exploration (the amendment to ADR 0048 records this change). Plan and Goal keep their read/inspection core. Skill is deliberately not deferred: a /skill:<skill-id> invocation instructs the model to call it, and a tool absent from the schema cannot be called at all, so it ships with the first request whenever the skill catalog is non-empty (D404, ADR 0230). The runtime still registers optional capabilities without sending their full schemas up front:

  • BrowserPreview
  • PluginCheck, PluginScaffold, and PluginPack
  • plugin-declared agent tools
  • JevClassify when the user enables Jev and saves a TypeSafe API key

These tools appear in a bounded # On-demand tools catalog with compact descriptions. The model calls the local ToolSearch tool with an exact name or capability query; the matching schemas become available on the next model turn. At the beginning of every new user prompt, the sidecar clears the in-memory deferred set and restores only successful activation evidence from the effective session context: canonical details.addedToolNames on successful ToolSearch results and the names of successful deferred-tool results. For compatibility, historical details.activated and top-level addedToolNames markers are accepted as well. Failed rows, interrupted or missing-result placeholders, and assistant/user prose are ignored. Restored names must still be in the current mode's deferred catalog. The host permission, workspace/scratch containment, timeout, and audit rules do not change when a tool is loaded. ToolSearch itself never executes a workspace operation and never bypasses host-core policy.

JevClassify is offered only in Agent mode, only when explicitly enabled in Settings → Models, and only while its TypeSafe key is available. It is a structured classifier call, not a chat model or workspace operation. The Agent must supply its JSON state and typed questions; those values go directly to TypeSafe when the tool is called. The tool validates JSON shape and bounds the payload to 24 KiB, uses the pi-ai jev-latest classifier with a 45-second timeout, and returns only structured answers and reported usage. Plan and Goal never receive the tool. The settings disclosure warns users not to pass secrets or personal information.

An explicit composer MCP selection carries exact server IDs to the runtime. The current host-supplied catalog associates each MCP tool with its server ID; selection activates all mode-allowed tools for that server before the request, without a ToolSearch call or its result-count limit. An optional mcpToolNames selection narrows activation to those exact catalog names, each owned by a selected server. Unknown, differently owned or mode-denied requested tools fail; there is no fallback to whole-server activation. Steering activates only when the queued user message is consumed, before the next provider dispatch. Unknown servers and selections with no mode-allowed tools fail explicitly. Activation preserves other tools, follows existing session restoration, and never bypasses execution permissions. Missing selection fields retain normal on-demand discovery. Main rejects a selected MCP command without task text or an attachment before opening or persisting a turn. See docs/adr/composer-mcp-invocations.md.

3. Common Tool Constraints ​

Every non-interactive workspace execution tool must have:

  1. JSON schema / typebox parameter definition
  2. timeout
  3. workspace path validation
  4. output truncation policy
  5. trace id
  6. structured results

asktool is the interactive exception: it has a typed request event, waits for the renderer response without an expiry, and returns a bounded structured tool result. Options may be plain strings or { label, description? } objects; the selected label remains the answer value. Stopping the turn resolves outstanding questions as skipped.

A stop sends a typed tools.abort request for each in-flight call (ADR tool-abort-abandons-host-call). Host-core registers cancellation before permission evaluation, so a pending approval or queued admission is cancelled instead of starting after Stop. Bash and GenerateImages use the existing process cancellation path; the runtime waits up to 10s for the host to return output collected before termination. Local Read, Glob, and Grep calls may end the runtime's wait immediately and return TOOL_ABORTED; their results are discarded. A blocking filesystem worker may continue until it returns, but these tools do not mutate the workspace. For a mutating, MCP, or plugin call that has already started, the runtime waits for the host result before it settles the turn, so Stop cannot hide a write or extension action that is still running. Typed cancellation records include the tool name and cannot be inherited by a different tool that reuses the call id.

JevClassify is an external provider operation rather than a workspace execution tool. It validates its structured request, observes cancellation, uses a bounded provider timeout, and returns typed classifier results; it has no filesystem path or host-core workspace permission surface.

4. Path Rules ​

Native file and search tools enforce distinct path shapes (D208, ADR 0069):

  • Read.path is an existing regular file. A directory returns INVALID_ARGUMENT with a structured Glob suggestion rather than a generic execution failure.
  • Glob.path is a directory search root.
  • Grep.path may be one file or a directory tree. A directly named file is searched without walking siblings, while include still filters its base name and every output budget remains unchanged. Grep prefers a user-installed rg on PATH (and the Unix login PATH) and falls back to the in-process searcher when rg is missing or fails (D315). The model-facing contract does not change.

Workspace-relative paths in tool results use / for platform separators. On POSIX, a literal backslash in a filename remains a backslash so the result can be passed back to Read or Edit; Windows path separators are normalized to /.

Agent mode keeps Glob/Grep available from the first request. Other deferred tools still follow the per-prompt activation and successful-context restoration rules above; directory discovery can call Glob directly without a ToolSearch round trip.

The runtime accepts one alias per canonical argument name and folds it away before the host sees the call (D273):

ToolCanonicalAccepted alias
Read / Write / Edit / BrowserPreviewpathfile_path
Glob / Greppatternquery

Both spellings are optional in the schema and the runtime requires exactly one; a call naming neither fails with INVALID_ARGUMENT. When a call carries both, the canonical name wins. Bash.timeout accepts up to 100000000 in the schema so a millisecond value validates: a value above the honoured 21,600-second ceiling is read as milliseconds and converted to seconds, then clamped to 21,600 seconds (D273 / D329). In-range values, including 600 and 1800, are seconds.

  • For a durable sessionId, workspaceRoot is resolved from that session's persisted project binding. A path-less temporary session instead binds its workspaceRoot to <data_dir>/scratch/<sessionId>. Neither root is read from the mutable active sidebar tab at execution time.
  • All file paths are relative to the resolved workspaceRoot by default
  • After normalization they must still reside within the workspace unless the call receives explicit outside-path permission
  • .. escapes and symlink escapes are outside-path requests, not implicit access
  • Symlinks are resolved again immediately before execution, after permission approval, so approval cannot skip the canonicalization step
  • Exception (D114): absolute paths inside the session scratch directory are a second legal root for Read/Write/Edit — see §4b. Both roots run the same lexical + symlink containment defense. Read/Glob/Grep/Write/Edit may address an explicit path outside both roots only through the permission policy below; a denied or unapproved request returns TOOL_DENIED.

4a. Explicit outside-path permission ​

An explicit path argument that resolves outside the session workspace and scratch roots is a separate capability. The host checks it before the normal low-risk auto-allow decision:

  • auto allows the outside path without a card;
  • ask and accept-edits emit the ordinary permission card;
  • allow-once executes only the current call, while allow-session follows the existing per-tool session grant scope;
  • denial or cancellation never executes the operation;
  • relative .. escapes and symlink escapes use the same rule as absolute paths;
  • successful external Read/Write/Edit results carry root: "external" and an absolute canonical path; external Glob/Grep matches are absolute so the access remains visible in the transcript.

The exception applies only to the explicit path argument. It does not expand the workspace root, Bash's working directory, or any implicit directory walk.

4b. Session scratch directory (D114) ​

Temporary/intermediate files an agent produces (one-off scripts, downloaded data, drafts) must not dirty the user's project or its git status. Each session gets a scratch directory outside the workspace:

text
<data_dir>/scratch/<sessionId>/

OS clipboard files and images pasted into the composer are materialized by Electron main below <data_dir>/scratch/<sessionId>/pasted/ before their paths and metadata are captured as transient composer references. At dispatch, main validates the source against the session scratch/project roots. Images are also copied into the content-addressed <data_dir>/attachments/<sha256> store; the effective model capability applies the published image input plus the configured binding's supportsImages override. Eligible images receive image blocks when that effective capability is enabled, while unknown/custom models without an explicit override, disabled image input, and oversized images receive @ fallback paths. They use the same session lifecycle as other scratch data and do not enter the workspace, artifacts, or the persisted prompt as binary content.

  • Addressing. In a project session, the model addresses scratch by absolute path only; the path is advertised in the system prompt, relative tool paths resolve against the project workspace, and Bash exports PI_SCRATCH_DIR. POSIX shells, including Git Bash on Windows, receive the forward-slash path advertised in the prompt; PowerShell and cmd keep the native path spelling. In a temporary session, that same scratch directory is the session workspace root, so relative Read/Glob/Grep/Write/Edit/Bash paths work there without inheriting a project.
  • Containment. resolve_tool_path tries the workspace root first, then the scratch root, applying the identical two-layer defense (lexical .. normalization + canonicalized-ancestor symlink check) to each. A symlink planted inside scratch cannot reach the workspace or anywhere else.
  • Permissions. Write/Edit whose path is lexically inside the session's scratch root auto-allow without a permission card — they cannot touch the project. The lexical check only skips the prompt; execution still goes through the full resolver, so it is not an escape vector. Plan and Goal do not expose Write/Edit, so the scratch auto-allow rule cannot make those tools available in either. A contract-mode Bash call may still create or mutate scratch data when its permission mode allows it.
  • Artifacts. Successful scratch writes are not recorded in the artifacts table; artifact-driven file tabs represent workspace deliverables only, while the Files surface may still browse the active workspace. Tool results carry root: "workspace" | "scratch" to make this decision and the UI rendering explicit.
  • Tool coverage. Read/Write/Edit use the workspace and scratch roots; Glob/Grep use the workspace root by default and may search an explicitly scoped scratch directory or an explicitly approved external directory. The model should use bounded native search tools instead of shell directory walks. BrowserPreview remains workspace-relative in v1. Its Main-process handler resolves the root from the originating durable session, and the renderer event carries sessionId; the selected foreground workspace is never used for a background preview.
  • Lifecycle. Created lazily on the first Write/Edit/Bash or composer clipboard paste of a session. Deleted with session.delete. A startup sweep removes scratch dirs whose session no longer exists and dirs untouched for over 7 days (crash/force-quit fallback; no scheduled job needed).
  • A project switch does not redirect or cancel a background session's tools; sessions A and B remain sandboxed to projects A and B respectively.
  • A Temporary/path-less session uses only its own scratch directory as its workspace root, even if another project is visible or recently active. It never inherits that project. Plan and Goal still require a persisted project root, so this binding does not expand contract-mode execution. High-risk tools operate only inside the temporary session's scratch root.
  • Legacy calls that do not resolve to a durable session may use the selected host workspace only during the compatibility window.
  • A session lookup/storage error fails the tool request; it must never be treated as a missing legacy session or redirected to the selected workspace.

4b.1 Session visualization directory (D671) ​

Inline visualizations (ADR gen-ui-inline-visualization) are HTML fragments the model writes for the transcript to render. They live in a third host-owned root, separate from scratch so that scratch keeps its 7-day sweep while a visualization lives as long as its session:

text
<data_dir>/visualizations/<sessionId>/
  • Availability. Local sessions only, and opt-in through AppSettings.inlineVisualizations (absent and false mean off). While it is off there is no directory, no prompt line, no pi-desktop/visualize skill in the catalog, / menu, or Skill tool, and the renderer strips reference lines without loading a card. A remote-workspace session gets none of it either.
  • Addressing. Absolute paths only; the path is advertised in the system prompt next to the scratch directory. Relative paths never resolve here.
  • Containment. Scratch and visualization roots are checked before the workspace, so a data directory inside a workspace remains controlled. The visualization base and session must be real directories, never symlink/junction redirects; canonical data-directory aliases are supported.
  • Content. Write/Edit accept only a single-component file name ending in .html; anything else in this root fails with a tool error. Read is allowed.
  • Permissions. Write/Edit inside the root auto-allow without a card, under the same conditions as scratch. Plan and Goal expose no Write/Edit, so contract modes cannot write here. Results carry root: "visualization", are not recorded as artifacts, and are not review-snapshotted.
  • Lifecycle. Created lazily on the first write. Deleted with session.delete. The startup sweep removes only safe session directories whose session no longer exists; it never expires live cards by age or traverses a redirected visualization base.
  • Rendering. A standalone ::visualize{"path":"<absolute path>","title":"…"} line outside code blocks renders as an inline card after the turn completes. The renderer derives pi-genui://session/<sessionId>/<file> from the path suffix and never sends the absolute path to Main. Host Core reads only regular non-symlink fragments up to 2 MB using pinned native directory capabilities. Main validates the private byte response, rejects full documents, and wraps the fragment in a host shell with a strict CSP. The shell preloads the bundled D3 and the viz chart helpers before the fragment and drops a fragment's own pinned D3 tag. The guest runtime reports the first uncaught fragment script error to the card, which keeps the frame and shows a notice with the bounded message as plain text. A successful Write of an .html file directly in the session's visualization directory also gets a static check in its result: inline scripts are compiled without running, and literal element ids passed to viz.render, viz.chart, getElementById, or querySelector("#…") must exist; problems are listed so the model can fix them with one Edit. Both the iframe and the response CSP header enforce sandbox allow-scripts, so omitting the caller's sandbox cannot enable cross-session DOM reads. Unix uses relative openat and a nonblocking leaf; Windows uses relative NtCreateFile directory handles, rejects reparse entries, and keeps share restrictions as an additional defense. Attribute-only junction mutation or directory replacement/restoration cannot redirect serving to another session. Fetches, frames, forms, and remote assets are blocked. WebRTC ICE remains the explicitly documented v1 residual channel (see the ADR).

4c. Message-owned review snapshots and rollback ​

Write and Edit are the structured review boundary. For a successful workspace-root mutation, host-core captures the previous file before execution and adds bounded review evidence to the tool result:

ts
type ReviewChange = {
  version: 1;
  snapshotId: string;
  messageId: string;
  path: string;
  operation: "write" | "edit" | "delete";
  status: "added" | "modified" | "deleted";
  state: "active" | "rolledBack";
  additions: number;
  deletions: number;
  hunks: DiffHunk[];
  binary?: boolean;
  truncated?: boolean;
  reversible: boolean;
};
  • The renderer persists and displays this record with the tool message; it does not recompute Review from Git, HEAD, or the current dirty tree.
  • Scratch-root, failed, denied, and unresolvable writes have no review record. Binary or oversized content may omit hunks and be non-reversible.
  • Rollback is host-owned and hash-guarded. It restores the captured previous bytes, or removes a newly-created file, only when the current content still equals the post-tool hash. A later edit returns conflict without touching the file. A completed rollback invalidates the session's snapshot entries for that path, so the model cannot keep editing against a tag the rollback replaced.
  • An Edit carrying MV DEST records two entries under one tool call — a source deletion and a destination creation — and rollback restores both or neither. REM records a deletion whose rollback restores the captured bytes. The hash guard uses the full digest, not the 16-bit tag.
  • Review snapshot files live outside the workspace and are removed with their session; orphaned session directories are swept on host startup.

4d. Mutation ordering and edit recovery ​

Write and Edit are serialized within each session. Read/search tools may continue in parallel, and different sessions may mutate different roots concurrently, but a session never has two in-flight mutations. The host holds the per-session mutation permit before consuming a global mutation slot, so a queued mutation cannot reserve capacity while it waits for an earlier edit.

Edit names positions and supplies new content only; it never matches existing text. Every call carries the whole-file tag minted by whichever tool last displayed the content, and the host rejects a call whose tag does not hash the live file or whose anchors reference lines this session never displayed. The full contract — tag computation, the session snapshot store, the op grammar, block resolution, registers, and drift recovery — is 18-line-anchored-edit-contract; this section keeps only the ordering and loop-guard rules. The agent mutation workflow is:

  1. Edit the deliverable directly with Edit or Write when it is inside the advertised workspace.
  2. If a dedicated worktree is outside that root, perform one guarded edit in that worktree with Bash and verify the resulting diff.
  3. Classify a failed edit before recovering. For a stale tag or unseen lines, perform one fresh Read of the current target and regenerate the change once (a complete EDIT_LINES_UNSEEN reveal may be retried unchanged). For a deterministic syntax or range error such as EDIT_PARSE_FAILED, correct the operation payload directly; another Read does not repair malformed syntax. A body-bearing replacement must use a header such as PUT 48.=48:. After three counted failures on one path in a prompt (18-line-anchored-edit-contract §9.3), the third counted failed Edit for that path — or the third failed shell patch command (apply_patch, git apply, or patch) — returns a terminating tool result with an error-specific recovery hint, so the agent stops after reporting the exact mismatch. Do not hand-edit old unified-diff hunk headers or continue a repair loop.
  4. Recovery counters are scoped to the parent turn or the individual delegate run. One delegate's failed mutation cannot terminate another delegate or the parent. A delegate that exhausts its budget returns a failed, resumable Task result while preserving any report text it already produced.
  5. Keep mutations to one path sequential, even when read/search calls are issued in parallel.

An EDIT_LINES_UNSEEN rejection whose reveal was complete is exempt from step 3's re-read: the error already displayed the missing lines and merged them into the session's provenance, so the same tag retried unchanged applies. That retry is also the one grace EDIT_LINES_UNSEEN gets on the path, so a second one does count toward the guard.

Serialization also protects the snapshot store, which both producers and Edit mutate: without the per-session permit, a concurrent record could land between a validation and its write.

4e. Isolated session worktrees (ADR 0329) ​

A session created with isolated: true in a git project owns a dedicated worktree at <data_dir>/worktrees/<project-key>/<id> on branch pi-desktop/<id>, created from the project's current HEAD (uncommitted project changes are not copied; git-ignored files listed in .worktreeinclude are, without following links). For such a session:

  • the worktree replaces the project path as workspaceRoot for every rule in §4–§5, so file tools, Bash's working directory, review snapshots, and artifacts resolve inside it; it never falls back to the main checkout (a missing worktree fails with a WORKTREE_* error);
  • AGENTS.md instructions are read from the worktree, while project identity (memory, skills, plugins, MCP, subagents) stays keyed to the project path;
  • Bash receives PI_PORT (first port of a 10-port block in 41000–48999; a block is assigned only when all ten ports are free), PI_PROJECT_ROOT, and PI_WORKTREE_ROOT;
  • the optional per-project setup command runs once in each new worktree, with the same variables, in the user's default command shell (defaultCommandShell, otherwise the platform default: Windows PowerShell on Windows, bash elsewhere). It is not translated between dialects, so on Windows it must use PowerShell syntax ($env:PI_PORT, ; rather than && on Windows PowerShell 5.1) unless a POSIX shell such as Git Bash is the configured default;
  • git hooks and fsmonitor are disabled for host-run git commands in the worktree, and removal never follows links out of it;
  • the registered worktree folder must be a real directory: if it (or a directory down to the project subdirectory) was replaced by a symbolic link or a Windows junction, it is treated as missing (WORKTREE_MISSING) and is never used as a tool root, merged, or synced, and deleting the session removes only the link, never its target.

The host's workspace diff collector applies the same Git hardening to every repository it inspects, including non-isolated workspaces. Each host-started Git command disables repository-configured hooks and core.fsmonitor, and removes environment variables that can redirect Git to another repository or inject command-scope configuration. Diff invocations also disable external diff and text-conversion helpers.

Merging into the project is an explicit user action (preview, then merge with the preview token); it syncs the project branch into the worktree and only fast-forwards the project, so conflicts stay in the worktree. Deleting an isolated session discards its worktree and branch.

5. Bash Rules ​

Host execution baseline:

  • A project-bound session workspace is required
  • Default cwd = the originating session's workspaceRoot
  • Confirmation required by default
  • Set a mandatory 60s timeout; accept a 1s–21,600s override (D329)
  • A timeout returns TOOL_TIMEOUT with the effective timeoutMs budget and guidance to raise that budget or split the command
  • Stream stdout and stderr separately, then return bounded final output
  • Truncate large output without mixing the two streams
  • No interactive TTY (MVP)
  • A command that exits non-zero returns ok: false, isError: true, and errorCode: TOOL_FAILED while preserving its exitCode, stdout, and stderr in content so the agent can diagnose the command without blindly retrying.
  • runInBackground: true detaches the command from the turn: the call returns backgroundTaskId as soon as the shell starts, no timeout applies, and the process survives tools.abort and agent.abort. The host owns the process tree, reports it through the composer's shared container, and stops it on request or on app shutdown. Each stream keeps its last 256KB.
  • Background tasks belong to the session that started them. The session id is fixed at spawn and never changes: switching, renaming, archiving, or restoring a session does not re-home its tasks. A session runs at most 10 concurrent background tasks; the refusal names that per-session limit. A separate process-wide ceiling guards the host against many sessions adding up, and it is reported as its own condition so the two causes stay distinguishable. A spawn is refused when the session no longer exists, so a task can never start in a session that would not be able to show it.
  • Background output is drained before a terminal state is published, including output queued when the process exits or is stopped. UTF-8 characters split across pipe reads remain intact. Shutdown signals do not acquire output locks.
  • Todo and background tasks share one container in the Composer, above the Plan/Ask decision surfaces and the input. The container has a single header carrying the todo progress summary, the running-task count, the tab list, and the disclosure control; collapsing hides only the panels, so the tabs stay reachable while collapsed. Tabs appear only when both sides have content: with one side populated, that panel renders on its own and the tab list is not rendered at all, and a container with nothing to show renders nothing. The panel scrolls within min(200px, 24vh) so additional rows do not displace the decision surfaces. Existing approval and Ask input gating is unchanged.
  • The displayed tab is per-session view state. It remembers the user's own choice; a tab the container picked on its own never overwrites that choice. A remembered tab whose side has become empty falls back to the other side. The container gates on an active session that allows local tools, the same condition the todo dock already used.
  • A newly started task never steals the tab. While the tasks panel is not the one on screen, new tasks raise a count badge on its tab; entering that tab clears the badge. Clicking a tab while collapsed also expands the container, because otherwise the click would appear to do nothing.
  • A finished background task is not permanent furniture. Its row lives for a 10-second grace window after the renderer observed the task end, so the exit code stays readable, then it leaves the container. A task that had already finished when the renderer first loaded is registry history and never appears. An open output preview pins its row so a log being read cannot vanish underneath it.
  • Removal is a view action with a live-task guard. Only a terminal task (exited, failed, stopped) can be removed; a running task — including one already sent a stop signal, which stays running until the supervisor settles it — refuses removal in the state layer, not merely by hiding the control. The host task, its output, and its id are untouched, so the record stays readable by id until the host's own retention drops it. Removal is remembered per session and is not persisted.
  • Deleting a session stops its running background tasks. Archiving does not: archiving is a reversible marker, so killing its processes would be a destructive side effect. Deletion signals stop and returns — the deletion does not wait for a task to settle and does not fail when a task cannot be killed. A deleted session's stopped tasks appear in no container, because the session that would have showed them is gone.
  • Only one output preview is open at a time. While its task runs, the Renderer refreshes that preview at one-second intervals after the previous read settles; collapsed outputs are not fetched. The preview shows at most 8,000 stdout characters and 4,000 stderr characters. Late reads from a previous selection are ignored. Read/stop/remove failures are visible and stopping can be retried.

Shell catalog (D190) exposes the stable IDs windows-powershell, windows-pwsh, cmd, git-bash, and bash where supported by the platform. The host persists defaultCommandShell; if that persisted choice later becomes unavailable, the effective catalog selection intentionally falls back to the first available platform shell. A turn pins the effective shell ID and dialect. Bash remains the tool/protocol name, and the request carries the pinned shell ID separately. Host-core resolves the entry again before spawn and rejects a changed ID/dialect with COMMAND_SHELL_CHANGED; settings writes reject unavailable or wrong-platform IDs with COMMAND_SHELL_INVALID. No arbitrary executable path or executable path hash is accepted as shell identity.

  1. PI_DESKTOP_BASH env override (path to a bash executable)
  2. Unix: well-known locations (/bin/bash, /usr/bin/bash, /usr/local/bin/bash, Homebrew), then PATH
  3. Windows: bash.exe from Git for Windows — derived from the git on PATH, then standard install dirs, then PATH excluding the WSL launcher in System32
  • Unix invokes bash -lc (login shell keeps profile PATH for Finder/Dock launches); Windows invokes bash -c with CREATE_NO_WINDOW. The hidden host-core runner that owns the command tree is created with CREATE_NO_WINDOW and, only when the job that contains the host grants JOB_OBJECT_LIMIT_BREAKAWAY_OK, with CREATE_BREAKAWAY_FROM_JOB; it is then assigned to its own kill-on-close Job Object. Requesting the breakaway from the host's own job keeps a launcher or policy Job around the Desktop process from becoming the runner's termination scope. When that job withholds JOB_OBJECT_LIMIT_BREAKAWAY_OK, the flag is omitted and the runner joins the host-owned job as a nested job, because CreateProcess fails with ERROR_ACCESS_DENIED for a breakaway the job forbids; the shell itself stays inside the host-owned job either way, so timeout and abort still terminate the complete command tree.
  • On Unix, the Bash tool additionally probes the user's login shell for its PATH — $SHELL (fallback /bin/zsh → /bin/bash → /bin/sh) with -lic 'printf %s "$PATH"', 5s-bounded, cached per process — and injects it into every subprocess. bash -lc alone sources only the bash profile; on macOS the default shell is zsh, so nvm/pnpm/Homebrew initialized in ~/.zshrc / ~/.zprofile would otherwise be invisible to agent commands. The probe is best-effort: missing shell, non-zero exit, or timeout fall back to the host PATH unchanged. Agent commands stay POSIX bash (D181 / ADR 0045). Electron main applies the same lookup to stdio MCP spawn (uvx/npx from the market) so a Finder/Dock launch can find them (D600 / issue #571).
  • No bash bundled in the installer: Git for Windows is the Windows prerequisite (the app requires git anyway)
  • Resolution failure returns stable SHELL_NOT_FOUND with install guidance
  • Windows PowerShell and cmd use their native non-interactive invocation.
  • PowerShell output passes through PowerShell's own formatter, so objects (tables, lists, hashtables, Measure-Object results) reach the agent as a console shows them, not as type names. Stderr lines from native commands are forwarded to stderr and do not fail the call by themselves: the call reports the native exit code. Any other PowerShell error fails the call with exit code 1.
  • PowerShell 7 resolves pwsh.exe from %ProgramFiles%\PowerShell\7 (or ProgramW6432 when the host process is 32-bit), then PATH, which covers machine-scope, Store, user-scope, and portable installs. The PATH probe treats Microsoft Store / MSIX app-execution aliases under WindowsApps (IO_REPARSE_TAG_APPEXECLINK) as present executables, because winget's default PowerShell 7 layout is an msixbundle that does not create %ProgramFiles%\PowerShell\7. It shares the Windows PowerShell 5.1 invocation contract and is never selected implicitly, so it cannot change an existing user's default shell. Resolution failure returns SHELL_NOT_FOUND naming the locations that were searched.
  • Git Bash uses the discovered Git for Windows executable.
  • Unix Bash uses an approved system Bash entry.
  • User abort and timeout terminate the complete process tree before returning.

Initial denylist (extensible):

  • Directly reading/writing sensitive paths outside the workspace
  • Destructive operations without confirmation (policy governed by the permission layer)

6. Permission Model ​

Risk Levels ​

riskExampleDefault policy
lowRead/Glob/Grep inside the session rootsAuto-allow
mediumlow-risk network/metadataConfirm or allow by policy
highWrite/Edit/BashConfirm by default

Tools from user-configured MCP servers (mcp_<serverId>_<tool>) are classified medium, the same as a plugin tool without a valid declared risk. A risk level self-declared by an MCP server is not trusted, unlike the risk in a plugin manifest the user accepted. Under ask and accept-edits an MCP tool call shows an approval card with reason "MCP server tool requires approval"; an allow-session grant suppresses further prompts for that tool name in that session (grants are in-memory only). auto auto-allows it, and the Plan/Goal contract-mode hard deny still applies (D640, ADR mcp-tool-approval-risk).

Decision Types ​

  • allow-once
  • allow-session
  • deny

May be added later:

  • allow-always-for-tool
  • allow-always-for-command-pattern

Permission Modes (D115/D132) ​

How high-risk tool calls get approved is governed by a permission mode:

ModeWrite/EditBash / plugin tools
ask (default)confirmconfirm
accept-editsauto-allowconfirm
autoauto-allowauto-allow

An explicit outside-workspace path is an exception to the low-risk row: it is auto-allowed only in auto; ask and accept-edits both confirm it.

Resolution order per tool call (host-core tools.execute):

  1. Session's persisted permission_mode, unless it is inherit
  2. Global defaultPermissionMode from app settings (ask / accept-edits / auto)
  3. ask

Rules:

  • The session value is stored in sessions.permission_mode (inherit | ask | accept-edits | auto, default inherit, schema v5) and set via session.configure permissionMode.
  • Plan's hard deny wins over every permission mode for Write/Edit and plugin tools that lack planSafeActions. auto cannot re-enable a hidden or denied tool.
  • Low-risk tools (Read/Glob/Grep) inside the session roots auto-allow in every mode, as before.
  • BrowserPreview is an explicit read-only UI inspection capability and is available in both operating modes.
  • Plan retains the permission-mode selector. Bash is confirmed under ask and accept-edits, and is auto-allowed under auto; therefore Plan is planning intent, not a strict read-only security profile.
  • allow-session grants continue to work under ask and stay scoped to the session; under accept-edits/auto they are simply never needed.
  • Scratch-directory writes (D114) stay prompt-free in every mode.
  • UI: Settings → segmented global default; composer shows a per-session chip in Agent, Plan, and Goal whose menu offers the three effective modes without a separate global-default/inherit entry. The chip and selected menu item display the effective mode; choosing an item stores that explicit session override. Existing inherited sessions continue to resolve through the global setting until the user chooses a mode.
  • Enforcement lives in host-core only; the sidecar/model is never told the mode and cannot influence it.

7. Permission Flow ​

text
tool call
 → policy.evaluate()
 → allow? execute
 → need confirm? push to UI
 → deny? return tool error result

Permission confirmation:

  • The local approval remains pending until Allow once, Allow for session, Deny, cancellation, or host/process shutdown. Tool-specific execution timeouts still apply after approval.

8. Tool Result Visibility to the Model ​

  • Success result: given to the model
  • Failure result: given to the model (with error info)
  • User denial: give the model an explicit "user denied permission"
  • Sensitive info: redact before persisting/displaying

9. Auditing ​

Each tool call records:

  • sessionId
  • turnId
  • toolCallId
  • toolName
  • args hash / preview
  • externalPathPermission classification when an explicit path is present
  • decision
  • duration
  • success / error code

MVP may start by writing to SQLite or a log file.

Audit rows may retain the existing segmented timing fields for forensic inspection: prompted (whether a permission card was shown), permissionWaitMs, durationMs (the tool body), overheadMs (host bookkeeping), and totalMs. Denied calls carry the same fields with a zero tool body. These are structured audit fields; they are not emitted as process-log timing lines. See 09. Logging and Observability for the current key-log policy.

10. Operating-mode matrix ​

ModeRead/Glob/GrepBrowserPreviewWrite/EditBashPlugins
Agentallowallowpermission policypermission policyregistered risk policy
Planallowallowdenyask/accept-edits: confirm; auto: allowplan-safe actions only
Goalallowallowdenyask/accept-edits: confirm; auto: allowplan-safe actions only

Notes ​

  • Plan and Goal hard-deny Write/Edit and plugin tools without planSafeActions before permission UI; a direct host call cannot bypass the matrix. Plugin tools that declare a non-empty list are admitted; the runner still rejects any action outside that list (ADR 0211).
  • Agent mode uses permission cards or the selected automatic policy for Write/Edit/Bash and registered plugin tools.
  • Plan and Goal Bash may mutate workspace or scratch state when the user selected Auto; the UI must make that tradeoff visible.
  • allow-session is remembered per toolName for the active session only
  • Session grants follow sessionId across project-tab switches and are never inherited by another session or Temporary conversation

10.1 Plan and Goal control tools ​

new_context is available in every mode and needs no confirmation: it only asks the runtime to compact at the next turn boundary, which the host would do on its own once the hard budget is reached (see 02-agent-runtime §5.1). A submit tool is available only in its own contract mode and must be the only tool call in its assistant batch. It preserves the exact Markdown bytes in a new unique artifact under the kind's directory (.pi/plan/*.md for SubmitPlan, .pi/goal/*.md for SubmitGoal) through host-core before creating one pending approval. EnterPlanMode and EnterGoalMode are available only in Agent, and each must be the only tool call in its batch. The host validates the durable mode, the proposal kind, and the active-turn/configuration boundary before any transition; the visible tool list is guidance, not the security boundary.

10.2 Delegation and subagent tool scope (D201, ADR 0062) ​

Task is available in Agent mode only, and only when the session has at least one subagent definition. Plan and Goal are read-only contract negotiations, so a delegate with Bash, Edit or Write would drive straight through them.

A definition declares the tools its delegate may call. By default those names are drawn only from the seven working tools Read, Glob, Grep, BrowserPreview, Bash, Edit and Write. A definition that declares none gets Read, Glob, Grep; tools: "*" means all seven working tools. An unrecognized name — including the withdrawn A2A and Peer tools (D326 / ADR 0165) — is dropped with a parse warning.

A document may opt into the parent session's live tool catalog with tools: inherit or tools: [inherit, Bash] (ADR 0246 / D415). At Task spawn the runtime unions toolCatalog keys (including deferred plugin/MCP tools) with any assignable extras, then drops Task / TaskWait / TaskList / TaskStop, EnterPlanMode / EnterGoalMode, asktool, new_context, and ToolSearch. Builtins do not opt in. inherit is visible in the Markdown and in Settings; host-core keeps the token so an inherit-only document still loads. Plugin tools, Skill, and MCP tools are therefore available to a delegate only through this opt-in, never by putting those names on the assignable whitelist.

Without tools: inherit, a delegate's available tools are its definition's, never its session's: it cannot gain a tool because the parent has it, and a session cannot lend mutation rights to a read-only delegate. Delegate calls are built by the session runtime and go through the same tools.execute path, so path rules (§4), Bash rules (§5), permission modes (§6), the operating-mode matrix (§10) and auditing (§9) apply unchanged — evaluated against the owning session.

A builtin or user definition may additionally declare permission: inherit | ask | accept-edits | auto (ADR 0089, default inherit). With the default inherit (including all builtins), the sidecar attaches no override and the delegate uses the session's effective permission mode. Thus a parent in auto allows the delegate's explicit external paths without a second authorization card. A project definition may not declare a scope: it arrives with the repository, so its declaration is dropped at parse time with a warning and its delegates resolve under the session's effective mode — cloning a repository never grants it a permission upgrade. When an eligible builtin or user explicitly declares a non-inherit scope, the sidecar attaches it to the delegate's tools.execute calls and host-core resolves each call under that mode instead of the session's effective permission mode. The scope is a permission-mode override only: the contract modes' hard deny and the external-path gate (§4.1) stay in force. accept-edits therefore auto-allows Write/Edit inside the workspace and scratch roots, while external paths and other tools retain their normal approval behavior.

Permission requests from a delegate carry the asking delegate's name, so the card can say which delegate wants the call (see 04-ux/03-permission-ux.md §6a). Session-scoped allow-session grants are still per toolName and per session: one delegate's approval of Bash applies to the whole session, including the parent and other delegates.

Inside an isolated session (§4e), a delegate whose tools include Write or Edit runs in its own worktree, branched from the session worktree after the session's pending edits are checkpointed; its tools.execute calls carry the delegation chain id as delegationId, which re-roots path rules and Bash to that worktree. A completed run is merged back with the same algorithm as "Merge into project"; a conflict, failure, or stop keeps the delegate branch and the report tells the parent to resume it. Read-only and Bash-only delegates, and every delegate of a non-isolated session, share the owning session's root unchanged.

11. Plugin Tools ​

Plugins can contribute tools via agentTools. Agent mode sees every registered plugin tool. Plan and Goal see only tools whose planSafeActions list is non-empty (ADR 0211 / D384):

  1. manifest declaration
  2. user grants agent.tool.register
  3. PluginManager registers them into the ToolHost
  4. execution goes through the unified permission/audit/timeout wrapper

A plugin tool without planSafeActions is hidden from the model in Plan and Goal. A direct attempt returns PLUGIN_DISABLED_IN_PLAN — the _IN_PLAN codes are shared by both contract modes rather than duplicated per kind — and is audited as a contract-mode policy denial. When the list is present, host-core admits the tool and the plugin-runtime rejects any action outside the list with PERMISSION_DENIED. Missing or invalid plugin risk defaults to medium for Agent and never grants contract-mode access by itself.

Naming:

  • Internal full name: plugin.<pluginId>.<toolName>
  • Name exposed to the model: forced prefix plugin_<pluginIdSafe>_<toolName> (D015) to avoid conflicts

12. Future Extensions ​

  • MCP tools
  • tool group toggles
  • command allowlist / denylist
  • dry-run mode
  • apply patches after preview

Image generation and editing ​

GenerateImages is a high-risk Agent-only capability, authorized by host-core before the trusted desktop executes the request. Plan/Goal remain denied even under Auto. See image generation for cancellation, limits and result semantics.

Local-first · Model-agnostic · Plugin-powered. nib.am