Skip to content

feat: phase 1 contract work that needs no decision, and the phase 0 gates (plan r9) - #10

Merged
zy1233 merged 27 commits into
mainfrom
codex/phase1-contract-docs
Oct 10, 2026
Merged

zy1233 merged 27 commits into
mainfrom
codex/phase1-contract-docs

Conversation

@zy1233

@zy1233 zy1233 commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Stacked on #9 (base codex/linux-wsl-r8). It adds sixteen commits on top of d84d98a: the three phase 0 gates of docs/PLAN/PLAN-RUST-AGENT-RUNTIME-REMOVAL.md that were still open after r8 (0.3 on the Rust side, 0.7, 0.8), and the parts of phase 1 that need no decision. Section 10.11 and appendix A of the plan have the full record.

No behaviour-changing decision is made here. Where a decision is needed (the permission-mode mapping, the --session-id contract, what happens to zypi export, ...) the plan writes a proposal and says what it would cost. Section 10.11 ends with twelve of them, each with my preference; none is implemented.

What the branch does:

  • 0.3, e2e against the real agent. python_agent_e2e.rs: 10 tests drive the pager's ACP client against the real Python agent, and tui-ci.yml installs the agent first and sets PI_E2E_REQUIRED=1 so a missing Python fails instead of passing silently. Its first run found two product bugs, both fixed here: the reason an agent failed to start was swallowed into channel closed, and connect() hung when the agent exited at once.
  • 0.7, can the pager lose its own disk reads (ADR1)? Measured: with 1,000 sessions on Windows the first page of session/list went from 2.0 s to 0.11 s and session/load from 2.0 s to 0.12 s (a header is read without reading the file, and the repo lists a page at a time). ADR1's override condition did not trigger. The plan inventories the pager's 13 disk-read call sites in 8 files, adds a 13-case PTY matrix of the resume flags (scripts/tui_pty/resume_matrix.py: 9 PASS / 4 GAP, the GAPs are the work ADR1 would do) and a landing design with the sandbox ordering problem and a PR slicing.
  • 0.8, capability matrix and ACP version pairing (appendix A): every row marked decided / proposed / open, the upgrade rules, and a per-method table of what each end supports.
  • 1.P1: session/list is paged with an opaque cursor, the pager's picker follows nextCursor, and a session is looked up by id without reading every file.
  • 1.P4: the pager sends no mcp_servers; the agent says which ones a client passed and that it ignored them.
  • 1.P7: the permission round trip and session/cancel are covered over real stdio.
  • Agent exits on its own: the pager says so and why, instead of a bare channel closed.
  • 1.P3 input, not a change: scripts/tui_pty/mode_probe.py records which permission modes the Python agent honours (12 scenarios on WSL2). Plan mode restricts nothing, Auto is always-approve in the agent, the settings "Default" is dropped, and of the six --permission-mode values only bypassPermissions does anything.

Decisions that were open

Plan section 10.11, last list. The three that unblock the most, and what became of them:

  1. Start ADR1 with PR 1: the Python side only (_meta.sessionId honoured by session/new, the session's cwd in the session/load response). No TUI change, least risk. Done in feat(agent): honour _meta.sessionId on session/new, say where a session works (ADR1 PR 1, plan r10) #11.
  2. 1.P3: hide Plan, Auto and Default (Shift+Tab becomes Normal <-> Always-Approve) and accept only the --permission-mode values that do something. Today Plan gives users a false sense of safety. Done in feat(tui): offer only the permission modes the agent honours (plan 1.P3 (a), plan r10) #12.
  3. The sandbox ordering (the first of the plan's three options): drop "restore the sandbox profile a session saved", which never applies to a Python session. Still open.

Not verified

  • The Rust e2e tests and the PTY scripts ran on Linux (WSL2) only; the latency numbers on Windows measure the Python side only. Cold disk, network file systems and more than 1,000 sessions were not measured.
  • Cross-directory --resume <id> (which directory the TUI uses for @ search, diffs and git info), the settings "Default" path under a pty, and the -m / --model flag.
  • The landing design is on paper; everything uses the mock LLM.

Review guide

Commit What
28fed94 1.P7: permission round trip and cancel over real stdio
54a38fe, 1b9fb47 harness: read a header without the file; list a page at a time; find by id
e944ed5 scripts/session_list_bench.py
c1f02c8, 9f7bc35 1.P1: Python pages session/list; the pager follows nextCursor
935e4b8, 7becb49, 9798e1d 1.P4: the agent names the ignored MCP servers; the pager sends none; docs
affda9e the pager notices an agent that exits, and says why a start failed
3fc182b 0.3: python_agent_e2e.rs, tui-ci.yml, manifest
babd75f 0.7: resume_matrix.py
bee3a36 manifest: pi-pager reference_passed 5,602
679cccb, d426253 1.P3 input: mode_probe.py
eef675b docs: plan 10.11, appendix A, B.4, docs/baselines/tui.md, CHANGELOG, AGENTS files

#9 was squash-merged first and this PR's base then moved to main. The PR still lists #9's commits, and GitHub could not merge it because files that both sides changed near each other conflict. b0d001b merges main into the branch with the ours strategy: the tree of main is the tree of #9's last commit, the base of this branch, so main holds nothing the branch lacks, and the branch's tree is unchanged.

Verification

  • CI. The run on eef675b is green (TUI CI run 38061734062). The new install step in tui-ci.yml ran for the first time, python_agent_e2e's 10 tests really ran (a missing Python would fail them), and the 10 suites add up to 8,659 passed / 0 failed / 22 ignored with pi-pager at 5,602: the numbers measured locally. cargo test took 23 m 19 s (32 m 40 s on fix(tui): Linux follow-ups for the Rust agent runtime removal (plan r8, run on WSL2) #9), Gates + cargo check 7 m 9 s, Ruff 9 s, pytest on 3.11 / 3.12 / 3.13 about 2 minutes each. tui-ci.yml now also runs when pi_agent_cli/** changes. After fix(tui): Linux follow-ups for the Rust agent runtime removal (plan r8, run on WSL2) #9 was squash-merged, main was merged into this branch (b0d001b, keeping the branch's tree, which is the tree of eef675b) and CI ran again: green too (run 38074449935; cargo test 21 m 48 s, 8,659 passed again).
  • Whole manifest on WSL2 (python scripts/tui_baseline.py test, scrubbed environment, PI_E2E_PYTHON and PI_E2E_REQUIRED=1): exit 0, 10 suites, 8,659 passed, 0 failed, 22 ignored = fix(tui): Linux follow-ups for the Rust agent runtime removal (plan r8, run on WSL2) #9's CI result (8,634) + 25 new pi-pager tests (10 e2e, 8 agent-exit, 6 paging, 1 for mcp_servers). pi-pager: 5,602.
  • pytest: 2,352 passed / 35 skipped on Linux (WSL2, Python 3.12.3), 2,351 passed / 36 skipped on Windows (Python 3.12.7); scripts/tests/test_tui_pty.py with pyte: 19 passed.
  • ruff check . and ruff format --check .: clean (226 files) with the project's Ruff 0.16.10 and with CI's pinned 0.16.0.
  • resume_matrix.py: 9 PASS / 4 GAP / 0 FAIL. mode_probe.py: 12 scenarios.

zy1233 added 26 commits October 8, 2026 16:29
… keep a bad meta description from breaking the session (audit follow-up 8, F7-01, P7-17)

F7-01: <project>/.pi-python/workflows/*.py became slash commands in any project, trusted
or not, with a description of the repository's choosing, and could take the place of the
user's own saved workflow of the same name, or of /workflows. They are now one more thing
project trust covers:
- trust_fingerprint.gated_project_resources lists .pi-python/workflows (when it holds
  *.py files and is not the user's own <pi home>/workflows), so the ACP question names
  it, the saved answer is bound to its contents, and the "skipped" notice lists it.
  Headless runs and default_project_trust = "never" leave it out.
- ExtensionAPI.project_trusted (HarnessBridge.project_trusted; only an actual True counts,
  anything else is no) tells an extension whether the project is trusted. The workflow
  extension leaves the project's directory out of WorkflowStore(include_project=False)
  unless it is.
- A saved workflow can no longer take the place of /workflows or a built-in workflow
  name, trusted project or not.

P7-17: a non-string meta.description reached AvailableCommand, which refused it, and the
agent re-sends the command list with every prompt until a send succeeds, so every
session/prompt failed. Three layers, each enough on its own: WorkflowStore drops a
non-string description / when_to_use; ExtensionAPI.register_command raises TypeError;
PiAcpAgent._advertise_commands leaves out a command the schema rejects (with a warning
that names it) and sends the rest.

Docs: Phase 7 spec (3.2, 4.4, 16), both AGENTS.md (new invariant 9, project trust),
README, TUI guide, agent.example.toml, audit tracker (batch 8). The review doc of the
other session is not kept.
CHANGELOG: the [0.5.0] section is condensed (about 7,300 words down to 2,800): one entry
per change, the details stay in the specs and the audit tracker, and related entries
(project trust, DeepSeek thinking, home resolution, <git_status>, fetch_url, pi-goal-x,
sub-agent routing) are merged. Sections for 0.4.0 and earlier are untouched.

Tests: written red first (90 of the new tests failed before the change). 33 hand-made
mutants, all killed (two survived the first round; tests added for them). Windows 2151
passed / 14 skipped; WSL 3.12 2150 / 15; WSL 3.11 and 3.13 2131 / 34; ruff 0.16.10 and
0.16.0 clean.
Not changed: F7-02 to F7-09 (low impact, and documentation notes) stay open, see the
tracker. No tag, no push.
`zypi -p` runs the Python agent as a plain child on the user's terminal. The TUI's agent
learns that its client is gone from EOF on its stdin; here stdin is the terminal, so that
signal does not exist. Killing only zypi (kill -9, kill <pid>, an IDE's stop button) left the
agent and the tools it was running going on the terminal: a `sleep 300` started by the agent
was still there afterwards.

- zypi names itself to the agent in PI_AGENT_PARENT_PID (print_mode::agent_command).
- pi_agent_cli removes the variable first thing in main(), so nothing it starts inherits it.
  It is used only if the pid is this process's parent, or a process that no longer exists
  (the parent died while the agent was starting). A live process that is not the parent is a
  wrapper script in between: the watch stays off. POSIX only.
- In -p mode a task polls os.getppid() every 0.5 s; once the parent is not the one named it
  asks for the same stop as SIGTERM. The turn is cancelled and the process group of every
  running tool is reaped. -p now also stops on SIGTERM and SIGHUP, with exit status
  128 + the signal number.
- _StopRequest is the piece both paths share; serve() (the stdio agent) behaves as before.

An environment variable rather than a flag, so a zypi and a pi_agent_cli of different versions
still work together. Not PR_SET_PDEATHSIG: it is Linux-only, tied to the thread that started
the child, and after a SIGKILL it would leave the tools orphaned.

Limits: a PI_AGENT_COMMAND that points at a wrapper script keeps the watch off, so killing
only zypi still leaves that agent behind. Windows has no counterpart.

Tests: 15 pytest cases in packages/pi-agent-cli/tests/test_print_shutdown.py (POSIX only;
disabling the watch makes the two that depend on it fail) and one Rust test in pi-pager-bin.
Against a built zypi (scripts/tui_pty/print_exit.py, added later in this series), SIGTERM,
SIGKILL, SIGHUP and SIGINT sent to zypi alone leave nothing running.
With --sandbox on Linux zypi re-executes itself under bubblewrap. A plain bwrap only waits for
its child, so killing bwrap alone (kill $!, timeout, an IDE's stop button, a supervisor that
signals just the process it started) left zypi, the Python agent and the agent's running tools
on the terminal. Measured with bwrap 0.9.0 on WSL2: SIGKILL, SIGTERM and SIGHUP sent to bwrap
all left them alive.

bwrap now gets --die-with-parent: the sandboxed child is SIGKILLed when bwrap dies (and bwrap
itself when its parent dies). The agent then sees EOF on its stdin and reaps its tools on the
way out; all of them are gone within a second. It is a bwrap option, so it goes before `--`;
a test pins the position.

Trade-off: the child dies by SIGKILL, so when only the outer bwrap is signalled with SIGTERM or
SIGHUP zypi cannot restore the terminal (alternate screen, raw mode). Before, the whole tree
stayed up instead. Easy to take back: this commit is the flag and its test.
SandboxManager::apply() degrades on purpose: where the platform cannot enforce the profile (on
Linux, a kernel without Landlock) or applying it fails, it logs, returns Ok and the run goes
on with none of the profile's file or network limits. The user was never told: the status bar
label is drawn from profile_name(), which is None both for "no sandbox asked for" and for "a
sandbox that did not take".

- pi-sandbox keeps the reason (SandboxManager::not_enforced; pi_sandbox::not_enforced once
  installed) and builds the texts: not_enforced_warning (the profile, the reason, what is
  lost) and not_enforced_label (`sandbox:workspace (not enforced)`). A build without the
  `enforce` feature reports it as well.
- pi-shell's apply_sandbox prints the warning on stderr at start-up.
- The welcome top bar and the session status bar draw the label in the error colour where the
  label of an applied profile goes. The stderr line is behind the alternate screen by then.

Checked under a pseudo-terminal on WSL2 (kernel 5.10, no Landlock): the warning and the label
appear. Not exercised: a kernel with Landlock, where the profile is applied and no label may
show. Only unit tests cover that side, because apply() would confine the test process for
good.

Tests: 5 new in pi-sandbox (87 in the crate together with the previous commit).
The TUI's agent child inherited zypi's stderr, which app::run points at /dev/null once the
TUI is up, so a Python traceback or warning from the agent went nowhere.

The child's stderr is now <home>/logs/agent.stderr.log:
- appended to across runs, with one `--- agent started <rfc3339> (zypi pid N) ---` line per
  spawn so that a run's output can be told apart;
- owner-only (0600) on Unix, since a traceback can quote prompts, paths and keys;
- rotated at spawn: past 1 MiB it becomes agent.stderr.log.1 (replacing the one before) and a
  new file starts. The size is not enforced while an agent runs;
- when it cannot be opened (the `logs` path is a file, a read-only home) the agent still
  starts and its stderr is discarded as before, with a tracing warning.

`-p` is not affected: there the agent keeps the user's terminal as its stderr.

Tests: 6 in acp::spawn::agent_stderr_tests (location, start marker, append, rotation at the
limit, file mode, a log that cannot be opened).
Neither change touches production code.

- pi-pager, tool_media_same_length_same_mtime_rewrite_retries_failed_load: the stamp that
  tells a rewrite from an untouched file includes ctime, which comes from the kernel's coarse
  clock. At CONFIG_HZ=100 (WSL2's 5.10 kernel) a tick is 10 ms, and a rewrite in the same tick
  as the first stamp carries the same ctime and looks unchanged. The test now lets 25 ms pass
  between the two writes; a real slow write is never that fast.
- pi-pager-minimal, committed_edit_keeps_diff_line_backgrounds: Theme::current() follows a
  process-wide switch that the terminal-native tests turn on while they hold
  theme_cache::test_lock(). This test did not take the lock, so with the default thread count
  it could run in that window, get the native palette and find no diff backgrounds. It takes
  the lock now. 40 runs each with the default threads, --test-threads=1, the test alone, and
  (default threads and the test alone) with every CPU busy: no failure.
…fest

scripts/tui_baseline.toml is the manifest that scripts/tui_baseline.py, and with it the TUI CI
job, runs. Two suites join it and doctor_cmd:: is no longer skipped:

- pi-pager: drop skip = ["doctor_cmd::"]. It was there because the voice probe queried the
  audio input device, which hangs on macOS CoreAudio without microphone access. The probe is
  gone (plan section 10.10) and the 12 tests pass without an audio device. reference_passed
  5552 -> 5577: the 5,559 of the last CI run, plus those 12, plus the 6 agent_stderr tests.
- New suite pi-pager-render (--lib; 1,092 passed, 2 ignored on Linux). One test is listed in
  known_failures: tmux_probe::tests::successful_near_deadline_exit_still_returns_captured_output
  spends 1.2 s of a 1.5 s budget in a shell script, so it fails when the machine stalls. It
  failed twice on WSL2, both times on the first run after the binary was linked, and not once
  in the 94 runs after that, idle or with every core busy. known_failures only reports: the
  test is named in the report when it fails, and the run is not blocked.
- New suite pi-pager-minimal (--lib; 79 passed).
- pi-pager-bin: 20 -> 21, for the test that names zypi as the agent's parent in `-p` mode.

The numbers are WSL2's, not the runner's. The first CI run with the unskipped doctor_cmd tests
and the two new suites decides whether they hold there; if doctor_cmd does not, put the skip
back.

Whole manifest on WSL2 (python scripts/tui_baseline.py test): exit 0, 10 suites, 8,633 passed,
1 known failure, 22 ignored, 1,469 s.
…l terminal

scripts/tui_pty/ runs a built zypi under a pty (a pyte screen reads what it draws) and looks
at the process tree it leaves. They are the Linux counterpart of the macOS PTY runs in the
plan (sections 10.7 and 10.10). They need a built binary, so they are not part of pytest or CI.

- smoke.py: welcome page, one turn, /model, /exit, a restart in the same home with /resume,
  the agent's stderr log, and with --sandbox PROFILE what the status bar says about the
  profile. 23 checks on WSL2 (26 with --sandbox workspace).
- exit_matrix.py: while the agent runs a long bash tool call, leave zypi six ways (/exit twice,
  kill -9, SIGTERM, SIGHUP, closing the terminal, Ctrl-C) from two launch styles; nothing may
  be left running. 12/12 on WSL2 (14/14 with --sandbox workspace).
- print_exit.py: `zypi -p` while only zypi gets SIGTERM, SIGKILL, SIGHUP or SIGINT; the agent
  and its tool call must stop too. 4/4 (the tool survived before the -p fix).
- pty_term.py (the driver and the /proc helpers) and zypi_env.py (finds the binary, builds a
  throw-away PI_HOME, HOME and working directory and an environment from scratch, so what the
  caller's shell exports, PI_HOME included, never reaches zypi and a real ~/.pi-python is not
  touched).

scripts/tests/test_tui_pty.py covers the helpers: 10 tests on Linux; on Windows 5 pass and 5
are skipped.
The plan's macOS side is merged (#6 to #8); this is the Linux counterpart, run in WSL2
(Ubuntu 24.04, kernel 5.10 without Landlock, bubblewrap 0.9.0).

- docs/PLAN/PLAN-RUST-AGENT-RUNTIME-REMOVAL.md: section 10.10 (what was run on Linux, the
  findings and what was done about each, what is not verified, what is left for the user to
  decide), the r8 revision-log line and the validation tables.
- docs/baselines/tui.md: 10 suites, the numbers measured on WSL2 (not yet confirmed by CI), the
  tmux known failure, and a section on the PTY drivers.
- CHANGELOG.md, [Unreleased]: the agent's stderr log, the not-enforced notice, the `-p` orphan
  fix, --die-with-parent and its trade-off.
- AGENTS.md: how the Rust tests and the PTY drivers are run.
  packages/pi-agent-cli/AGENTS.md: the agent's stderr and the `-p` parent watch.
  docs/TUI-AND-CODE-AGENT.md: where the stderr log is and what --sandbox says when it is not
  in force.

Verification of the whole series: scripts/tui_baseline.py test exits 0 (10 suites, 8,633
passed / 1 known failure / 22 ignored); pytest 2,160 passed / 30 skipped on Linux (WSL) and
2,156 passed / 34 skipped on Windows; ruff check and ruff format --check clean. The numbers of
the new suites and of the unskipped doctor_cmd tests are WSL2's; the first TUI CI run on the
pull request confirms or corrects them.
… microphone

fake_standalone_facts_compose_through_shared_view asserts issue_count() == 1, but
collect_report_with also runs apply_voice_probe, which on Linux looks for pw-record,
parec or arecord on PATH. A host without one, as the CI runner is, gets a
voice.no-input-device issue on top: 2 instead of 1. The first TUI CI run of PR #9
failed on exactly that; WSL2 has all three recorders, which is why it passed there.

The test now leaves that finding out of the count (it checks the facts it composes,
not the machine it runs on). Verified by running the test binary directly with
PATH=/nonexistent: before the change 11 passed and 1 failed with left: 2, right: 1;
after it 12/12, and 12/12 with the recorders on PATH as well.

The docs said doctor_cmd:: needs no audio device and that r5 had removed
apply_voice_probe. Neither is true (the function is still there and is called on
every report), so the manifest comment, the baselines page and plan section 10.10
are corrected and now carry the numbers of that CI run: 8,633 passed, every suite
equal to the WSL2 count except this one test.
…al stdio (plan 1.P7)

The stdio contract suite could not reach the tool side of the protocol: its mock LLM never calls a
tool, so a permission request, or a cancel with something running, never happened on the wire.
tests/test_acp_stdio_tools.py drives tests/_tool_agent.py (the real serve() with a scripted LLM
turn that calls one tool) the way the TUI drives the agent, and asserts what the pager depends on:

- in ask mode a `write` call is announced as a tool_call update and then put as
  session/request_permission, before the tool runs: title, kind, raw_input and the two options
  allow-once / reject-once (the tool had not run when the request arrived);
- allow-once runs the tool; reject-once and a `cancelled` outcome do not, and the turn still ends
  (the model is told); auto mode never asks;
- session/cancel ends the prompt as `cancelled` while a question is open (the pager answers it
  `cancelled` after the cancel) and while a `bash` call runs, which is then reaped (POSIX only);
  with nothing running, or for a session the agent does not have, it is harmless.

_tool_agent.py gains PI_TEST_TOOL_CALL (a JSON tool call replacing the long bash) and
PI_TEST_PERMISSION (default auto, as before). Its stream now ends `aborted` when the signal is set
before the next LLM call, as the LangChain adapter does; without that a cancelled turn went on to
the next scripted call and ended end_turn, which says something about the stream, not the agent.

8 tests (7 on Windows, where the bash one is skipped), plus the 4 shutdown tests that share the
helper, all pass on Windows and in WSL2. Mutation checks: an agent that allows whatever the client
answers fails the two refusal cases; one that never asks fails 5; one whose cancel does nothing
fails the two cancel cases (by timeout).
`read_text_lines(path, max_lines=1)` read and decoded the whole file and then cut the first line
off, so listing sessions cost the total size of every session file (one of the files on this
machine is 1.85 MB). It now reads only as far as the lines asked for, with the same newline
handling as before (universal newlines, then `str.splitlines`), so every result is the same;
the tests compare against the old behaviour over awkward line endings and check that bytes
after the header are never decoded.

`LocalExecutionEnv.list_dir` called `file_info` on every child: a worker-thread round trip and
about six `stat` / `realpath` calls each, 1.3 s for a directory of 1000 files on Windows. It
is now one `scandir` in one worker thread and one `resolve` for the directory, with the same
`FileInfo` for every child (a test compares them child by child, symlinks included). A child
that disappears while the directory is read is left out instead of failing the whole listing.

Both were found by measuring `session/list` for plan 0.7 (scripts/session_list_bench.py).
`JsonlSessionRepo.list` reads the header of every session file, so a listing, and every
`session/load`, which looked its session up through it, cost the whole directory.

`list_page({limit, after, cwd})` walks the file names newest first (the names start with the
creation time) and reads a header only when the page reaches that file; it stops once the page
is full and the next session is in sight, and says where the next page starts (`next_after`,
a file name, so no state is kept and a cursor survives a restart). A page that carries a
cursor is never followed by an empty one.

`find(id)` picks the candidates from the file names (`...-<id>.jsonl`) and reads only those
headers; only when none of them is the session does it fall back to reading every file, as
`list` would, so a file named by someone else is still found.

`list` itself is unchanged.
…eal agent

scripts/session_list_bench.py writes a synthetic PI_HOME (N sessions, a third of them the
project's) and times, over stdio as the TUI talks to the agent: start up to `initialize`, the
first page of `session/list`, every page, the cross-project listing, `session/load`, and the
repo and preview parts in-process. It is how plan 0.7 gets the number ADR1 hangs on (what a
list costs once the TUI no longer reads the session files itself).
…lan 1.P1)

The picker asked for `session/list` once and ignored `nextCursor`, which was fine while the
Python agent answered with everything but would show only the first page once it pages. It now
follows the cursor until the agent sends none.

The cursor is opaque (ACP): it is handed back exactly as received and never read. The loop also
ends on an empty cursor, on a cursor the agent already gave (it would go round forever), and at
100 pages (5000 sessions at the agent's 50 a page); the sessions collected so far are shown and
a warning is logged. An error on any page fails the whole fill with the same `seq`, so a picker
never silently stops half way.

Tests drive a scripted agent over the same channel the real one uses: several pages in order
(with the cursor and cwd checked on every request), a single page, a repeated cursor, an empty
cursor, the page cap, and an error on a later page. Two mutations (the cursor not sent back; no
repeat check) make exactly the two tests that cover them fail.
…up by id (plan 1.P1)

`session/list` answered with every session and ignored `cursor`. It now returns
SESSION_LIST_PAGE_SIZE (50) sessions, newest first, with `nextCursor` while more follow. The
cursor carries the file name the next page continues below, so the agent keeps nothing between
requests; a string it did not issue is `invalid_params`. The TUI follows `nextCursor` (previous
commit), so its picker still shows everything.

`load` / `resume` / `pi/session/delete` found their session by listing all of them; they now
ask the repo for that id (one header read).

Measured on a synthetic home with the real agent (scripts/session_list_bench.py, medians):
Windows, 1000 sessions: first `session/list` 3171 ms -> 113 ms, `session/load` 2084 ms -> 108 ms;
WSL2 ext4, 1000 sessions: 492 ms -> 79 ms and 560 ms -> 78 ms. See plan section 10.11.
ACP lets a client hand an agent MCP servers in session/new, session/load and session/resume,
and says an agent MUST support stdio ones. The Python agent connects to none (MCP is a Phase 4
non-goal) and used to drop the list without a word, so a user who had set a server up in their
editor just found its tools missing.

The three handlers now pass the list to the deferred session setup, which logs a warning and
sends one agent message once the response has flushed, like the other setup notices:

  This agent does not connect to MCP servers, so the 2 MCP servers the client passed (a, b)
  were ignored and their tools are not available in this session.

Only the names are used, in the log and in the message (at most 8, each cut at 60 characters,
then "and N more"): `env`, `headers`, `args` and `url` routinely hold tokens. Servers given as
dicts or as the SDK's models are both read; one without a usable name shows as "(unnamed)".

The departure from ACP stays a documented deviation, not a fix: packages/pi-agent-cli/AGENTS.md
gets a section saying what the agent does with the list, why the notice is the place to start
if MCP is ever implemented, and that `initialize` advertises no mcpCapabilities.

tests/test_mcp_notice.py (12): the names and the message for none / one / many / long lists,
no token in the message or in the log, and the agent's own new_session, load_session and
resume_session driven with a recording client (one notice each; none for an empty list; the
session still answers a prompt). Mutation: load_session without the list makes
test_load_and_resume_say_it_too fail.
…1.P4)

The pager read `.mcp.json`, `~/.claude.json` and Cursor's `mcp.json` (pi_shell
`load_mcp_servers`) and put every server it found into the request, including each one's `env`
and `headers`. The Python agent connects to none of them, so that only copied tokens into a
process that threw them away, and the user was told nothing.

Both requests now carry an empty list, and the `session.create.done` log no longer reports a
server count. `load_mcp_servers` is left in pi-shell; nothing in the pager calls it any more.

The test writes a project `.mcp.json` with a token in it, checks that `load_mcp_servers` still
finds it (so the test cannot pass because the file was never read), and drives NewSession and
LoadSession through a scripted agent: neither request has an `mcp_servers` entry. Mutation:
putting `.mcp_servers(load_mcp_servers(..))` back into both calls makes the test fail (WSL).
…rrive in pages

docs/TUI-AND-CODE-AGENT.md still drew MCP in the TUI's box and said nothing about two things
the ACP side now does differently: the agent ignores the MCP servers a client passes (and says
so in the session), and `session/list` returns 50 sessions at a time with `nextCursor`, which
zypi follows.
… failed

The stdio bridge waited only for the pager's own stop request. An agent that went
away by itself (a crash, a kill, a Python that cannot import pi_agent_cli) went
unnoticed: nothing waited on the child, so the pager sat on requests that could not
be answered, and the event loop's "agent disconnected" arm (it quits when the
connection's cancel fires) never ran. A start that failed was no better: the reason
was the bridge thread's return value, which only the final join reads, so
`initialize` reported a closed channel and nothing else.

The bridge now waits on the child too. When it exits unasked, the bridge lets the
reader finish, fires the connection's cancel (the event loop quits), drops its tasks
(the pending requests fail instead of waiting for ever) and ends with an error that
names the program, how it ended and the last lines of its run in agent.stderr.log.
`connect()` returns that error when `initialize` fails, and the process guard prints
it after the terminal is restored when the agent went away mid-session. The pager's
own exit status is unchanged.

Found by the end-to-end test against the real agent (next commit): `false` as the
agent made connect() hang, and a program that does not exist was reported only as
"channel closed".
…plan 0.3)

tests/python_agent_e2e.rs drives pi_pager::acp::connect over real stdio against
`python -m pi_agent_cli` (mock model), without a pseudo-terminal: the handshake and
a clean stop; a streamed prompt; a session listed with its title and replayed by a
later agent; session/list followed by cursor (51 sessions, and an invalid cursor);
the permission question and both answers; cancel of a running tool, whose process is
reaped; a program that does not exist; an agent that exits at once; a Python without
pi_agent_cli; an agent killed in the middle of a turn.

PI_E2E_PYTHON names the interpreter. Without it the tests say so and pass; with
PI_E2E_REQUIRED=1 (set by tui-ci.yml) a missing interpreter is a failure, so a CI that
lost its Python is not green. The manifest runs the file in the pi-pager suite (10
tests on Linux, reference 5,587). tui-ci.yml installs the Python packages for it, and
also runs when the agent's own package changes.
…0.7)

scripts/tui_pty/resume_matrix.py makes one session through the TUI with the Python
agent, then starts zypi again with --continue, --resume <id>, --resume <title>,
--session-id and `zypi export`, and says whether each did what its help text promises.

Measured on WSL2 against a debug zypi: 9 cases hold and 4 do not. `--resume <title>`
finds nothing (it matches the old agent's summaries only), `--session-id` is ignored by
the Python agent and its in-use check cannot see Python sessions, and `export` says
"not found". The four are recorded as known gaps in the table (reported as GAP, and
--strict fails them) for the change that lands ADR1 to close; a gap that has closed
shows as FIXED until its text is dropped.

--continue works only because the pager's fallback reads the first line of each session
file and the Python header happens to carry the same `id` and `cwd` keys: format
knowledge the pager should not have.

The judging and the table are unit-tested in scripts/tests/test_tui_pty.py; those tests
need no zypi and run on any platform.
5,587 only added the new end-to-end tests to the last CI count. A local run of the
pi-pager suite through the baseline runner on Linux (scrubbed environment, the
end-to-end tests required, not skipped) passed 5,602 with 13 ignored: the 5,577
of CI run 38052332014, plus 10 end-to-end tests, 8 agent-exit tests, 6
fetch_session_list tests and the no-mcp_servers test. The next CI run confirms it.
…ours (plan 1.P3)

The TUI offers Normal, Always-Approve, Plan and Auto (Shift+Tab, --always-approve), and
tells the agent with a pi/yolo_mode_changed notification, or for Plan with session/set_mode.
The Python agent understands the notification only. mode_probe.py starts a fresh zypi per
way of choosing a mode against the scripted agent (permission mode ask, first turn calls
write) and prints whether a question appeared and whether the file was written unasked.

Today: Normal asks; --always-approve and Always-Approve do not; back to Normal asks again;
Plan asks and, once allowed, writes (it restricts nothing: set_mode is answered "method not
found" and the TUI only logs that); Auto never asks (the TUI describes a classifier that does
not exist behind the Python agent). The probe judges nothing; it is the evidence for plan
section 10.11 and the baseline a change to the mode mapping is compared with.
The probe covered the Shift+Tab cycle and --always-approve. `zypi --help` lists six values for
--permission-mode, and the probe now starts zypi with each of them.

On WSL2 only bypassPermissions changes anything: it behaves as --always-approve (status bar
"always-approve", no question, the file is written). default, acceptEdits, auto, dontAsk and plan
behave exactly as no flag at all: the status bar shows nothing and the write is asked about, so
acceptEdits does not accept edits, dontAsk does not stop asking, and plan does not enter Plan.

The reason is on the agent's side of the wire: the TUI puts yoloMode / autoMode in the _meta of
session/new, which the Python agent does not read. A mode chosen at the start works only where the
TUI answers permission questions itself (always-approve).

The probe still judges nothing; the plan (section 10.11) cites its table.
… a decision (plan r9)

The three gates still open after r8 (0.3 on the Rust side, 0.7, 0.8) are written down, together
with the parts of phase 1 that need no decision. Nothing here changes behaviour that was not
already committed; every behaviour-changing decision is a proposal for the user (section 10.11,
twelve items), not a change.

- docs/PLAN/PLAN-RUST-AGENT-RUNTIME-REMOVAL.md: section 10.11 (PR #9's second CI run, the e2e
  suite and the two product bugs it found, the cold-start latency before and after, the 13
  disk-read call sites in 8 files, the 13-case resume matrix, the landing design of ADR1 with the
  sandbox ordering and the PR slicing, the 12-scenario permission-mode probe, what changed, what
  was run, what was not, the twelve proposals); appendix A (capability matrix with every row
  marked decided / proposed / open, the ACP version pairing and the upgrade rules, the per-method
  table); B.4 (how to reproduce); the checklist ticks and the r9 revision-log line.
- docs/baselines/tui.md: pi-pager 5,577 -> 5,602 (the four new groups of tests), the full ten-suite
  run on WSL2 (8,659 passed / 0 failed / 22 ignored = CI's 8,634 + 25), the path-filter change.
- CHANGELOG.md, [Unreleased]: the session list is read in pages, the pager no longer sends
  mcp_servers and the agent says it ignores them, the agent-exit fixes.
- AGENTS.md: the resume matrix, the mode probe and python_agent_e2e.rs (PI_E2E_PYTHON).
  packages/pi-agent-cli/AGENTS.md: what the pager shows when the agent exits on its own.

Verification: scripts/tui_baseline.py test exits 0 (10 suites, 8,659 passed / 0 failed / 22
ignored); pytest 2,352 passed on Linux (WSL) and 2,351 on Windows; scripts/tests/test_tui_pty.py
19 passed with pyte; ruff check and ruff format --check clean with the venv's 0.16.10 and with
CI's pinned 0.16.0. All of it is WSL2's or Windows' own run; this branch has not been pushed, so
the first CI run confirms or corrects the numbers and the new e2e install step.
#9 went into main as one squash commit, 60033a4, so main no longer shares #9's commits with this
branch, and GitHub could not merge #10 into it: files that both sides changed near each other
conflict, although they changed them the same way. The tree of 60033a4 is the tree of d84d98a, #9's
last commit and the base of this branch, so main holds nothing this branch lacks. The merge keeps
this branch's tree as it is (-s ours); the tree stays the one that CI ran on for eef675b.
@zy1233 zy1233 changed the title feat: phase 1 contract work that needs no decision, and the phase 0 gates (plan r9, stacked on #9) feat: phase 1 contract work that needs no decision, and the phase 0 gates (plan r9) Oct 10, 2026
@zy1233
zy1233 merged commit 1dcfcaf into main Oct 10, 2026
7 checks passed
zy1233 added a commit that referenced this pull request Oct 10, 2026
…on works (ADR1 PR 1, plan r10) (#11)

## Summary

**Stacked on #10**, which is merged first; this branch is then rebased onto `main` and its base moved there. One commit: ADR1 PR 1 of `docs/PLAN/PLAN-RUST-AGENT-RUNTIME-REMOVAL.md` (section 10.11 has the design and the PR slicing, section 10.12 the record). It is the Python half only: **nothing in the TUI changes**. A client that never sends `_meta.sessionId` sees one difference: the `_meta["pi/cwd"]` key in the responses that open a session.

`zypi --session-id <uuid>` sends the id in `session/new`'s `_meta.sessionId`, and the agent ignored it and picked its own, so the flag did nothing on the Python agent (`resume_matrix.py` case 10). This makes it work, and gives the TUI two things it will rely on once it stops reading session files, plus one library helper for the first:

- **`session/new` takes `_meta.sessionId` as the session's id.** It has to be a UUID in any form `uuid.UUID` reads, and it is stored in canonical form (lower case, hyphens). Anything else is `invalid_params`, because the id ends up in a file name (`../../outside` is refused). An id some session already has is `invalid_params` too, whatever directory that session belongs to: `JsonlSessionRepo.create` accepts an id but does not refuse a used one, so a second file with the same id would fit beside the first and either could answer a later `session/load`. Requests for the same id at once make one session (an asyncio lock).
- **`session/new`, `load` and `resume` answer with `_meta["pi/cwd"]`**, the directory the session works in. `load` and `resume` bind the one saved with the session, not the client's, so a client that only knows where it was started (`zypi --resume <id>` from another directory) can learn the real one. Nothing in the TUI reads it yet.
- **`JsonlSessionRepo.has_session(id)`** answers "is this id taken?" without reading every header. `find` answers the same, but for an id nobody has (the usual case) it reads all of them: 1.7 s at 5,000 sessions. `has_session` opens a file only when its name does not already say it belongs to another session (`<timestamp>-<id>.jsonl`, as the repo writes them), so a free id costs the directory listing: 0.07 s at 1,000 sessions and 0.3 s at 5,000, on Windows and WSL2 alike. It takes one thing on trust: a file renamed by hand to carry another id.

## Not done, not verified

- **The pager does not use any of this yet.** It does not read `pi/cwd` and does not pre-check a duplicate `--session-id`, so case 11 of the matrix stays a GAP: the pager's in-use check reads the old layout and never sees the session, sends `session/new`, the agent refuses, and the TUI shows `Session creation failed: Invalid params` and stays up instead of exiting 1. That is PR 2.
- Two agent processes on one home are not covered by the lock (it is in-process).
- Nearly all of what `has_session` still costs is `LocalExecutionEnv.list_dir` building a `FileInfo` per entry (`os.listdir` of the same 5,000 files takes 2-11 ms). It is shared with `session/list` and left alone (proposal 13 in the plan).
- Python 3.11 and 3.13 run only in CI. Mocks and scripted agents only, no real LLM; the real `zypi` ran on Linux (WSL2) only.

## Review guide

One commit, 12 files, +499 / -37.

| Files | What |
|---|---|
| `packages/pi-agent-harness/.../session/jsonl_repo.py`, `tests/test_harness_session_page.py` | `has_session`, seven tests |
| `packages/pi-agent-cli/pi_agent_cli/agent.py` | `_meta.sessionId` on `session/new`, the per-id lock, `_meta["pi/cwd"]` on `new` / `load` / `resume` |
| `packages/pi-agent-cli/tests/test_session_chosen_id.py`, `test_acp_stdio_contract.py` | 18 + 2 tests: id honoured and visible after a restart, canonical form, bad values, id in use (other spelling, after a restart, another directory), five concurrent requests make one, `pi/cwd` in all three responses |
| `scripts/session_list_bench.py`, `scripts/tui_pty/resume_matrix.py`, `scripts/tests/test_tui_pty.py`, `scripts/tui_pty/README.md` | benchmark numbers for `new` and a free id; matrix case 10 loses its gap and case 11's gap text says what is seen now; the test that pinned "every fresh-id case is a gap" goes |
| `docs/PLAN/...`, `CHANGELOG.md`, `packages/pi-agent-cli/AGENTS.md` | plan section 10.12, one CHANGELOG line, the agent's contract |

## Verification

- **CI.** The run on the commit as first pushed (`1a9a336`) is green: Ruff, pytest on 3.11 / 3.12 / 3.13 (about 2 minutes each), and the TUI job, which runs because `pi_agent_cli/**` changed: 10 suites, **8,659 passed / 0 failed / 22 ignored** with `pi-pager` at 5,602, which includes the 10 `python_agent_e2e` tests that drive the pager's ACP client against this agent ([TUI CI run 38073976529](https://github.com/zy1233/pi-python/actions/runs/38073976529); `cargo test` 32 m 53 s). The branch was then rebased onto `main` after #10's squash; the tree of the rebased commit is the tree of `1a9a336`, so that result stands.
- pytest: 2,378 passed / 36 skipped on Windows (Python 3.12.7), 2,379 passed / 35 skipped on Linux (WSL2, ext4 clone). 27 new tests.
- `ruff check .` and `ruff format --check .`: clean.
- Eleven one-line mutations of the new code, each caught by at least one test.
- Against the real `zypi` on WSL2 (`scripts/tui_pty/resume_matrix.py`): `--session-id <fresh uuid>` now starts the session with that id; the matrix goes from 9 PASS / 4 GAP to 10 PASS / 3 GAP, no FAIL.
zy1233 added a commit that referenced this pull request Oct 10, 2026
…P3 (a))

The Python agent does two things about a tool call: it asks, or it approves everything. The TUI
still offered Plan, Auto, a settings "Default" and six --permission-mode values. Measured against
the real zypi on WSL2 (plan section 10.11): Plan restricts nothing (the agent has no
session/set_mode handler, the call fails unseen and the screen goes on saying "plan"); Auto never
asks, because the agent treats `auto` like `always-approve` while the screen promises a classifier;
the settings' Default is a value the agent drops, so after Always-Approve it says Default and the
agent still approves everything; of the six --permission-mode values only bypassPermissions did
anything. All of them err the same way: the user thinks the agent is more careful than it is.

Hidden, not deleted. app/agent_modes.rs holds three capability constants (PLAN, AUTO, DEFAULT, all
false: "the agent honours this"); AppView.{plan,auto,default}_mode_gate and the mirrored
PagerLocalSnapshot gates follow them. Test fixtures open the gates, so the hidden paths and their
old tests still run, and flipping one constant brings its mode back with its tests (1.P3 (b)).

- Shift+Tab cycles Normal <-> Always-Approve (dispatch_cycle_mode_without_plan, next stop from the
  pure next_ring_step_without_plan; Auto sits between them only while its gate is on). Everything
  else is as before: the pre-session branch stages the flags SessionCreated reads, the session
  branch goes through set_yolo_mode_inner, both persist; the managed-policy pin keeps Normal and
  shows the policy's warning; a stale Plan flag leaves through Normal with session/set_mode(default).
- Settings: no plan_mode row and no plan-nudge toggle, permission_mode offers Ask and Always
  approve only (its description no longer mentions Default, Auto or a classifier), set_plan_mode
  does nothing, set_permission_mode turns Auto/Default into Ask, the plan nudge tip is not shown,
  and apply_contextual_hints (the one funnel for the hint toggles) clamps plan_mode.
- A saved permission_mode = "auto" or "default" is shown as Ask and the file is left alone; a launch
  never starts in Auto (agent_modes::launch_auto).
- --permission-mode takes `default` (no flag) and `bypassPermissions` (--always-approve); plan,
  auto, acceptEdits and dontAsk exit 2 with a message that says the flag would change nothing and
  lists the valid values. pi-shell's PermissionMode::VALID_VALUES (agent-definition frontmatter) is
  untouched.

The Python agent is untouched.

Against the real zypi (scripts/tui_pty/mode_probe.py, now 10 scenarios, the four refused values and
a settings-page check): Shift+Tab goes Normal -> Always-Approve -> Normal -> Always-Approve (asked
in Normal, not asked in Always-Approve); --permission-mode default / bypassPermissions behave as
before; a saved auto or default shows nothing in the status bar and the write is asked about; a
saved always-approve shows "always-approve" and is not asked; plan, auto, acceptEdits, dontAsk exit
2; the settings search for "plan" finds no Plan mode row and the permission picker offers Ask and
Always approve.

Tests: 24 new in pi-pager (21 lib: agent_modes 2, cli 3, dispatch modes 13, settings_modal 3; 3
settings_e2e) and 4 in scripts/tests/test_tui_pty.py. Three old tests that assumed Plan or Default
were visible were adjusted (rows_contain_categories_and_settings_through_pr_14,
expanded_description_wraps_to_modal_width, enum_choice_gated_off_covers_voice_and_permission).
Docs: plan section 10.13, appendix A.1 and B.6, CHANGELOG, packages/pi-agent-cli/AGENTS.md,
scripts/tui_pty/README.md; the baseline reference for pi-pager is 5,626 and docs/baselines/tui.md
records PR #10's CI run (8,659 passed, the numbers r9 had measured locally).

Verified: scripts/tui_baseline.py test on WSL2 (10 suites) 8,683 passed / 0 failed / 22 ignored,
pi-pager 5,626; pytest on Windows 2,382 passed / 36 skipped, on Linux (WSL2, ext4 clone) 2,383
passed / 35 skipped; ruff check and format --check clean with the venv's 0.16.10 and CI's pinned
0.16.0. Twelve one-line mutations of the new code, each caught by at least one test; one of them
(a Default in front of Ask in the picker) was not seen by the e2e picker test, which only pressed
Down, so it now presses Up too. Only mocks and scripted agents, no real LLM; zypi ran on Linux only.
Not pushed: this branch sits on codex/adr1-pr1-python, which sits on PR #10.
zy1233 added a commit that referenced this pull request Oct 10, 2026
…ds the merge order

The ADR1 PR 1 branch and the mode_a branch were local when the plan and the baseline report were
written, and said "not pushed" in eight places. They are pushed now: #11 (codex/adr1-pr1-python) on
top of #10, #12 (codex/phase1-mode-a) on top of #11. Those places name the PRs instead; the r9
statements that were true when they were written stay, as the r10 entry already says.

The r10 entry in the revision log gets the last step. The user asked to sync main to origin and
merge the processed PRs. The stack has four (#9, #10, #11, #12), and the upper ones have nothing to
merge into until the lower ones are in, so all four are merged, bottom-up, one squash each (the
convention of #7 and #8), with the base moved to main before each merge and the tree of main compared
with the branch's afterwards. Local main is 9 commits ahead of origin/main; all of them except one
older docs commit (superseded by the revised one in #9) are already in #9's branch, so main is not
pushed: after the merges it is reset to origin/main, and the old main stays on a backup branch.

Docs only: the plan, docs/baselines/tui.md.
zy1233 added a commit that referenced this pull request Oct 10, 2026
…rged (plan r10)

PR #12's CI run counted what the local WSL2 run had: 8,683 passed / 0 failed / 22 ignored over the
ten suites, pi-pager 5,626, each suite equal to its reference_passed. The baseline report and the
comment in the manifest said the next CI run would confirm that; they now say it did, with the run.
#10 (after main was merged into its branch) and #11 were green too, 8,659 over the ten suites.

The r10 entry of the revision log gets what happened when the stack was merged. #9 was a squash,
so #10's branch still held #9's original commits and GitHub reported conflicts in files both
sides had changed near each other, in the same way. main was merged into #10's branch with the
ours strategy (the tree of main is the tree of #9's last commit, so nothing is dropped), #11 and
#12 were each rebased onto the new main before their squash, and after every merge the tree of main
was compared with the branch's. A squash of the lower PR of a stack makes the next one conflict
unless it is brought up to main first.

Docs and a comment in the manifest only; the code is the tree CI already ran on.
zy1233 added a commit that referenced this pull request Oct 10, 2026
…P3 (a), plan r10) (#12)

## Summary

**Stacked on #11**, which is merged first; this branch is then rebased onto `main` and its base moved there. Three commits: plan 1.P3 (a) (the first option of decision 7 in section 10.11), a docs commit that names the PRs of the two r10 branches, and a docs commit that records the CI runs and how the stack was merged. Section 10.13 of `docs/PLAN/PLAN-RUST-AGENT-RUNTIME-REMOVAL.md` has the record.

**This changes what users see.** The Python agent does two things about a tool call: it asks, or it approves everything. The TUI still offered Plan, Auto, a settings "Default" and six `--permission-mode` values. Measured against the real `zypi` on WSL2 (section 10.11):

- Plan restricts nothing: the agent has no `session/set_mode` handler, the call fails unseen, and the screen goes on saying "plan".
- Auto never asks, because the agent treats `auto` like `always-approve`, while the screen promises a classifier.
- The settings' Default is a value the agent drops: after Always-Approve it says Default and the agent still approves everything.
- Of the six `--permission-mode` values, only `bypassPermissions` did anything.

All of them err the same way: the user thinks the agent is more careful than it is. This PR stops offering what the agent does not honour. **The Python agent is untouched.**

**Hidden, not deleted.** `app/agent_modes.rs` holds three capability constants (`PLAN`, `AUTO`, `DEFAULT`, all `false`: "the agent honours this"); `AppView.{plan,auto,default}_mode_gate` and the mirrored `PagerLocalSnapshot` gates follow them. Test fixtures open the gates, so the hidden paths and their old tests still run, and flipping one constant brings its mode back together with its tests (decision 7 (b)).

- **Shift+Tab** cycles Normal <-> Always-Approve (Auto sits between them only while its gate is on). The pre-session branch stages the flags `SessionCreated` reads, the session branch goes through `set_yolo_mode_inner`, and both persist. The managed-policy pin keeps Normal and shows the policy's warning. A stale Plan flag leaves through Normal with `session/set_mode(default)`.
- **Settings:** no `plan_mode` row and no plan-nudge toggle; `permission_mode` offers Ask and Always approve only, and its description no longer mentions Default, Auto or a classifier; the plan nudge tip is not shown. `set_plan_mode` does nothing, and `set_permission_mode` turns Auto / Default into Ask.
- **A saved** `permission_mode = "auto"` or `"default"` is shown as Ask and the file is left alone; a launch never starts in Auto.
- **`--permission-mode`** takes `default` (no flag) and `bypassPermissions` (`--always-approve`). `plan`, `auto`, `acceptEdits` and `dontAsk` exit 2 with a message that says the flag would change nothing and lists the valid values. `pi-shell`'s `PermissionMode::VALID_VALUES` (agent-definition frontmatter) is untouched.

## Decision for you

This is (a) of decision 7, the option you picked (`mode_a`). A real Plan mode ((b): the agent grows a `session/set_mode` handler and a read-only toolset) or a real Auto ((c)) would start from the three constants; neither is done here.

## Not verified, left

- Only mocks and scripted agents, no real LLM; the real `zypi` ran on Linux (WSL2) only, so macOS and Windows runs of the TUI are not done.
- Left, and listed in section 10.13: the settings search for "plan" still finds "Show contextual hints", and `permission_mode`'s search keywords still include "classifier" and "auto"; `--no-plan` is still in `--help`; the embedded upstream user guide (extracted to `<home>/docs/user-guide/`) still describes the old Plan / Auto cycle; the other permission settings (`remember_tool_approvals`, `default_selected_permission`) are not checked against the Python agent (candidate 14); the path that leaves a stale Plan flag is covered by a unit test only.

## Review guide

| Files | What |
|---|---|
| `tui/crates/codegen/pi-pager/src/app/agent_modes.rs` (new) | the three constants, `permission_mode_flag_values()`, `launch_auto(...)`, the module doc with the three measured facts |
| `app/app_view.rs`, `app/dispatch/modes.rs`, `dispatch/prompt.rs`, `dispatch/mod.rs`, `dispatch/settings/ui.rs`, `app/event_loop.rs`, `app/mod.rs` | the gates, the Shift+Tab ring (`next_ring_step_without_plan`, `dispatch_cycle_mode_without_plan`), the degrade paths, the plan nudge |
| `settings/registry.rs`, `settings/defs.rs`, `views/settings_modal/state.rs`, `actions/defaults.rs` | settings rows and choices, the `CycleMode` action text |
| `app/cli.rs` | `--permission-mode` value parser |
| tests in `pi-pager` | 24 new (`agent_modes` 2, `cli` 3, `dispatch/tests/modes.rs` 13, `settings_modal` 3, `settings_e2e` 3) and 3 adjusted old ones (`rows_contain_categories_and_settings_through_pr_14`, `enum_choice_gated_off_covers_voice_and_permission`, `expanded_description_wraps_to_modal_width`) |
| `scripts/tui_pty/mode_probe.py`, `scripts/tests/test_tui_pty.py` | the probe now has 10 scenarios, 4 refused values and a settings-page check; 4 new helper tests |
| `scripts/tui_baseline.toml`, `docs/...`, `CHANGELOG.md`, `packages/pi-agent-cli/AGENTS.md` | `pi-pager` `reference_passed` 5,626; plan section 10.13, A.1, B.6; one CHANGELOG entry under Changed |

## Verification

- **CI.** The run on the commit as first pushed (`bd44289`) is green ([TUI CI run 38074038191](https://github.com/zy1233/pi-python/actions/runs/38074038191)): the ten suites add up to **8,683 passed / 0 failed / 22 ignored** with `pi-pager` at 5,626 (13 ignored), each suite equal to its `reference_passed`, so the local numbers below held on the runner, including the `settings_e2e` test that was edited after the local manifest run (that binary alone: 254 passed). `cargo test` took 29 m 13 s, `Gates + cargo check` 5 m 46 s, Ruff 10 s, pytest on 3.11 / 3.12 / 3.13 about 2 minutes each. The branch was then rebased onto `main` after #11's squash and one docs commit was added; compared with the commit CI ran on, only three files differ (the plan, `docs/baselines/tui.md`, and a comment in `scripts/tui_baseline.toml`).
- Whole manifest on WSL2 (`python scripts/tui_baseline.py test`, scrubbed environment): exit 0, 10 suites, **8,683 passed, 0 failed, 22 ignored** = #10's CI result (8,659) + 24 new `pi-pager` tests. `pi-pager`: 5,626.
- pytest: 2,382 passed / 36 skipped on Windows, 2,383 passed / 35 skipped on Linux (WSL2); `scripts/tests/test_tui_pty.py` with `pyte`: 23 passed.
- `ruff check .` and `ruff format --check .`: clean (227 files) with the project's Ruff 0.16.10 and with CI's pinned 0.16.0.
- Twelve one-line mutations of the new code, each caught by at least one test. One was not seen at first: a Default listed in front of Ask in the picker, because the e2e test only pressed Down and the picker clamps at both ends; it presses Up as well now.
- Against the real `zypi` (`scripts/tui_pty/mode_probe.py`): Shift+Tab goes Normal -> Always-Approve -> Normal -> Always-Approve (a write is asked about in Normal, not in Always-Approve); `--permission-mode default` and `bypassPermissions` behave as before; a saved `auto` or `default` shows nothing in the status bar and the write is asked about; a saved `always-approve` shows "always-approve" and is not asked; `plan`, `auto`, `acceptEdits` and `dontAsk` exit 2; the settings search for "plan" finds no Plan mode row, and the permission picker offers Ask and Always approve.
@zy1233
zy1233 deleted the codex/phase1-contract-docs branch October 11, 2026 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant