Commit Graph

20 Commits

Author SHA1 Message Date
joakimp 25c1265681 task tool + fork-gate: put the "pi-task, not fork" rule where the decision is made
The rule was correct and written down twice (global AGENTS.md, this skill)
and was still violated by agents that had just read it: on 2026-09-17 all
five fork briefs in one session carried "do not", one returned confident
verbatim quotes that did not exist, and four had disjoint write boundaries
fork cannot enforce (re-run as pi-task, they passed). Three mechanisms,
none of them wording:

  1. fork is a TOOL — its self-recommending description ("implementation,
     testing, review…") is in the model's face every turn; pi-task was a CLI
     to be remembered and reached through bash with a hand-written JSON.
  2. The skill is gone after the first compaction; the tool list never is.
     The asymmetry widens in exactly the long sessions where fork is worst.
  3. Friction: one string vs a spec file + bash + reading result.json.

extensions/task.ts registers pi-task as the `task` tool. Flat parameters
build the spec; the decision rule sits in the description and in
promptGuidelines (appended to the system prompt, so compaction cannot remove
it). Before spending a model run it rejects the two spec errors that make a
boundary violation certain — write_allowed not an exact subset of roots, and
a writable root nested in a watched-only root (the parent's porcelain would
change every time; pi-task keys deltas by root string) — and it serialises
sibling tasks whose roots overlap (parallel siblings saw each other's writes
as violations, 2026-09-17). Returns the CLI's own parent-facing report; a
FAIL verdict is a result, only a CLI refusal is an error.

extensions/fork-gate.ts is a tool_call hook that BLOCKS a fork whose brief
contains a prohibition, a write boundary, or a clause-initial file-changing
imperative, and returns as the reason the exact task(...) to make instead.
Wording, not intent — the message says so and how to rephrase a genuinely
read-only brief. PI_FORK_GATE=off logs instead; /ext disables.

Evidence: test/fork-gate.test.mjs is two-sided (15 must-block incl. the real
shapes, 10 must-pass incl. "Write a summary…", "Report which files were
modified…", "Give me an update…"); the classifier redirects 5/5 of the real
briefs from the motivating session. test/task.test.mjs pins root overlap and
the pre-launch validation. Live in `pi -p`: the fork was intercepted before
any child spawned (no /tmp/pi-fork-* dir) and the model received the
redirect; task returned PASS with an evidence pointer and audit dir, a
budget-overrun returned a FAIL result (isError=false), and a nested-root spec
was rejected with no audit dir created.

skill/SKILL.md: Part 1 now opens with "decide the rung before the brief"
(the table, the three-question pre-flight, the roots contract, overlap,
what isolation does not fix); the ladder section and quick reference no
longer say pi-task "will never appear in your tool list". package.json gains
"type": "module" and a test script.
2026-09-19 16:41:23 +02:00
joakimp 2610545c83 hooks: warn at push time when skill/ changes are not yet mirrored
Editing skill/ here is only half the job: skillset mirrors it at
skills/pi-extensions/, and that copy is what most consumers actually read --
each Mac's host-side ~/.agents/skills/pi-extensions symlinks into it, and a
workstation with only skillset cloned has no other copy. The refresh is a manual
cp in another repo and it has now been forgotten on two consecutive edits, so
the mirror drifted 4890 B behind, then 9579 B.

hooks/pre-push compares the PUSHED content (git show <sha>:skill/...) against the
mirror on disk and prints the exact cp/sed/commit sequence when they differ.
Pushed content rather than the worktree: uncommitted local edits are not what
this push publishes.

It warns and exits 0 rather than blocking, for two reasons that are not
squeamishness: the direction rule is "edit upstream, THEN refresh", so the
refresh legitimately comes after this push, and it is a commit in a different
repo that cannot be made from here. Enforcement belongs downstream and already
exists -- skillset's pre-commit gate refuses a commit that leaves the mirror
stale. This hook only shortens time-to-detection from "next skillset commit" to
"seconds, to the person who caused it".

Silent when the mirror already matches, when the push does not touch skill/, on
branch deletions, and when no skillset clone is on disk -- a reminder that cannot
be acted on is noise that trains people to skim hook output.

install.sh activates it (core.hooksPath=hooks, per-clone config that cannot be
tracked), preserves a foreign hooksPath rather than clobbering it, and does NOT
undo the activation on --uninstall: removing a safety gate as a side effect of
uninstalling extensions would be a surprise in the wrong direction.

Verified: all three activate_hooks branches in a throwaway repo, and five
pre-push scenarios driven through the real stdin protocol (stale -> warns,
in-sync -> silent, non-skill push -> silent, branch deletion -> silent, no
skillset clone -> silent).
2026-09-08 23:01:00 +02:00
Joakim Persson a7f3044c94 skill: co-locate the pi-extensions agent skill in the package
Add skill/SKILL.md (+ skill/evaluate-extension-usage.py, referenced by the
skill via ./) so the canonical 'how to use fork/recall/ssh-controlmaster'
skill lives next to the extensions it documents — the single source of truth.

Motivation: the global AGENTS.md (pi-toolkit) tells every pi session to read
~/.agents/skills/pi-extensions/SKILL.md at session start to fix fork/recall
under-utilisation, but that skill previously lived ONLY in the private
skillset repo. In any environment without the skillset mounted (e.g. a
pi-devbox container started without it) the pointer dangled. Co-locating the
skill here gives a public, package-owned source that downstreams can vendor.

install.sh is intentionally unchanged: skill deployment on a normal
workstation stays the skillset repo's responsibility (no double-deploy).
2026-06-23 15:27:59 +02:00
joakimp 8a47f2f3b4 feat(ssh-controlmaster): use ~/.ssh-local/config so pi --ssh can reach LAN peers
dssh reaches host-LAN peers from inside the devbox container because it runs
`ssh -F ~/.ssh-local/config` (which Includes the host-owned, bind-mounted
ssh-lan.conf carrying `ProxyJump host` entries). pi --ssh shelled out to plain
`ssh`/`ssh -G` against the default ~/.ssh/config, which has no jump, so it
could not reach peers the host can.

Thread `-F <config>` through every ssh call (ssh -G, pwd probe, master start
for both key and password paths, sshExec, bash exec, ssh -O exit), resolved
once at load by resolveSshConfigOpts():
  PI_SSH_CONFIG (leading ~ expanded, honored even if missing)
  else ~/.ssh-local/config if present
  else [] (no -F)

No hostnames are baked into the image — the LAN list lives only in the
host-owned, read-only-mounted ~/.config/devbox-shell/ssh-lan.conf. On the host
(native pi) ~/.ssh-local/config doesn't exist, so -F is omitted and behavior is
unchanged. Command-line -o options still win over -F, so own-master /tmp socket
and ControlMaster decisions are unaffected. Status/notify shows [config: <path>]
when a non-default config is used.

Verified from the container: the patched probe reaches an enrolled peer (pve ->
/root) where plain ssh times out. Reaching a new peer (e.g. alpserv-2) is now a
one-line host-side edit to ssh-lan.conf.

Docs: README + AGENTS.md updated.
2026-06-20 22:40:57 +02:00
joakimp aaa7d906df fix(ssh-controlmaster): prevent session_start from hanging on unreachable host
pi awaits session_start before the agent loop accepts input, so a blocking
ssh call there presents as "TUI is up but prompts are silently ignored".
The remote pwd probe and ControlMaster start had no ConnectTimeout, no
BatchMode, and no wall-clock cap, so an unreachable host (classic case:
`pi-dev --ssh <lan-host>` runs pi *inside* the devbox container, which has
no route to LAN hosts without a ProxyJump) blocked startup on the OS TCP
timeout (~75s+) while silently swallowing keystrokes.

Hardening:
- CONNECT_OPTS (ConnectTimeout=8 + ServerAlive keepalives) on every ssh
  invocation: pwd probe, master start (both key and password paths),
  sshExec, and bash exec.
- run() gains a timeoutMs option (kills child + rejects); pwd probe and both
  master-start paths bounded by STARTUP_TIMEOUT_MS (15s).
- BATCH_OPTS (BatchMode=yes) on key-auth calls so ssh never waits on a
  /dev/tty password/passphrase/host-key prompt behind the TUI; omitted under
  --ssh-ask-pass so the SSH_ASKPASS path still works. askPass flag is now
  read before the probe to pick the right opts.
- Probe failure sets an "unreachable" status + error toast and returns,
  instead of throwing an unhandled rejection from the handler.

Docs: README "How it works" + AGENTS.md technical note updated.
2026-06-20 22:16:48 +02:00
joakimp 6f7dca06e8 fix(ssh-controlmaster): handle read-only ControlPath under bind-mounted ~/.ssh
The devbox bind-mounts ~/.ssh read-only. A user ~/.ssh/config with a per-host
ControlPath under it (the CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) is
unwritable there, so a plain `ssh <host> pwd` exits 255 trying to bind the
master socket — blocking `pi --ssh <host>` with "Could not resolve remote pwd".
A system default cannot override a user's per-host value (SSH first-value-wins),
so this must be handled in the extension.

- controlPathWritable(): expands ~, tests whether the socket's parent dir is
  writable (or missing but creatable via nearest existing ancestor). Pure fs
  check — OS-agnostic, no host-OS detection.
- negotiateMaster / negotiateMasterWithPassword: reuse the system master only
  when its ControlPath is writable; otherwise start our own /tmp master whose
  command-line `-o ControlPath` overrides the user's unwritable path.
- Remote pwd probe: `-o ControlPath=none -o ControlMaster=no` so a read-only
  system ControlPath cannot make the initial probe fail.

No behaviour change for configs without ControlMaster. Updates README.md +
AGENTS.md to match.
2026-06-18 21:59:00 +02:00
joakimp 1381a37115 Rename @mariozechner/pi-* to @earendil-works/pi-*
Pi moved to its new home at earendil-works on 2026-05-07
(https://pi.dev/news/2026/5/7/pi-has-a-new-home). Affected packages:

  @mariozechner/pi-coding-agent  -> @earendil-works/pi-coding-agent
  @mariozechner/pi-tui           -> @earendil-works/pi-tui
  @mariozechner/pi-ai            -> @earendil-works/pi-ai
  @mariozechner/pi-agent-core    -> @earendil-works/pi-agent-core

The old @mariozechner/* packages are deprecated on npm with the
explicit message 'please use @earendil-works/pi-coding-agent instead
going forward', and the version stream has moved on (old top-out
0.73.1; new currently 0.74.0). Anyone npm-installing the old names
gets a deprecation warning + a stale binary.

Sweep:
- All 7 extension TypeScript files: import statements updated.
- README, AGENTS, install.sh: textual references and the github.com/
  mariozechner/pi-coding-agent URL pointed at github.com/earendil-works/
  pi (the new monorepo root; coding-agent now lives at
  packages/coding-agent inside it).
- Bun build of mcp-loader, ext-toggle, ssh-controlmaster verified clean.

Brew install references (`brew install pi-coding-agent`) left as-is:
the homebrew formula still works at 0.73.1 and a tap update is
tracked upstream at earendil-works/pi#2755. Historical CHANGELOG
entries are untouched.
2026-05-09 17:56:15 +02:00
joakimp 37cc49e06f mcp-loader v2: streamable-HTTP transport for remote MCP servers (context7)
- New RemoteMcpClient implementing MCP streamable-HTTP per spec 2025-03-26:
  POST JSON-RPC, parse application/json or text/event-stream responses,
  round-trip optional Mcp-Session-Id, optional auth via 'headers' config.
- Refactor StdioMcpClient to share an IMcpClient interface with the remote
  client; extension entry dispatches on cfg.type. Drops the v1 'remote
  skipped with warning' code path.
- Bump MCP_PROTOCOL_VERSION to 2025-11-25 (single constant, both clients).
- 404 self-heal: when a remote returns 404 to a request carrying our
  Mcp-Session-Id, drop the id, re-initialize, retry the request once
  (per spec 2025-11-25 \u00a72.2). allowReinitOn404=false on the retry path
  prevents recursion. Verified via mock-server smoke test.
- Sanitize pi-facing tool names to ^[A-Za-z][A-Za-z0-9_]{0,63}$. Anthropic
  allows hyphens but Bedrock's Anthropic shim rejects them, causing entire
  turns to 4xx silently when context7's hyphenated tools (resolve-library-id,
  query-docs) were registered. Original MCP-side names are preserved in the
  tool-execute closure, so sanitization is purely pi-facing.
- /mcp slash command: drop 'remote (skipped)' status label.
- Docs: README and AGENTS updated for transports, headers config, 404
  self-heal, tool-name sanitization rationale, OAuth limitation.

End-to-end verified: context7 connects through pi, returns useful docs
(Bun streaming/SSE example fetched successfully).
2026-05-09 15:26:36 +02:00
joakimp 7eec49b9b8 mcp-loader: add /mcp slash command for runtime status + toggle
Mirrors /ext UX (space=stage, enter=apply+reload, esc=cancel) but for
MCP servers in the settings.json `mcp` block. Tracks per-server runtime
state captured at extension load time so users can see at a glance
which servers are running / failed / disabled / remote-skipped /
invalid, with tool counts for the running ones.

Toggling writes back to settings.json — disabling sets enabled:false,
re-enabling removes the explicit key (default is true) to keep the
file tidy. Then ctx.reload() picks up the change.

Closes the visibility gap surfaced by 'searxng_search isn't in /ext':
MCP-provided tools are runtime-spawned, not file-based extensions, so
they need their own list view. /mcp fills that hole.
2026-05-08 21:05:09 +02:00
joakimp 141bf64d81 Add mcp-loader extension: generic MCP server registration via settings.json
Reads an `mcp` block from ~/.pi/agent/settings.json (same shape as
opencode and Claude Desktop) and connects to each declared MCP server,
exposing all of their tools to pi as native tools namespaced as
<server-name>_<tool-name>.

Why: pi has no built-in MCP loader. Adding each new MCP server as a
hand-rolled extension (the way mempalace.ts does it) doesn't scale.
This is the config-driven generalization — one extension, any number
of servers, no per-server boilerplate.

Settings.json schema matches opencode and Claude Desktop verbatim:

  {
    "mcp": {
      "searxng": {
        "type": "local",
        "command": ["uvx", "mcp-searxng"],
        "env": { "SEARXNG_URL": "https://searxng.your-host.lan" }
      },
      "context7": {
        "type": "remote",
        "url": "https://mcp.context7.com/mcp"
      }
    }
  }

Per-server keys: type (local/remote), command, url, enabled, env.

Implementation:
  • StdioMcpClient class spawns subprocess, performs MCP initialize
    handshake (protocol 2024-11-05), lists tools, exposes a callTool()
    method. Newline-delimited JSON-RPC over stdio.
  • Each MCP tool registered via pi.registerTool with the server-
    namespaced name, the upstream MCP inputSchema passed through
    via Type.Unsafe (TypeBox is JSON-Schema-compatible at runtime).
  • Per-server fail-soft: a server that won't start logs one stderr
    line and is skipped; others continue.
  • SIGTERM all subprocesses on session_shutdown so /reload doesn't
    leak processes.

Tool naming: prefix with <serverName>_ except when the upstream tool
name already starts with that prefix (mempalace's tools are already
mempalace_search, mempalace_kg_query, etc — avoids double-prefixing).

Coexists with mempalace.ts but does not replace it. The mempalace
bridge has bespoke agent-identity injection that's worth preserving.

v1 limitations:
  • Stdio transport only. Remote (streamable-HTTP) servers are
    detected and skipped with a warning. v2 will add streamable-HTTP.
  • No reconnect on subprocess death — same limitation as mempalace.ts.

Verification:
  • node --check syntax clean
  • Standalone smoke test against `uvx mcp-server-time`: handshake +
    tools/list (2 tools) + tools/call (get_current_time) all green
    on the same JSON-RPC code that lives inside the loader.

Debug: set PI_MCP_LOADER_DEBUG=1 to surface per-server stderr.
2026-05-08 20:02:21 +02:00
joakimp ba994014a7 Add todo.ts (verbatim copy of upstream examples/extensions/todo.ts)
Provides the agent with a 'todo' tool (list/add/toggle/clear) and
registers /todos for the user. Useful for externalising multi-step
plans during long arcs.

State persists in tool result details rather than an external file,
which means: pi --continue brings todos back with the session, and
/fork forks the todo state along with the branch.

Copied not symlinked because the upstream path lives under a
homebrew-versioned Cellar dir that rotates on every pi upgrade.
Refresh procedure documented in AGENTS.md.
2026-05-07 21:10:00 +02:00
joakimp e47cbe5795 ext-toggle: stage-then-commit UX (space stages, enter applies)
Replaces the single-pick + immediate-apply flow with a SettingsList
overlay where:

- ↑/↓ navigate
- space stages a toggle (●/○ flip in-place; not yet applied)
- enter commits all staged renames at once and triggers ctx.reload()
- esc cancels, no changes applied

Implementation: ctx.ui.custom() builds a Container with header, a
SettingsList (which cycles values on space), and a footer status line
showing pending changes (e.g. 'pending: notify→off, foo→on'). The
wrapper's handleInput intercepts Enter via matchesKey(data, Key.enter)
before SettingsList sees it — SettingsList would otherwise consume
Enter for cycling.

Disable guards still fire on the space-stage attempt: a refused toggle
is reverted via settingsList.updateValue and the reason shown in the
footer. ssh-controlmaster guard during --ssh therefore now refuses at
stage time, not commit time — clearer feedback.

Subdir extensions render as read-only rows (no , so SettingsList
will not cycle them).

Batches multiple toggles into a single ctx.reload() instead of one
reload per change, which was awkward when flipping several at once.
2026-05-07 20:51:13 +02:00
joakimp c624eafe64 ext-toggle: refuse to disable ssh-controlmaster during --ssh session
Disabling ssh-controlmaster mid --ssh session would tear down the
ControlMaster (if we own it) and silently redirect read/write/edit/bash
back to the local filesystem while the system prompt still claims we're
on the remote. Now blocked with an explanatory dialog.

Implementation: a DISABLE_GUARDS map keyed by bare extension name lets
specific extensions register a refusal predicate. ssh-controlmaster's
guard checks process.argv for --ssh and refuses if present. Easy to
extend with similar foot-guns later.
2026-05-07 20:43:20 +02:00
joakimp 9f38ba7797 install.sh: respect /ext disabled state on re-run
When linking, check for <name>.ts.off pointing into this repo and skip
relinking if found. Means a previously /ext-disabled extension stays
disabled across install.sh re-runs (e.g. when adding a new extension).

README + AGENTS updated with the new behavior.
2026-05-07 20:37:11 +02:00
joakimp d2b2b3fb43 Add ext-toggle extension and /ext slash command
extensions/ext-toggle.ts:
  /ext lists ~/.pi/agent/extensions/ with active/disabled markers
  and toggles individual extensions by renaming between name.ts and
  name.ts.off (pi only auto-discovers *.ts). Calls ctx.reload() so the
  change takes effect without restarting pi.

  Subdirectory-style extensions (name/index.ts) are listed read-only
  in v1 — toggling a directory cleanly is more work than the rename
  trick is worth.

install.sh:
  --uninstall now matches both *.ts and *.ts.off symlinks pointing
  into this repo, so a disabled extension is still cleaned up.

README.md / AGENTS.md:
  Document ext-toggle alongside the others; AGENTS notes the API
  surface used (registerCommand, ui.select/confirm/notify, reload)
  and the rename-not-delete design decision.
2026-05-07 20:26:41 +02:00
Joakim Persson b29bf6db2d add confirm-destructive, git-checkpoint, notify extensions 2026-05-05 23:24:31 +02:00
Joakim Persson 96dee97094 ssh-controlmaster: add --ssh-ask-pass flag for password auth 2026-05-05 23:06:52 +02:00
Joakim Persson 9843a1b327 README: document ControlMaster negotiation behaviour 2026-05-05 22:59:59 +02:00
Joakim Persson dee755e291 install.sh: add --only and --skip flags for subset installs 2026-05-05 22:50:27 +02:00
Joakim Persson 6307072b21 init: pi-extensions with ssh-controlmaster 2026-05-05 22:45:08 +02:00