Commit Graph

23 Commits

Author SHA1 Message Date
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 357fcc6eca AGENTS.md: documentation-drift sweep as explicit pre-commit step
Companion to the same addition in the cloud-init and ansible repos.
Caught real drift in those repos in a recent session only because
the user explicitly asked. Codify the sweep with concrete, repo-
specific drift hotspots rather than a vague 'watch for drift' rule
that gets ignored.

Each AGENTS.md addition lists the doc files most likely to fall
behind code changes here, plus a quick-triage one-liner using
'git diff --name-only HEAD | xargs grep -l ...' so the rule is
actionable not aspirational.
2026-05-20 23:12:03 +02:00
joakimp da34f41798 AGENTS: note that extension imports are coupled to host pi version
The 2026-05-09 rename of extension imports from @mariozechner/pi-* to
@earendil-works/pi-* surfaced a sequencing failure: extensions are
loaded by jiti at runtime, and their import statements resolve against
whatever node_modules the running pi binary bundles. A host pi still
on @mariozechner-bundle 0.73.1 cannot resolve @earendil-works/* and
fails extension load with 'Cannot find module'.

Bun build --external '*' does NOT catch this \u2014 it only validates the
bundle shape, not runtime module resolution. The actual gate is
running pi against the extensions on the target system.

Codify the rule in Conventions so future renames (or major-version
deps bumps) sequence the host upgrade alongside the import change.
2026-05-09 21:20:53 +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 feb7add717 ext-toggle: self-guard against disabling /ext
Disabling ext-toggle through its own /ext UI would rename the file to
ext-toggle.ts.off, which pi's auto-discovery skips, so the next /reload
silently drops the /ext command itself. The .ts.off rename also
persists across pi restarts and \u2014 in containerized setups
(opencode-devbox) where ~/.pi is mounted on the devbox-pi-config named
volume \u2014 across container recreate, so even nuking the container
doesn't recover the surface.

Add ext-toggle to the existing DISABLE_GUARDS map so the toggle is
refused at stage-time with an explanation pointing at the manual
recovery path:

    mv ~/.pi/agent/extensions/ext-toggle.ts.off \
       ~/.pi/agent/extensions/ext-toggle.ts

Same shape as the existing ssh-controlmaster guard. Sibling slash
commands (/mcp from mcp-loader) don't need this guard because
settings.json is their source of truth and remains editable by hand
even if the loader is disabled \u2014 only ext-toggle's own UI is the
single point of management.
2026-05-09 16:31:20 +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
joakimp 9218fe512c fix(install): replace declare -A with bash 3 compatible string set
macOS ships bash 3.2 which does not support associative arrays (declare -A,
bash 4+ only). Replace INSTALL_SET associative array with a space-delimited
string and an in_install_set() helper function. All operations preserved:
add, skip/remove, empty-check, iteration, membership test.
2026-05-05 23:33:03 +02:00
Joakim Persson b29bf6db2d add confirm-destructive, git-checkpoint, notify extensions 2026-05-05 23:24:31 +02:00
Joakim Persson 4a804f3619 add AGENTS.md 2026-05-05 23:19:56 +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 5aa8036a6f ssh-controlmaster: reuse system ControlMaster from ~/.ssh/config if present 2026-05-05 22:58:34 +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