Readonly Mode — Design Plan
Goal and Scope
Add a repo/file mutation protection mode to the pi-agenticoding extension.
In this document, readonly means:
- block parent
write
- block parent
edit
- block destructive
bash both for parent and spawned children
- remove
write and edit from future spawned children
It does not mean absolute immutability. This design does not block:
ledger_add
handoff
- persistence via
appendEntry()
- other non-bash mutating extension behavior unless explicitly blocked later
Current Repo Baseline
This repo does not currently implement readonly mode.
Current relevant behavior
index.ts currently wires:
before_agent_start → injects CONTEXT_PRIMER + live ledger listing
context → injects watchdog nudges when context usage is high
session_start → resets all state on /new
turn_end → updates TUI indicators
It does not currently register:
/readonly
- readonly shortcut
- readonly CLI flag
- readonly
tool_call blocking
- readonly
session_tree handling
spawn/index.ts currently:
- inherits the parent's active built-in tools
- excludes
spawn and handoff
- adds child ledger tools
- passes the child task as
session.prompt(fullPrompt)
- does not currently filter
write / edit / bash
state.ts currently has no readonly field.
ledger/rehydration.ts already calls pi.setActiveTools(...) to ensure ledger_get / ledger_list are active after session start.
Pi-Native Options and Chosen Deviation
Pi's documented read-only pattern is to switch the active tool set directly, e.g.:
pi.setActiveTools(["read", "bash"]);
That is the normal pi way, and examples/extensions/plan-mode/index.ts follows that pattern.
This design intentionally does not use parent setActiveTools() for readonly toggling.
Why deviate?
Goal: keep the parent's active tool set stable during readonly toggles and enforce readonly through runtime policy instead.
Tradeoff
| Option |
Pros |
Cons |
Parent setActiveTools() |
Canonical pi pattern; prompt/tool list fully matches reality |
Mutates parent active tool set on toggle |
| Runtime blocking + child filtering |
Avoids parent tool-set switching for this feature; child control stays local to spawn |
Parent may still advertise blocked tools; more enforcement logic |
Important: this repo already uses pi.setActiveTools() elsewhere during ledger rehydration. So the design goal here is narrower:
Readonly toggling itself should not rely on parent setActiveTools().
Proposed Architecture
Integrate readonly mode directly into the existing extension:
index.ts
spawn/index.ts
state.ts
tui.ts
- new
readonly-bash.ts
No standalone extension.
State
Location: state.ts
Add:
export interface AgenticodingState {
// ... existing fields ...
readonlyEnabled: boolean;
readonlyNudgePending: boolean; // true after toggle-off, cleared after one-shot OFF nudge
}
Initialize both in createState(). Clear both in resetState().
Enforcement Layers
Layer 1 — Parent awareness via nudges
Both ON and OFF nudges use the context hook — ephemeral, one-shot.
Tree tracking uses appendEntry so the OFF nudge can detect prior ON nudges.
ON nudge (one-shot, re-injected after compaction/handoff)
// On toggle-on:
state.readonlyEnabled = true;
state.readonlyNudgePending = true;
pi.appendEntry("readonly-nudge", { direction: "on" });
pi.on("context", async (event, ctx) => {
if (state.readonlyEnabled && state.readonlyNudgePending) {
state.readonlyNudgePending = false;
return {
message: "Readonly mode is active. Do not call write or edit. " +
"Destructive bash operations will be blocked. Use /readonly to disable.",
};
}
});
- Fires once after toggle-on, then not again.
- No re-injection needed after compaction/handoff — in-memory state survives, and the handoff brief carries the readonly-awareness forward.
OFF nudge (one-shot, only if ON nudge exists on current branch)
pi.on("context", async (event, ctx) => {
if (state.readonlyNudgePending) {
state.readonlyNudgePending = false;
// Check if an ON nudge exists on the current branch
const branch = ctx.getBranch();
const hasOnNudge = branch.some(
(e) => e.customType === "agenticoding-readonly"
);
if (hasOnNudge) {
return {
message: "Readonly mode has been turned off. You may now use write, edit, and bash freely.",
};
}
}
});
- Context hook messages are ephemeral — not persisted in tree.
readonlyNudgePending flag set on toggle-off, cleared after one-shot delivery.
readonlyNudgePending set on both toggle-on and toggle-off, cleared after respective one-shot delivery.
Layer 2 — Parent runtime enforcement via tool_call
Add a tool_call handler in index.ts.
pi.on("tool_call", async (event, ctx) => {
if (!state.readonlyEnabled) return;
if (event.toolName === "write" || event.toolName === "edit") {
return {
block: true,
reason: "Readonly mode: write/edit disabled. Use /readonly to disable.",
};
}
if (event.toolName === "bash") {
const cmd = event.input.command as string;
if (!isSafeReadonlyCommand(cmd)) {
return {
block: true,
reason:
"Readonly mode: dangerous command blocked. Use /readonly to disable.\n" +
`Command: ${cmd}`,
};
}
}
});
This is the main enforcement layer and matches pi's documented use of tool_call for blocking.
Layer 3 — Child enforcement at spawn boundary
Filter child tools inside executeSpawn() in spawn/index.ts, after buildChildToolNames().
const childToolNames = buildChildToolNames(parentToolNames, childTools, pi.getAllTools());
const filteredChildToolNames = state.readonlyEnabled
? childToolNames.filter((name) => name !== "write" && name !== "edit")
: childToolNames;
Pass filteredChildToolNames into createAgentSession({ tools: filteredChildToolNames, ... }).
This is the right integration point because spawn/index.ts already owns child tool construction.
Layer 4 — Child awareness in spawn prompt
When readonly is enabled, append a readonly notice to the child task prompt.
const readonlyNotice = state.readonlyEnabled
? "\n\nReadonly restrictions apply in this child. Do not attempt mutating or destructive bash operations.\n"
: "";
Then append that notice to the child prompt text.
Bash Safety
Create a small helper module, e.g. readonly-bash.ts.
Source inspiration: examples/extensions/plan-mode/utils.ts.
Policy
Use destructive blacklist approach. Everything else is explicitly allowed to allow full integration with the system, debugging, browser automation, etc.
Blocked patterns
Block destructive commands such as:
- file mutation:
rm, rmdir, mv, cp, mkdir, touch, chmod, chown, ln, tee, truncate, dd, shred
- privilege/process mutation:
sudo, su, kill, pkill, killall
- redirects:
>, >>
- package mutation: install/remove/update flows
- editors:
vim, nano, code, etc.
Git command policy
Use allowlist for git commands (not blacklist). Only known-immutable commands and safe subcommands pass.
- Always immutable (pass):
diff, log, show, status, blame, grep, ls-files, ls-tree, merge-tree, format-patch, rev-parse, rev-list, cat-file, for-each-ref, merge-base, fsck
- Always mutable (block):
add, commit, push, pull, merge, rebase, reset, revert, cherry-pick, clean, rm, mv, restore, switch, checkout, fetch, stash (except list/show)
- Mixed (inspect subcommand):
branch, tag, stash, remote, config, reflog, notes, worktree, submodule, apply, bisect — allow read subcommands (e.g. branch --list, tag --list, stash list, remote -v), block write subcommands
Full classification table lives in readonly-bash.ts comments.
Note: Temp-dir-bounded writes deferred to v2.
Visual Indicator
Extend updateIndicators() in tui.ts.
ctx.ui.setStatus(
"agenticoding-readonly",
state.readonlyEnabled ? theme.fg("warning", "🔒 readonly") : "",
);
Toggle Controls
Register in index.ts:
| Trigger |
Idle-gated? |
Implementation |
ctrl+shift+r |
Yes, via ctx.isIdle() guard |
registerShortcut |
/readonly |
Yes in practice; slash commands do not fire mid-stream |
registerCommand |
pi --readonly |
Startup only |
registerFlag |
Flag usage
pi.registerFlag("readonly", {
description: "Start in readonly mode",
type: "boolean",
default: false,
});
Restore with pi.getFlag("readonly") during session_start.
Session Lifecycle and Persistence
Pi session replacement behavior matters here.
New runtime boundaries
/new, /resume, /fork, and /clone create a new extension runtime.
That means in-memory readonly state does not survive those boundaries.
So readonly must be persisted and rehydrated.
Persistence leverages pi's structured session history via appendEntry to record readonly toggles, then recomputes state from branch scanning so storage cannot diverge.
Same runtime boundaries
Handoff/compaction does not replace the extension runtime.
So in-memory readonly state can survive handoff without rehydration.
Persistence flow
On toggle:
state.readonlyEnabled = enabled;
pi.appendEntry("readonly", { enabled });
On session_start for non-new sessions:
- scan current branch newest-to-oldest
- find the latest
customType === "readonly"
- restore
state.readonlyEnabled
- if CLI
--readonly is set, it overrides persisted branch state
On session_tree:
- re-scan branch and restore
state.readonlyEnabled
On /new:
resetState() clears readonlyEnabled
Repo-Specific Integration Points
state.ts
- add
readonlyEnabled: boolean
- add
readonlyNudgePending: boolean
- initialize both in
createState()
- clear both in
resetState()
index.ts
- register
/readonly command
- register
ctrl+shift+r shortcut
- register
--readonly flag
- extend existing
before_agent_start
- add readonly
tool_call blocking
- extend existing
session_start rehydration flow
- add
session_tree rehydration
spawn/index.ts
- filter child
write / edit from child tool names when readonly is enabled
- append readonly notice to child prompt
- revise misleading child-authority wording when readonly is enabled
tui.ts
- add readonly footer badge
readonly-bash.ts
- expose readonly bash classifier, e.g.
isSafeReadonlyCommand()
Files Changed
| File |
Change |
state.ts |
add readonlyEnabled, initialize, reset |
index.ts |
readonly command/shortcut/flag, prompt notice, tool blocking, rehydration |
spawn/index.ts |
child tool filtering + prompt notice + wording fix |
tui.ts |
readonly status indicator |
readonly-bash.ts |
bash safety classifier |
Non-Goals
This design does not currently attempt to:
- hide blocked parent tools from the parent prompt/tool list
- make the entire session immutable
- retroactively strip tools from already-running children
- create an approval workflow for specific blocked writes
Edge Cases
Toggle mid-turn
/readonly should not fire mid-stream. Shortcut should guard on ctx.isIdle().
That is enough for v1.
Parent prompt still advertises blocked tools
Because parent readonly uses runtime blocking instead of setActiveTools(), the parent may still see write / edit in its active tool set.
This is intentional in this design, but it is also the main UX downside versus canonical pi tool switching.
Nudge design (resolved — see Layer 1):
- Both ON and OFF nudges are one-shot via
context hook (ephemeral).
- ON: fires once after toggle-on. No re-injection after compaction — in-memory state survives and handoff brief carries awareness.
- OFF: fires once after toggle-off, only if an
readonly-nudge { direction: "on" } entry exists on the current branch.
Readonly Mode — Design Plan
Goal and Scope
Add a repo/file mutation protection mode to the pi-agenticoding extension.
In this document, readonly means:
writeeditbashboth for parent and spawned childrenwriteandeditfrom future spawned childrenIt does not mean absolute immutability. This design does not block:
ledger_addhandoffappendEntry()Current Repo Baseline
This repo does not currently implement readonly mode.
Current relevant behavior
index.tscurrently wires:before_agent_start→ injectsCONTEXT_PRIMER+ live ledger listingcontext→ injects watchdog nudges when context usage is highsession_start→ resets all state on/newturn_end→ updates TUI indicatorsIt does not currently register:
/readonlytool_callblockingsession_treehandlingspawn/index.tscurrently:spawnandhandoffsession.prompt(fullPrompt)write/edit/bashstate.tscurrently has no readonly field.ledger/rehydration.tsalready callspi.setActiveTools(...)to ensureledger_get/ledger_listare active after session start.Pi-Native Options and Chosen Deviation
Pi's documented read-only pattern is to switch the active tool set directly, e.g.:
That is the normal pi way, and
examples/extensions/plan-mode/index.tsfollows that pattern.This design intentionally does not use parent
setActiveTools()for readonly toggling.Why deviate?
Goal: keep the parent's active tool set stable during readonly toggles and enforce readonly through runtime policy instead.
Tradeoff
setActiveTools()Important: this repo already uses
pi.setActiveTools()elsewhere during ledger rehydration. So the design goal here is narrower:Proposed Architecture
Integrate readonly mode directly into the existing extension:
index.tsspawn/index.tsstate.tstui.tsreadonly-bash.tsNo standalone extension.
State
Location:
state.tsAdd:
Initialize both in
createState(). Clear both inresetState().Enforcement Layers
Layer 1 — Parent awareness via nudges
Both ON and OFF nudges use the
contexthook — ephemeral, one-shot.Tree tracking uses
appendEntryso the OFF nudge can detect prior ON nudges.ON nudge (one-shot, re-injected after compaction/handoff)
OFF nudge (one-shot, only if ON nudge exists on current branch)
readonlyNudgePendingflag set on toggle-off, cleared after one-shot delivery.readonlyNudgePendingset on both toggle-on and toggle-off, cleared after respective one-shot delivery.Layer 2 — Parent runtime enforcement via
tool_callAdd a
tool_callhandler inindex.ts.This is the main enforcement layer and matches pi's documented use of
tool_callfor blocking.Layer 3 — Child enforcement at spawn boundary
Filter child tools inside
executeSpawn()inspawn/index.ts, afterbuildChildToolNames().Pass
filteredChildToolNamesintocreateAgentSession({ tools: filteredChildToolNames, ... }).This is the right integration point because
spawn/index.tsalready owns child tool construction.Layer 4 — Child awareness in spawn prompt
When readonly is enabled, append a readonly notice to the child task prompt.
Then append that notice to the child prompt text.
Bash Safety
Create a small helper module, e.g.
readonly-bash.ts.Source inspiration:
examples/extensions/plan-mode/utils.ts.Policy
Use destructive blacklist approach. Everything else is explicitly allowed to allow full integration with the system, debugging, browser automation, etc.
Blocked patterns
Block destructive commands such as:
rm,rmdir,mv,cp,mkdir,touch,chmod,chown,ln,tee,truncate,dd,shredsudo,su,kill,pkill,killall>,>>vim,nano,code, etc.Git command policy
Use allowlist for git commands (not blacklist). Only known-immutable commands and safe subcommands pass.
diff,log,show,status,blame,grep,ls-files,ls-tree,merge-tree,format-patch,rev-parse,rev-list,cat-file,for-each-ref,merge-base,fsckadd,commit,push,pull,merge,rebase,reset,revert,cherry-pick,clean,rm,mv,restore,switch,checkout,fetch,stash(exceptlist/show)branch,tag,stash,remote,config,reflog,notes,worktree,submodule,apply,bisect— allow read subcommands (e.g.branch --list,tag --list,stash list,remote -v), block write subcommandsFull classification table lives in
readonly-bash.tscomments.Note: Temp-dir-bounded writes deferred to v2.
Visual Indicator
Extend
updateIndicators()intui.ts.Toggle Controls
Register in
index.ts:ctrl+shift+rctx.isIdle()guardregisterShortcut/readonlyregisterCommandpi --readonlyregisterFlagFlag usage
Restore with
pi.getFlag("readonly")duringsession_start.Session Lifecycle and Persistence
Pi session replacement behavior matters here.
New runtime boundaries
/new,/resume,/fork, and/clonecreate a new extension runtime.That means in-memory readonly state does not survive those boundaries.
So readonly must be persisted and rehydrated.
Persistence leverages pi's structured session history via
appendEntryto record readonly toggles, then recomputes state from branch scanning so storage cannot diverge.Same runtime boundaries
Handoff/compaction does not replace the extension runtime.
So in-memory readonly state can survive handoff without rehydration.
Persistence flow
On toggle:
On
session_startfor non-newsessions:customType === "readonly"state.readonlyEnabled--readonlyis set, it overrides persisted branch stateOn
session_tree:state.readonlyEnabledOn
/new:resetState()clearsreadonlyEnabledRepo-Specific Integration Points
state.tsreadonlyEnabled: booleanreadonlyNudgePending: booleancreateState()resetState()index.ts/readonlycommandctrl+shift+rshortcut--readonlyflagbefore_agent_starttool_callblockingsession_startrehydration flowsession_treerehydrationspawn/index.tswrite/editfrom child tool names when readonly is enabledtui.tsreadonly-bash.tsisSafeReadonlyCommand()Files Changed
state.tsreadonlyEnabled, initialize, resetindex.tsspawn/index.tstui.tsreadonly-bash.tsNon-Goals
This design does not currently attempt to:
Edge Cases
Toggle mid-turn
/readonlyshould not fire mid-stream. Shortcut should guard onctx.isIdle().That is enough for v1.
Parent prompt still advertises blocked tools
Because parent readonly uses runtime blocking instead of
setActiveTools(), the parent may still seewrite/editin its active tool set.This is intentional in this design, but it is also the main UX downside versus canonical pi tool switching.
Nudge design (resolved — see Layer 1):
contexthook (ephemeral).readonly-nudge{ direction: "on" }entry exists on the current branch.