Skip to content

feat: Readonly Mode #2

@ofriw

Description

@ofriw

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions