Skip to content

fix(deps): repair incomplete bundled-dep entries in demo/package-lock.json - #1078

Closed
dreadster3 wants to merge 46 commits into
agegr:mainfrom
dreadster3:fix/demo-lockfile-wasm-bundled-entries
Closed

dreadster3 wants to merge 46 commits into
agegr:mainfrom
dreadster3:fix/demo-lockfile-wasm-bundled-entries

Conversation

@dreadster3

Copy link
Copy Markdown

Problem

Renovate updates root package.json + package-lock.json and demo/package.json fine, but has never been able to update demo/package-lock.json — every Renovate dependency PR (e.g. #41, #44, #35, #47) ships manifest bumps without the demo lockfile update and carries:

  • a renovate/artifacts status = FAILURE
  • an "⚠️ Artifact update problem" comment like:
##### File name: demo/package-lock.json

npm error code EALLOWREMOTE
npm error Fetching packages of type "remote" have been disabled
npm error Refusing to fetch "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz"

Because the lockfile is left stale, the Demo workflow's npm ci fails on such PRs with the familiar mismatch (from #41):

npm error Invalid: lock file's katex@0.16.47 does not satisfy katex@0.19.0

Root cause

@tailwindcss/oxide-wasm32-wasi (optional wasm32 dep of tailwindcss@4.3.3 locked in the demo) ships bundled dependencies (@emnapi/*, @napi-rs/wasm-runtime, @tybys/wasm-util, tslib). The old lockfile entry declared bundleDependencies but was missing its nested inBundle child entries — an incomplete lockfile round-trip (npm bug family, e.g. npm/cli#9821).

So whenever npm rebuilds the demo tree (including Renovate's npm install --package-lock-only --no-audit --ignore-scripts artifact update), npm tries to repair the inconsistency by refetching the tarball. With allow-remote fetch gating enabled (as in Renovate's npm environment) the fetch is refused → EALLOWREMOTE → lockfile update fails. The root lockfile is unaffected because its oxide-wasm32-wasi@4.2.2 entry is complete (all 6 bundled children recorded).

Reproduced locally: the exact Renovate command fails in demo/ (crash in idealTree:buildDeps on oxide-wasm32-wasi) but succeeds at the repo root; after this fix it passes in demo/ too, even with allow-remote=none.

Fix

Delete the incomplete @tailwindcss/oxide-wasm32-wasi@4.3.3 entries from demo/package-lock.json and re-resolve with npm install --package-lock-only, which writes the entry back complete with all six bundled children. The diff is purely additive (+66 lines, no version changes) — it now matches the structure the root lockfile already has.

After merging, Renovate's lockfile updates for the demo will succeed, so demo/package-lock.json will be updated in lockstep with demo/package.json going forward. To refresh the existing open Renovate PRs, tick their rebase/retry checkbox (or rename titles to start with rebase!).

dreadster3 and others added 30 commits September 28, 2026 02:53
…swap + full UI integration) (#3)

* refactor(subagents): replace built-in engine with user-installed pi-subagents (#1)

Remove the built-in pi-web subagent engine entirely; subagent delegation
now comes from the user-installed pi-subagents package (a standard pi
extension, not an npm dependency). Pi Web no longer strips or manages it.

Removed:
- lib/subagent-{runtime,extension,queue,prompt,input,settings}.ts + tests
- /api/subagents/settings route (builtInEnabled/maxConcurrent settings)
- /api/subagents/[id] POST (steer/abort); route is GET-only legacy now
- built-in profiles (general-purpose/explore/plan) and their injection
- preferPiWebSubagentExtension stripping logic in rpc-manager

Kept:
- legacy session readers (pi-web:subagent metadata) so historical
  sessions keep rendering in the session tree and chat
- agent-profile file editor (/api/subagents/profiles GET/PUT/DELETE);
  pi-subagents reads/writes the same profile files
- SubagentToolDetails type relocated to lib/api-types.ts

AgentsConfig is now a pure profile editor; 8 dead i18n keys removed
(en/zh-CN/zh-TW). Demo mirror synced (pi-subagents 'subagent' tool entry,
rewritten tutorial prose, no removed-engine tools). New ADR 0006
supersedes ADRs 0003/0005; AGENTS.md and demo README updated.

* feat(subagents): pi-subagents agent catalog + live subagent sessions in the UI (#2)

* refactor(subagents): replace built-in engine with user-installed pi-subagents

Remove the built-in pi-web subagent engine entirely; subagent delegation
now comes from the user-installed pi-subagents package (a standard pi
extension, not an npm dependency). Pi Web no longer strips or manages it.

Removed:
- lib/subagent-{runtime,extension,queue,prompt,input,settings}.ts + tests
- /api/subagents/settings route (builtInEnabled/maxConcurrent settings)
- /api/subagents/[id] POST (steer/abort); route is GET-only legacy now
- built-in profiles (general-purpose/explore/plan) and their injection
- preferPiWebSubagentExtension stripping logic in rpc-manager

Kept:
- legacy session readers (pi-web:subagent metadata) so historical
  sessions keep rendering in the session tree and chat
- agent-profile file editor (/api/subagents/profiles GET/PUT/DELETE);
  pi-subagents reads/writes the same profile files
- SubagentToolDetails type relocated to lib/api-types.ts

AgentsConfig is now a pure profile editor; 8 dead i18n keys removed
(en/zh-CN/zh-TW). Demo mirror synced (pi-subagents 'subagent' tool entry,
rewritten tutorial prose, no removed-engine tools). New ADR 0006
supersedes ADRs 0003/0005; AGENTS.md and demo README updated.

* feat(subagents): surface live pi-subagents sessions and progress

Reconstruct parent/child relations for user-installed pi-subagents runs from
disk and the extension's async-status widget, mapping into the existing
relation.kind === "subagent" UI (session families, Agents panel) without a
rewrite. Legacy pi-web:subagent metadata keeps working unchanged.

- lib/pi-subagents-runs.ts: read async-subagent-runs/*/status.json across
  every pi-subagents temp root; map runner state to panel status.
- lib/pi-subagents-snapshot.ts: parse PI_SUBAGENT_ASYNC_JSON: widget lines
  (pure, shared with the demo).
- session-list-scanner: bounded nested enumeration of <projectDir>/<parentBase>
  child session.jsonl files, deduped, without touching artifacts.
- session-reader: derive subagent relations from the child layout, joining
  async run status for live state; foreground children read completed unless
  their parent session is live and the transcript was just written.
- AgentSessionPanel: show live current tool / elapsed / turn and tool counts
  from the widget snapshot, falling back to relation.status.
- i18n agentSwitcher.run.* + status.stopped; demo mock child session.

Verified on the real session tree: 55 pi-subagents children derived, 0
orphans, 26 legacy relations unchanged.

* feat(agents): show the pi-subagents agent catalog in the Agents panel

The panel could only edit profile files under ~/.pi/agent/agents and the
project dirs, so the 13 pi-subagents built-ins (oracle/advisor, scout,
worker, reviewer, ...) and package-provided agents were invisible even though
the runtime dispatches them.

lib/pi-subagents-catalog.ts reads the same files pi-subagents' own discovery
reads — the installed package's agents/, its package manifests, the user dirs
(PI_SUBAGENT_EXTRA_AGENT_DIRS, settings agentScanDirs, ~/.pi/agent/agents,
~/.agents) and the nearest project root's .agents and .pi/agents — without
importing the package (its exports map forbids src/*). It applies
subagents.agentOverrides, defaultModel/defaultProvider/defaultThinking, and
disableBuiltins, and marks same-name rows a higher-precedence source shadows.
It never throws: a missing package or malformed file means "no entry".

GET /api/subagents/catalog?cwd= reuses the profiles route's cwd validation.
AgentsConfig fetches it in parallel with profiles and renders read-only rows
grouped by source, leaving the editor untouched; a failed or empty catalog
falls back to today's profiles-only view.

lib/subagents.ts now discovers (and writes) project profiles at the nearest
project root rather than the session cwd, matching pi-subagents, so a session
opened in a subdirectory sees the same profiles the runtime loads.

The demo mirrors the new files byte-identically and answers the route from
mock/captured/subagent-catalog.json.

* fix(subagents): correct live pi-subagents progress, joins, orphans, delete cascade

Address the Phase 2b review findings for live pi-subagents child sessions:

- AppShell: drop the id/state-only update guard so label, activity and
  timestamps reach the Agents panel instead of freezing the progress line.
- ChatWindow/useAgentSession: apply the `subagent-async` widget in the busy
  reconcile branch, and poll wrapper state every 4s while the snapshot still
  has a non-terminal run (stops once terminal or the wrapper is gone) so
  detached runs refresh while the parent sits idle.
- session-reader: an async child takes its own step's status, not the run
  aggregate, and records the snapshot step node id (`stepRunId`) so a
  multi-step chain maps one run to many rows. Orphaned children keep a dangling
  `subagent` relation (parent id from the directory name) instead of surfacing
  as top-level sessions. `findSessionPathById` also walks nested
  `<parentBase>/**/session.jsonl` so cold deep links resolve.
- AgentSessionPanel: join snapshot nodes by `stepRunId`, then run id, then
  path/name substring; the shared, unit-tested `lib/pi-subagents-progress.ts`
  helper keeps each chain step's own progress and never lets a workflow
  aggregate overwrite a step row.
- DELETE /api/sessions/[id]: remove the session's `<parentBase>/` pi-subagents
  tree, and prune a deleted child's empty run/child directories.
- Nits: first-complete-line snapshot parse, split demo store line, drop the
  never-present totalTokens/totalCost fields, include `label` in the
  ChatWindow key, simplify the "(no messages)" description path.

Tests: new lib/pi-subagents-progress.test.mjs, AppShell.subagent-runs,
ChatWindow.subagent-poll, nested delete cascade + empty-dir prune, nested path
resolution, multi-step chain status/identity, orphan relation; updated panel,
runs, relations, reconcile and session-reader tests. Mirrored demo files stay
byte-identical apart from documented demo-only divergence.

* fix(subagents): normalize step statuses, gate and abort the 4s poll

- pi-subagents-runs: map the on-disk step spellings `pending`→`queued` and
  `completed`→`complete` like the package's own projection, and fall an
  unknown or absent step status back to the run state instead of `failed`.
- ChatWindow: only poll wrapper state while the tab is visible (the
  visibilitychange nudge fetches only when visible again) and abort the
  in-flight fetch on cleanup so a stale response cannot apply widgets.
- session-list-scanner/session-reader: share the `(no messages)` placeholder
  constant so the two cannot drift.

Tests: pin both step spellings + `pending` + unknown/absent fallback; pin the
visibility gate and abort controller in the subagent-poll source test. Demo
ChatWindow mirrors only the fix logic and keeps its documented divergence.

* fix(agents): align catalog discovery with pi-subagents and render project rows

Render the project-scope catalog group the panel already fetched but never
showed, and close the discovery fidelity gaps a reviewer verified live
against pi-subagents 0.71.0:

- apply defaults before overrides so model:false/thinking:false survive
- mirror the applyBuiltinOverrides bulk-disable ladder with override shields
- keep thinking:false as an explicit off the default cannot overwrite
- prune nested .git dirs during the agent walk
- align package enumeration with collectPackageSubagentPaths (project root
  package, installed package and settings roots, project-scope before
  user-scope)
- fold package: frontmatter into the runtime name
- document remaining intentional divergences and the un-gated project reads

* fix(agents): honor disableThinking, drop unrecognized overrides, dedupe builtins

The catalog now mirrors pi-subagents' builtin ladder for
subagents.disableThinking (project value suppresses the user value;
an override that sets thinking survives), drops override entries whose
fields are all unrecognized so they cannot shield a builtin, and keeps
only the first configured pi-subagents install for the builtin dirs so
a user+project install no longer duplicates every builtin row.

* feat(agents): author pi-subagents-format agent profiles

Reshape the agent-profile editor from the removed pi-web engine's schema onto
the installed pi-subagents frontmatter dialect (camelCase keys, raw tool
selectors, tri-state extensions). The old editor wrote keys pi-subagents
ignores and silently dropped mcp:/package tool names on save.

- lib/subagents.ts: new SubagentProfile; parse all pi-subagents keys, migrate
  legacy load_skills/load_extensions/inherit_context/run_in_background/
  prompt_mode/disallowed_tools/max_turns-era files, drop retired keys on save,
  keep tools raw, validate the unions and positive ints.
- app/api/subagents/tools: GET enumerates builtin + extension tools via
  createAgentSessionServices (no session started), cached 60s per cwd.
- AgentsConfig: grouped collapsible sections, tri-state tools/extensions,
  raw-entry chips, no enable switch (agentOverrides owns that).
- i18n en/zh-CN/zh-TW parity; demo mirrors + fixtures.

* fix(agents): preserve presence-sensitive profile fields

Review findings F1–F8 against the pi-subagents frontmatter dialect:

- add the missing `agents.allowNestedSubagents` key to all three locales
  and the demo mirrors so the Launch toggle stops rendering a raw key.
- reject `toolTimeoutMs > 2147483647` on save, the bound pi-subagents
  throws on, and cap the number input.
- keep `allowedAgents` presence-sensitive: a bare key now parses to `[]`
  with an `allowedAgentsDenyAll` marker, and the editor's new "Deny all
  descendants" checkbox writes `allowedAgents: ''` instead of dropping it.
- parse `thinking: false` as `"off"` so an explicit off survives a save.
- normalize the legacy `skill` alias into `skills` and drop the alias.
- bound the tools-route per-cwd cache to 32 entries.
- reject commas in the raw-entry inputs with an inline note.
- reset the raw-entry inputs on profile switch, create, and duplicate.

* fix(subagents): mirror skill-alias precedence, reset raw entries on delete, doc managed keys

* feat(agents): default the panel to running agents with a Show completed toggle

The Agents panel listed every subagent session running-first, so a family
with many finished runs buried the live ones. Default the list to active
subagents (running/starting) and add a header checkbox to reveal terminal
rows, composing with the existing search filter.

A single subagentStatus() helper owns the badge/status precedence; the
list-level status map calls it with the same inputs, so the filter and the
row badge cannot disagree. The empty state distinguishes "no matches" from
"no running agents" and offers the toggle when terminal rows are hidden.

Adds agentSwitcher.showCompleted and agentSwitcher.noRunning (en/zh-CN/zh-TW)
and mirrors the component and locale files into demo/.

* feat(agents): disable and duplicate read-only pi-subagents agents

Built-in and package catalog rows were read-only: pi-subagents owns their
enable/disable through `subagents.agentOverrides.<name>.disabled` in settings,
and `eject` copies a bundled agent into an editable file, but nothing in the
panel wrote either.

- lib/subagent-overrides.ts: setSubagentOverrideDisabled writes only the user
  settings file (the `<agentDir>/settings.json` the SDK's FileSettingsStorage
  derives). Disable sets `disabled: true`; enable deletes that key and prunes an
  empty override entry, `agentOverrides`, then `subagents` like pi-subagents'
  own enable action. Everything else survives via a targeted JSON mutation and
  the shared atomic write, keeping the file mode. PUT /api/subagents/overrides
  exposes it with the profiles route's cwd allow-list and JSON/origin checks.
- lib/subagents.ts: ejectSubagentProfile copies a catalog file verbatim into the
  global or project agent dir, conflicts on an existing name (409), and returns
  the parsed profile. POST /api/subagents/eject requires the source to be a file
  the catalog discovered for the cwd and defaults the name to the source's
  canonical runtime name so the copy shadows it.
- AgentsConfig: the read-only catalog detail gets a Disabled switch that PUTs
  the override then refetches profiles + catalog, and a Duplicate flow that
  picks a scope, prefills the canonical name, and POSTs the eject. A
  project-scope disable locks the switch with a hint (project wins in the
  disable ladder); the row's effective disabled state is never re-derived.
- i18n keys in en/zh-CN/zh-TW + demo mirrors; demo mock answers both routes
  from in-memory state.

Tests: override write semantics (create/cleanup/idempotence/pruning/permissions)
and eject copy semantics (verbatim/conflict/name/validation/both scopes);
overrides PUT and eject route happy paths, 409, and security rejections;
component source tests for the toggle and duplicate wiring.

* fix(i18n): add missing agentSwitcher.status.queued label

* fix(agents): correct disable provenance, eject scope, symlink writes

Review findings on the disable/duplicate built-in controls:

- Disable provenance (was wrong): the catalog row's `overriddenBy` is file
  shadowing, not the reason a row is disabled. Add `disabledSource`
  ({ scope, via }) to `AgentCatalogAgent`, set whenever the effective
  `disabled === true`: the builtin ladder records a project/user override or
  `disableBuiltins` bulk at its scope, and a custom row applies user then
  project distinctly. The same-name winner's `disabled` + `disabledSource`
  are projected onto every shadowed row, since the winner is what the runtime
  dispatches. AgentsConfig locks the switch only when the user-scope toggle
  could not undo the disable (bulk at either scope, or a project override);
  a user override stays switchable. i18n hint keys reworked to scope/via-aware
  copy in en/zh-CN/zh-TW plus mirrors.
- Symlinked settings write-through: resolve the settings path with
  realpathSync before the atomic rename so a dotfile-managed link survives.
- Eject scope: `ejectSubagentProfile` and POST /api/subagents/eject now only
  accept `builtin`/`package` sources, matching pi-subagents' handleEject; the
  UI hides Duplicate on user/project rows.
- Failed-parse cleanup: delete the written copy before returning the 400.
- Documentation: AGENTS.md notes the symlink-safe unlocked writer (parity with
  pi-subagents) and the disable-provenance semantics.

Demo fixture/mock surface `disabledSource` for disabled rows (bulk, user
override, project override) so the hint path is demonstrable.

Tests updated/added: catalog disabledSource + shadow propagation, symlinked
settings write-through, eject scope rejection + failed-parse cleanup, and the
UI predicate source test.

* feat(agents): show the system prompt for read-only catalog agents

* chore: trigger CI (review completed, no changes requested)

---------

Co-authored-by: pi-web-agent <agent@pi-web.local>

---------

Co-authored-by: pi-web-agent <agent@pi-web.local>
* chore(npm): rebrand fork for @dreadster3/pi-web publish

- package.json: name @dreadster3/pi-web, version 0.0.1, fork homepage/repository/bugs,
  publishConfig { access: public, provenance: true }, exclude public/sw.test.mjs from tarball
- app-update: check @dreadster3/pi-web on the registry; release URLs -> dreadster3/pi-web
- web-push: default VAPID subject -> fork repo URL
- session-liveness: registry keys -> @dreadster3/pi-web/session-liveness/v1
- READMEs (en/ja/ru/zh-CN): install commands, demo URL, screenshots -> fork
- docs/release.md + demo mock content: package name and repo links -> fork
- package-lock.json: root name synced

* fix(npm): remediate review findings on the publish rebrand

- F1 (blocker): drop provenance from publishConfig — it throws EUSAGE for
  local publishes (OIDC-only); documented in release.md
- F2: replace undocumented file-level negation with public/.npmignore
- F3: add fork copyright line to LICENSE (upstream notice retained per MIT)
- F4: sync package-lock root version to 0.0.1
- F7: release.md — direct build+publish for the first 0.0.1 release;
  npm pack --dry-run verification step added
)

* feat(sidebar): list local branches in the worktree switcher for quick switching

The worktree dropdown now shows a LOCAL BRANCHES section under the worktrees.
Clicking a branch that already has a checkout selects that worktree; clicking
one without a checkout creates a worktree for that branch and switches to it,
reusing the existing POST /api/worktrees flow (addWorktree already checks out
an existing branch without -b, so no new git plumbing). The branch checked out
in the selected worktree gets the checkmark; the section duplicates no removal
buttons, and the dropdown filter applies to branch names too.

- lib/worktree.ts: listLocalBranches() via for-each-ref, sorted by refname
- app/api/worktrees: GET returns branches[], empty for non-git dirs
- components/SessionSidebar.tsx: branches in WorktreeState, handleUseBranch()
  shared by branch rows and the "New worktree" input
- lib/i18n: sidebar.localBranches (en, zh-CN, zh-TW)
- demo: adapted copy + mocked branches in the worktrees route
- tests: listLocalBranches coverage, sidebar branch-list assertions

* fix(sidebar): branch-switcher follow-ups from review

- F1: pin `listLocalBranches` on an unborn-HEAD repo (no commits) returning
  `[]`, so a future switch to `show-ref` (exit 1 there) cannot regress silently.
- F2: gate `visibleBranches` on `showWtFilter && wtFilter.trim()` exactly like
  `visibleWorktrees`, so an unmounting filter input cannot leave the branch list
  filtered by an invisible value (both the root and demo copy).
- F3: reword the `wtSectionLabelStyle` comment — only LOCAL BRANCHES has a
  rendered header.
- F4a: track `wtBusyBranch` and show the existing `sidebar.creating` label on
  the clicked branch row while its worktree is being created.
- F4b: show the filter once `worktrees + branches >= 8` instead of worktrees
  alone, so branch-heavy repos are filterable.

* chore: update gitignore

* feat(sidebar): delete local branches from the worktree dropdown

Each branch row in the worktree switcher gains a bin button for branches no
worktree holds. Deleting asks for confirmation first; an unmerged branch
returns 409 and re-asks as a force-delete retry, mirroring the dirty-worktree
flow. Backend: lib/worktree.deleteBranch plus DELETE /api/branches.
)

* fix(agents): qualify live subagent progress joins by run id (#5)

Step node ids are unique per run only: every async run starts its first step
at "step:0", and 49 rows in one session family carry that same value. Joining
a step node on `stepRunId` alone therefore let the one live run claim the
first finished row in family order, so the Agents panel badged finished
reviewer/worker rows Running with the live run's ticking activity while the
genuinely running agent had no row at all.

A step node now joins only the row that records both the owning run
(`relation.runId`) and the step (`relation.stepRunId`). A nested run the
package lifts back to the top level has no owning run node left, so run nodes
(and only run nodes — the `step:<n>` placeholder would repeat the mis-join)
may still join on `stepRunId`. Claimed nodes are tracked alongside claimed
rows so no node maps to two rows.

Regression tests reproduce the live "2 running" snapshot and fail on the old
join.

* fix(agents): refetch sessions once per newly appearing live run (#5)

The Agents panel derives its family from the sidebar's sessions list, which
does not contain a subagent session spawned mid-turn. While that row was
missing the panel showed "No running agents" (or joined the live run to an
unrelated row), so the running count and the status filter disagreed with the
live widget.

AppShell now remembers the top-level run ids it has seen and bumps
`refreshKey` once per newly appearing non-terminal run id. That re-runs
SessionSidebar's loadSessions effect, whose onSessionsChange feeds
sessionCatalog and therefore the panel's family. One bump per id: no timers,
no per-tick loop, and `step:<n>` step children are skipped since the run they
belong to already covers them.

* fix(agents): restrict bare stepRunId join to run nodes (#5)

The lifted-nested-run pass excluded only kind "step", leaving package
`host-step` children — whose ids are author-supplied monitor names — a live
path into the same cross-run misjoin: a monitor whose id collided with another
run's lane key could claim that run's finished row. Only run nodes may join on
a bare `stepRunId`, and run nodes are exactly the kinds "subagent"/"workflow"
that the package's kindForMode emits, so the pass now allows those and skips
every placeholder.

AppShell's freshness effect gets the same allowlist: a non-terminal
`host-step`/`step` id cannot spawn a subagent session, so it must not consume a
forced sessions scan.

Tests: a colliding host-step child claims nothing, and a doubly-visited step
node leaves unrelated finished rows unclaimed. That step case exercises the
kind allowlist and the claimedNodes guard together, so it fails only when both
are removed; claimedNodes alone is pinned by the run-node double-visit test
added next.

* test(agents): pin the claimedNodes guard against a doubly-visited run node (#5)

The existing doubly-visited case fed back a *step* node, whose kind the run-node
allowlist already rejects: it only fails when the allowlist and claimedNodes are
both removed, so it pinned their conjunction and left claimedNodes itself
unpinned. A *run* node is exactly what the allowlist admits, so walking one
twice — outer with its `inner` child, plus `inner` twice more as flattened roots
— reaches `grand` by run id once the guard is gone, and one node badges two rows.

Verified by mutation: dropping every claimedNodes check fails this test and
leaves the step case green; with the guard both pass.

* fix(agents): refetch sessions when a live run reaches a terminal state (#5)

A finished subagent row kept its Running badge for minutes after the run
completed. The badge came from the client's catalog: the refetch triggered
when the run first appeared live recorded `relation.status: "running"`, and
SessionSidebar's 2.5s running poll reloads the list only when
`sessionListVersion` changes — nothing bumps that version when a child
transcript's meta flips terminal, so the stale row survived every poll.

The run-start refetch now has a terminal counterpart: once a run this client
watched live publishes a terminal state, the list is refetched once more. The
rule lives in `trackPiSubagentSessionRefetches` next to the rest of the
snapshot helpers, so both AppShell copies share it and the bump count is
testable; `startedIds` doubles as the record of runs seen live, so a run first
publishing as terminal (already persisted finished) still costs nothing, and a
repeated terminal publish does not bump again. Both sets are refs, since
ChatWindow resets `subagentRuns` on unmount while the handled-id record must
outlive it.
… composer

Demo copies of ChatInput and the lib are byte-identical mirrors; the
cherry-picked upstream feature (fd037e4) would have diverged the demo
copy, so both mirrors carry the same change.
chore: sync upstream (agegr/pi-web @ fd037e4) + demo mirror
* feat(theme): register Catppuccin Latte and Mocha palettes

Add the two palette ids after Pine, keep Mocha in the dark-class chain
for first paint and useTheme, and cover the ordering, dark/light
classification, and preference validation in theme.test.mjs.

* feat(theme): add Latte and Mocha palette token blocks

Canonical Catppuccin surfaces, text, and accents, with the muted/dim/accent
steps nudged just far enough to keep every foreground/background pair at
WCAG AA (the bar e2e/themes.mjs already asserts).

* feat(theme): draw a cup glyph for Latte and Mocha

Same mug shape for both, distinguished by the saucer (Latte) and steam
(Mocha), so the pair reads as one family in the appearance grid.

* i18n(theme): label the Latte and Mocha options

English keeps the upstream names; the Chinese locales follow the existing
convention of translating palette names as 拿铁 and 摩卡.

* test(theme): cover the two new palettes in the browser checks

Extend the settings-panel palette list, and teach e2e/themes.mjs the
Latte/Mocha ids and labels plus the Mocha dark-class expectation.

* fix(e2e): keyboard nav lands on Mocha; align Mocha bg-subtle alpha
The fork's branch quick-switch added `handleUseBranch` right after
`handleDefaultCwd`, so the source slice the project-identity test reads
(handleDefaultCwd → handleCreateWorktree) swallowed a `setSelectedCwd`
call and the assertion failed. Move `handleUseBranch` above
`handleDefaultCwd` instead: `handleCreateWorktree` closes over it, so
moving it below would trip the temporal dead zone.

Functionally neutral — no body change, only declaration order. Follow-up
to upstream 433d09e, which added the test and the commitCustomPath
`remember` param the slice also asserts on.
…ally

Port the byte-identical mirrors from upstream 6a1246e so the demo copies
stay byte-identical mirrors of the root files as they were before the
merge: ChatInput, ModelSelector, useAgentSession, models-cache and the
three i18n message files, plus the new SelectorRow both selectors import.
ChatWindow and SessionSidebar are pre-adapted demo copies, so they get the
feature hunks from 6a1246e (defaultModel/savedDefaultThinkingLevel props
into ChatInput) and 433d09e (commitCustomPath's remember param plus the
handleDefaultCwd -> commitCustomPath hand-off) rather than a byte copy.
Their divergence from the root is unchanged, 3 and 39 diff lines.
The model and reasoning selectors' star now writes the global default
through PUT /api/models/default (upstream 6a1246e); the demo has no real
models.json, so the mock records the pick in memory and serves it back as
`defaultModel` / `savedDefaultThinkingLevel` from GET /api/models, the
same way the other mutable settings panels work in the demo.

POST /api/default-cwd already exists and needs no change: 433d09e only
made the client select its result through /api/cwd/validate, which the
mock's cwdRoute already answers for SCRATCH_ROOT.
Fold-in from review: reject empty provider/modelId and validate the
thinking level set, mirroring app/api/models/default/route.ts 400s.
…ence fix + default-CWD folders (#12)

* fix: do not persist new-session model picks into global defaults (agegr#871)

* fix: do not persist new-session model picks into global defaults

Selecting a model for a single Pi Web chat was rewriting
~/.pi/agent/settings.json via persistExplicitStartupPreferences, so a
one-off DeepSeek/flash pick silently became the CLI/global default.
Align with the TUI: session-scoped selection unless the user explicitly
opts into persist (future "Save as default").

* feat: explicit save-as-default for model and reasoning level

With new-session picks now session-scoped, pi-web had no way left to change
the default model or reasoning level. Add the Web counterpart of the TUI's
Ctrl+S: a star on each row of the model selector and the reasoning menu.

- PUT /api/models/default writes defaultProvider/defaultModel or
  defaultThinkingLevel, only for models the selector can offer, and refuses
  with 409 when a trusted project's .pi/settings.json overrides the key.
- The star saves the default and selects it for the current chat.
- Menu rows use SelectorRow, styled like session-list rows: the save star
  floats on hover/focus (always visible on touch), the default gets an
  inline marker.
- Remove the no-op startup-preferences helper and its call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: simpler default marker in model and reasoning menus

Drop the session-list accent bar on the active row and the inline star
after the default's name. The default row now shows a small static grey
star in the right gutter, the same spot where the save button floats in
on hover or focus, so an idle menu carries a single marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: luwanglin <luwanglin@meituan.com>
Co-authored-by: Alex Yang <agegcn@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(default-cwd): create dated folders under ~/pi-cwd using the local date (agegr#996)

"Use default directory" now opens ~/pi-cwd/YYYYMMDD instead of scattering
~/pi-cwd-YYYYMMDD folders across home. The date still keeps a first-time user
out of folders that hold their own data and gives a fresh scratch cwd per day.

The stamp is the local calendar date; toISOString() gave the UTC one, so in
UTC+8 the folder was named after yesterday until 08:00.

The default directory is now selected exactly like any other directory.
/api/default-cwd only creates the folder and returns its path; the sidebar
then passes it to commitCustomPath(), so /api/cwd/validate checks it, adds it
to the file allow-list, and resolves its project identity. The only difference
is remember: false, so it does not replace the custom-path picker's last path.

With that, the allow-list no longer scans home for default cwds at all. Older
~/pi-cwd-YYYYMMDD folders with sessions are ordinary session cwds, and
selecting any dated folder again goes through /api/cwd/validate.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sidebar): keep handleDefaultCwd next to the worktree handlers

The fork's branch quick-switch added `handleUseBranch` right after
`handleDefaultCwd`, so the source slice the project-identity test reads
(handleDefaultCwd → handleCreateWorktree) swallowed a `setSelectedCwd`
call and the assertion failed. Move `handleUseBranch` above
`handleDefaultCwd` instead: `handleCreateWorktree` closes over it, so
moving it below would trip the temporal dead zone.

Functionally neutral — no body change, only declaration order. Follow-up
to upstream 433d09e, which added the test and the commitCustomPath
`remember` param the slice also asserts on.

* chore(demo): mirror the upstream model-default selectors byte-identically

Port the byte-identical mirrors from upstream 6a1246e so the demo copies
stay byte-identical mirrors of the root files as they were before the
merge: ChatInput, ModelSelector, useAgentSession, models-cache and the
three i18n message files, plus the new SelectorRow both selectors import.

* chore(demo): port upstream's feature hunks into the adapted copies

ChatWindow and SessionSidebar are pre-adapted demo copies, so they get the
feature hunks from 6a1246e (defaultModel/savedDefaultThinkingLevel props
into ChatInput) and 433d09e (commitCustomPath's remember param plus the
handleDefaultCwd -> commitCustomPath hand-off) rather than a byte copy.
Their divergence from the root is unchanged, 3 and 39 diff lines.

* chore(demo): stub PUT /api/models/default in the mock

The model and reasoning selectors' star now writes the global default
through PUT /api/models/default (upstream 6a1246e); the demo has no real
models.json, so the mock records the pick in memory and serves it back as
`defaultModel` / `savedDefaultThinkingLevel` from GET /api/models, the
same way the other mutable settings panels work in the demo.

POST /api/default-cwd already exists and needs no change: 433d09e only
made the client select its result through /api/cwd/validate, which the
mock's cwdRoute already answers for SCRATCH_ROOT.

* chore(demo): validate default-model mock like the real route

Fold-in from review: reject empty provider/modelId and validate the
thinking level set, mirroring app/api/models/default/route.ts 400s.

---------

Co-authored-by: luck <wllu@stu.xidian.edu.cn>
Co-authored-by: luwanglin <luwanglin@meituan.com>
Co-authored-by: Alex Yang <agegcn@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
packages/apps/overlays/devShells built by buildNpmPackage with src = self and
npmDepsHash from the dotfiles packaging; dev input nixpkgs/nixos-unstable.
chore: ignore .pi/ (local agent config)
* feat(cli): add --version flag to pi-web

Read the version from package.json next to the bin entry and print it
before any build-artifact or port validation, so it works on an
installed package and without a .next directory. --help keeps winning
when both flags are passed.

* test(cli): make --version e2e test actually run without build artifacts
…#18)

* feat(agents): live-refresh subagents panel (poll + activate-on-focus)

SettingsPanel keeps every visited section mounted behind `hidden`, so the
agents panel stayed stale across section flips, tab returns, and edits made
by a terminal or the agent runtime. Give the panel its own visibility
(`active`) and a small refresh hook: the hidden -> visible edge refetches
after a 250ms debounce, and a 10s quiet poll keeps profiles, catalog and tool
options current while the panel is the visible section in a visible tab.

A poll is skipped when the draft is dirty, when a save/override/eject (or the
load it triggers) is in flight, and while another section is shown; requests
are AbortController-able and cancelled on unmount. Quiet loads never touch the
selection or an open draft — only a view-mode detail re-reads its file, and
`draftDirty` compares the draft against its loaded baseline so an external
edit cannot latch the panel permanently dirty.

The hook lives in hooks/ following the sibling hook + colocated test
convention, and drives a controller with injectable timers/document so the
tests can exercise every gate with mock timers. demo/ copies stay
byte-identical.

* fix(agents): quiet poll failures must not wedge the error banner (+ activation-gate test)

* test(agents): mock setInterval in hook tests so failures fail fast instead of wedging
Version bump 0.0.3 -> 0.0.4 (published to npm by maintainer).
Also repairs upstream 2bb48f5's lockfile defect: the 0.99.1
pi-codemode/pi-mcp entries shipped without 'integrity', which
panicked NPM_FETCHER_VERSION=2 prefetch-npm-deps and blocked
npmDepsHash recompute + nix build. Integrity values are the
registry-published dist.integrity. New hash
sha256-+sfleedrMv+estAmMXkyyvAXGs0BicXxyxZbEzlt1Dk= verified via
forced-mismatch nix build (got: == prefetch result) and a green
nix build .#pi-web (pi-web-0.0.4, wrapper --version smoke: 0.0.4).
… MCP

Re-mirrors the files the upstream sync changed whose demo copies were still
byte-identical to the fork's merge base (AgentsConfig, BranchNavigator,
useKeyboardShortcuts, agent-event-wire, settings-navigation, i18n), plus the
two client-safe modules AgentsConfig now imports (display-path,
settings-ui-helpers).

Adds the MCP overview to the demo's in-browser mock so a copied Settings › MCP
panel finds a route rather than a 404; Test and sign-in are refused with the
demo notice, as installs are.

The remaining pre-adapted mirrors are deferred to a follow-up PR.
dreadster3 and others added 16 commits October 2, 2026 19:30
* fix(settings): enlarge skill and plugin group switches

* feat(chat): fork a session while it is running (#1023)

Fork refused any running session, but that limit was pi-web's own: pi's
AgentSessionRuntime.fork() aborts the run and swaps sessions because the
TUI holds one session per process. pi-web's fork never goes through it -
it opens a separate SessionManager on the source file and copies the path
with createBranchedSession(), so the running AgentSession is untouched.
The only reason fork refused a running source was the shutdown that
follows it, a leftover from when fork mutated the wrapper in place.

fork and fork_branch now refuse only a running `!` shell command. The
copy needs finished entries alone, which pi appends synchronously in this
process, so the file already holds them. An idle source is still shut down
after a fork; a running one keeps its run and stays marked as running in
the sidebar. A source whose first assistant message has not landed has no
file yet and gets pi's "has not been saved yet" wording instead of an
opaque "Entry not found". The fork button and the quoted "ask in new chat"
action now stay available during a run, and a failed fork shows a notice
instead of only logging.

In-session branching stays blocked mid-run, as pi requires: one file has
one leaf, and the running agent appends under it. BranchNavigator,
however, still switched while running - it loaded the other branch into
the view while navigate_tree failed silently on the server, so the live
run rendered under the wrong branch. It now renders read-only with a note
while the session runs or compacts, and handleLeafChange refuses too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): preserve drafts when selecting history edits

* docs(adr): record how MCP and Code mode are supported (ADR 0006)

pi 0.99 ships codemode, tool_search and MCP as built-in extensions of the
CLI; pi-web loads none of them. ADR 0006 records how pi-web will: load the
three factories as builtin entries so -builtin:<name> and replacement work
as in the CLI, let a per-wrapper host register MCP servers lazily before
each prompt so changes apply on the next message without a reload, manage
servers from a Settings section that works without a session, and keep
the safety work (environment scrubbing, per-entry approval of project
mcp.json, Read-only policy, narrower file references) invisible to users.
It also lays out the phased rollout this series starts with P0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(subagents): register the subagent control tools as model-only

Agent, get_subagent_result and steer_subagent are now registered with
exposure "model-only". pi declares and activates them like direct tools,
but never lets another tool reach them through ctx.executeTool(), so a
codemode script cannot start, collect or steer a subagent. A run started
from a script would record the nested call id as its parentToolCallId,
which no transcript entry carries, and the chat would lose its link to the
child session.

An integration test drives a real AgentSession to show the model can
still call them while ctx.executeTool() cannot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(files): stop authorizing paths from system messages and non-coding tool results

/api/files?sessionId= reads a file outside the allowed roots when its path
appears in the session. Transcript system messages carry every tool schema,
and MCP, codemode and third-party extension results relay text a remote
party controls, so any path in them became readable.

Only pi's coding tools and the subagent tools now authorize paths from
their result text. Other results still authorize their
details.fullOutputPath and the arguments of the coding-tool calls they made
through ctx.executeTool(); context_edit replacements and the codemode
store never count. User and assistant messages and the model's own tool
call arguments count as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(mcp): load the SDK's unexported MCP modules and scrub stdio environments

MCP support needs SDK modules the package does not export: the connection
class, the stdio transport, the mcp.json editor and OAuth sign-in.
lib/pi-sdk-internals.ts loads them by file URL from the SDK copy pi-web
runs, refuses a second copy (PI_PACKAGE_DIR, a realpath mismatch, a
different pi-mcp instance), and caches one load per process on globalThis.
Contract tests fail on an SDK upgrade that moves them.

lib/mcp-transport.ts builds the transport factory: stdio servers start with
inheritEnv: false and the environment project bash commands get, so
PI_WEB_PASSWORD, PORT, NODE_ENV and NEXT_* never reach them, and an entry
that references PI_WEB_PASSWORD is refused. A fixture MCP server checks
both end to end.

Nothing imports either module yet. Loading them was checked under
Turbopack dev and a webpack production build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(tools): stop withExtensionTools from forcing inactive tools on

withExtensionTools() re-activated every direct or model-only extension tool
whenever a preset was applied, ignoring defaultActive: false, so loading
codemode and tool_search would have switched both on in every session, and
a preset change dropped what an extension or tool_search had activated.

resolveActiveToolNames() replaces it: a preset replaces only the coding
tools and every other tool that is active, registered and not hidden is
carried. Startup carries the SDK's initial loadout (so +codemode from
defaultTools survives), set_tools the current set, reload the set pi
rebuilt. After navigate_tree, which restores the branch's loadout from its
transcript, a normal session applies its pinned selection again, so a
Read-only pin no longer brings write and edit back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sessions): treat a wrapper as gone once shutdown starts and bound session_shutdown

Closing an MCP connection has no upper bound, so a wrapper could sit in
session_shutdown indefinitely while still registered and alive.

isAlive() now turns false as soon as shutdown() starts, so routes stop
sending work to a wrapper about to be disposed. Extensions get
PI_WEB_SHUTDOWN_DEADLINE_MS (5 s by default) to handle session_shutdown,
after which the wrapper logs once and disposes anyway; the timer is
unref'd. A wrapper removes its registry entry only while the entry still
points at it, so a late cleanup cannot unregister its replacement.
The new variable is documented in the README and pi-web --help.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(sse): slim nested tool events and coalesce tool updates

A codemode script's tool calls emit tool_execution_* events with
parentToolCallId, and codemode republishes every call made so far on each
start and end. The SSE projection now sends nested starts and ends slim
and drops nested updates (testing for nesting before the update rebuild,
which lost parentToolCallId), removes result from every
tool_execution_end (the browser reads only its ids; bash could carry
1 MiB), omits entry_appended, and keeps the newest 200 codemode calls.

The stream coalesces tool_execution_update per toolCallId (latest wins
within 150 ms) and discards a pending update before forwarding that call's
end. The wrapper no longer replays nested ids on reconnect, and the client
keeps nested events out of its running-tools phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): queue extension dialogs and custom panels by request id

useAgentSession held one extension dialog and one custom panel. Two
concurrent requests (tools running in parallel, each gated by a permission
extension) overwrote each other, and the hidden one never got an answer,
hanging the run until Stop.

Both are now FIFO queues keyed by request id: the head is shown with a
"+N more" label, an answer, cancel or extension_ui_closed removes exactly
its id, a reconnect replay does not duplicate an entry, and a custom
panel's re-render updates its entry in place. A tool change that rebuilds
the wrapper clears both queues, since the old wrapper's close events never
arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(settings): share settings panel blocks and localize Plugins and Skills

The Plugins and Skills panels now build on shared blocks in
components/SettingsUi.tsx (scope tag and switch, add-source panel, detail
grid, footer status, trust notice), itemsToSwitch() and lib/display-path.ts,
which the MCP section will reuse. OAuthPastePanel is extracted from
ModelsConfig.

Plugins' hard-coded English moves to i18n (en, zh-CN, zh-TW), and reasons
that lived only in a title tooltip, which a touch screen cannot show, are
visible text: why the project scope is unavailable, why Reload session
needs an open session, and the footer diagnostics. Subagent profile paths
in a sibling folder that shares the cwd prefix are no longer shown as
./-other/...

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): carry only active tools and keep session tools across navigation

resolveActiveToolNames() still added every extension tool pi activates on
registration on each apply, so a direct tool an extension had switched off
came back after reload, and navigation overrode a branch's recorded
loadout. pi already activates those tools itself, all of them when it
builds or reloads a session and each newly registered one later, so the
carried set holds them: nothing else is added now.

Navigation carries the session's own tools over from the branch it left:
codemode, tool_search and pi-web's subagent tools, which the built-in
subagent setting turns on for the whole session. Other extension tools
follow the target branch's loadout, as in the pi CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sessions): wait for a closing wrapper before reopening its session

Once a closing wrapper reported itself dead, the next request started a
replacement on the same session file while the old one was still in
session_shutdown. An extension appending there would branch the file away
from the replacement, and dispose() releases provider resources such as a
Codex websocket by session id, which the replacement shares.

startRpcSession() now waits for the closing wrapper to dispose, at most the
shutdown deadline plus a second, sharing the wait through the start lock.
setRpcSessionTools() waits for that or for a start already under way, then
applies the selection to the wrapper it left, instead of writing through a
second SessionManager and reporting success for a start that came up
without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): drop extension UI requests the server closed while the stream was down

A close event sent while a tab's SSE was down never arrives, so the queued
request stayed at the head and hid every later one; a stale custom panel
could not be dismissed at all.

The connected event now lists the extension UI request ids the session
still holds, which it replays right after, and the client keeps only those
in both queues, preserving the surviving entries so typed input survives a
reconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(mcp): load the codemode, tool-search and mcp built-ins in normal sessions

The pi CLI prepends these from a module the SDK does not export, so Pi Web
sessions loaded none of them and "defaultTools": ["+codemode"] did nothing.
Normal sessions now add the SDK's exported factories under the CLI's names
as replaceable built-ins, so -builtin:<name>, project overrides and
replacement by a third-party extension behave as in the CLI. Chat-only and
subagent sessions still load none of them.

The MCP extension gets a loadConfig that returns no servers, keeping only
the file's autoEnableCodemode: it connects nothing from mcp.json on
session_start, so its startup wait, which ignores Stop, never arms. Pi Web
will register the servers a session connects itself (ADR 0006). Servers it
is given connect through the environment-scrubbing transport, and /mcp
login opens no browser on the server host. MCP stays off when
PI_WEB_DISABLE_MCP is set or the SDK internals adapter cannot load.

Code mode is offered only after a once-per-process self-test has run a
script through the SDK's own sandbox, whose QuickJS worker and wasm are
resolved from the SDK's files at run time; a tool that fails every call is
worse than none. A built-in that cannot run keeps its builtin:<name> entry
with an empty factory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(chat): show a codemode call as its script and the tool calls it made

A codemode card showed its input as JSON, with the script escaped into one
string, and its result began with the "Script completed / Wall time /
Output:" header. While a script ran, the card stayed empty and the status
line said only "Running codemode", because its progress snapshots carry
calls but no text.

The card now shows the script highlighted as JavaScript, the calls it made
as rows inside the card (status, arguments, duration, error, model cost and
total), and the output without the header. Collapsed, it shows the
script's first meaningful line and the call count. The newest 20 calls are
listed and earlier ones fold behind a button; a progress snapshot's
omitted calls are counted. Running snapshots go to activeToolResults like
shell output, so calls appear as they run, and the status line names the
newest running call. Streamed input and a third-party "codemode" tool
without a code argument keep the generic view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(tools): write the Code mode choice through /api/tools/settings

Code mode offers Automatic or Always on (ADR 0006). Automatic writes
nothing: codemode registers inactive and the MCP extension activates it
when a codemode-exposure server connects. Always on adds +codemode to the
global defaultTools, so new sessions start with it active.

/api/tools/settings stays the only writer of that key. GET reports the
choice on every platform, and PUT takes either { enabled } for the
PowerShell switch, still Windows-only, or { codemode }. The edit drops only
the entries naming codemode and appends +codemode, which works on plain
and modifier-only lists alike; a modifier-only list left empty is removed,
since defaultTools: [] means no tools rather than pi's defaults.

Both switches now share lib/global-settings-file.ts, a locked
read-modify-write that takes the lock pi's SettingsManager takes on the
same file. The route reads the two values one after the other: run
together, the second reader found the lock held and backed off for a
second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(mcp): block MCP tools without readOnlyHint in read-only sessions

A Read-only session pins coding tools that cannot change anything, but MCP
tools stay callable beside them, directly or from a codemode script. A
tool_call policy now blocks every MCP tool whose server does not mark it
readOnlyHint: true while the session's pinned selection is read-only: it
names tools and none of bash, powershell, edit or write. No pin follows
pi's configured tools and is not read-only.

MCP tools are those the MCP extension registers, and any tool named
mcp__ by another MCP extension. A codemode script's calls pass through
tool_call too, so they are blocked the same way. The pin is read from the
session entries, which set_tools writes before applying it and cannot
change mid-run. The hint is the server's own, so the policy guards against
model mistakes, not against a malicious server (ADR 0006).

The MCP test fixture gains a record tool without annotations, so the
blocked path can be tested without starting processes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(mcp): connect mcp.json servers through a per-session MCP host

The MCP extension connects nothing from mcp.json on its own. A host
beside it now reads the global and project mcp.json and hands the servers
a session should connect to the extension with pi.registerMcpServer()
(ADR 0006):

- Nothing connects until a prompt starts a run. Before it, the wrapper
  asks the host to sync: entries whose canonical JSON changed are
  unregistered and registered again, so an edit made anywhere reaches
  every open session on its next message. The prompt then waits up to
  10 s for servers still connecting; one that outlasts a full wait is not
  waited for again.
- The wait runs in the wrapper before AgentSession.prompt(), because
  before_agent_start runs before a run has an abort signal. Stop ends it
  and rejects the message unsent, which returns it to the composer.
- The extension reports no connection state, so the host watches the
  transports it opens through pi-web's factory, without wrapping them:
  ready once every tool and resource list is answered, failed when the
  transport closes first or cannot be built.
- A server is unregistered only once its transport exists. The extension
  assigns the connection it closes only after loading the MCP runtime, so
  unregistering earlier left it nothing to close while it connected the
  server anyway.
- A host idle for PI_WEB_MCP_IDLE_MS (10 minutes) after its last run
  unregisters its servers; the next prompt registers them again.
- The host stays inactive unless /mcp belongs to builtin:mcp, so a
  replacement MCP extension never receives servers to connect through its
  own transport. Project entries wait for approval, which nothing grants
  until the Settings panel lands, and servers with codemode exposure are
  registered as deferred when the codemode sandbox cannot run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(chat): label MCP calls server/tool and indent their JSON results

MCP tools are registered as mcp__<server>__<tool>, sanitized and shortened
with a hash past 64 characters, and their cards showed that name. A card
now reads server/tool, as pi's TUI labels them, taking the real names
from the result's details when it has arrived and parsing the registered
name until then; the registered name stays in the tooltip. The calls a
codemode script makes and the running-tool status line use the same
label.

MCP servers often answer with one compacted JSON document as text; such a
result is shown indented. Anything else, including JSON surrounded by
other text, is shown as sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(adr): record that ADR 0006 P1 is in place

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): idle out servers registered for a prompt that starts no run

The MCP host stopped its idle timer when a prompt began preparing and
started it again only at agent_end. A prompt that registers servers and
then starts no run never reaches agent_end: Stop during the wait, a slash
command such as /mcp, a preflight that rejects the message. Its servers
stayed connected for as long as the session was open, whatever
PI_WEB_MCP_IDLE_MS said.

The timer now also starts when prepareForPrompt() returns and whenever a
sync finishes, so a sync that Stop gave up on and that completes later in
the queue still counts. It stays stopped from agent_start to agent_end and
while a prompt waits for its servers, so neither a run nor that wait is
cut short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): label MCP calls server/tool only from their result's details

pi registers MCP tools as mcp__<server>__<tool>, sanitized to
[A-Za-z0-9_-] and hashed past 64 characters, so the name cannot be split
back: docs.v2/search.pages and docs_v2/search_pages register alike, and
either part may contain "__". Parsing it showed docs_v2/search_pages for
docs.v2's search.pages, split names with "__" in the wrong place, and
garbled hashed ones, on running cards, in the status line and in a
codemode script's calls, which carry only that name.

A card now reads server/tool only from its result's details, where the
MCP extension records the real names. Everywhere else the call keeps its
registered name, which is also the name a codemode script calls it by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(worktrees): find the worktree to remove by its real path

removeWorktree() matched the caller's path against `git worktree list`,
which reports real paths, without resolving links first. A path that runs
through a link was refused as "Not a worktree of this repository": on
macOS that is any path under os.tmpdir(), since /var is a link to
/private/var, so the forced-removal test added in #1007 always failed
there. Pi Web's own callers pass paths Git reported and were not
affected; findCurrentWorktreePath() already resolved its path this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): connect a trusted project's servers as the pi CLI does

Project .pi/mcp.json entries now follow the project's trust instead of
waiting for a per-entry approval that nothing could grant yet. The host
already passes ctx.isProjectTrusted() to the SDK's loadMcpConfig, which
reads the project file only for a trusted project, so the approval check
and the waiting-approval state are removed.

Per-entry approval is dropped from ADR 0006 rather than deferred to P2: a
trusted project's .pi/extensions run inside the same boundary, earlier and
without approval, so gating only MCP entries stopped neither a malicious
repository nor inherited trust, and made Pi Web and the CLI disagree about
which servers run. P2 keeps the visibility part: the panel and the trust
dialog list project servers with the command each would run.

A contract test pins the SDK behavior the host now relies on: a project's
mcp.json is read only when trusted and replaces global entries by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): read project trust fresh before every host sync

The MCP host decided whether to read a project's .pi/mcp.json from
ctx.isProjectTrusted(), the wrapper's SettingsManager flag. That flag is
fixed when the wrapper is built and refreshed only on reload, and it is
true for a folder that needed no trust at that moment. A .pi/mcp.json that
appeared afterwards (git pull, `pi mcp add -l`, the model's write tool)
therefore connected on the session's next prompt, stdio command included,
without anyone trusting the project. A decision revoked in the CLI did not
reach an open session either.

The host now asks its mayReadProjectConfig(cwd) option on every sync. The
default, mayReadProjectConfigNow() in lib/project-trust.ts, reads the
folder's resources and trust.json each time. It is true only while a
decision, exact or inherited, trusts a folder that requires trust. It is
not getProjectTrustStatus().trusted, which is true for a folder with no
trust-requiring resources: loadMcpConfig looks at the folder again when it
opens the file, so a file landing between the two looks would be read with
no decision. Answering false there loses nothing, because .pi/mcp.json
alone makes a folder require trust. createMcpExtensionConfigLoader uses the
same read for autoEnableCodemode. The SDK calls loadConfig only on
session_start, so that one flag still follows a trust change only at the
next reload.

The read fails closed. If trust.json cannot be parsed, or is still locked
after the store's ~200 ms synchronous wait, the project counts as
untrusted and its connected servers are unregistered until a read
succeeds. Each distinct error is logged once, with the trust.json path,
because the lock error does not name the file.

While the project file may not be read, the host records each name it
declares with the new not-trusted state, for Settings to show later.
Problems are keyed by scope and name, because a project entry may share
its name with a connected global one. The names are read after
loadMcpConfig, so a file that just landed is reported at once. The file is
repository-controlled and nobody has trusted it, so only its mcpServers
keys are read: nothing is validated, resolved or run. The path must
resolve to a regular file of at most 1 MiB inside the project. It is
opened once with O_NOFOLLOW | O_NONBLOCK and checked through fstat, so a
link elsewhere, a FIFO or a device is never read. loadMcpConfig's errors[]
are now logged once per process each instead of dropped.

Tests run on real trust.json and mcp.json files:
- A file written after the session started is not read until the project
  is trusted, even when ctx says trusted, which the unit fixture makes
  throw. An exact false removes the server, inherited trust connects it,
  and an exact false beneath a trusted parent does not.
- Global servers, including one sharing a name with an untrusted project
  entry, stay connected throughout.
- A file landing between the trust read and the SDK's read is not read.
- An unparsable or a locked trust.json fails closed with one warning.
- Config errors are logged once.
- Names are read only from a regular file inside the project: not
  through a link elsewhere, a FIFO or an oversized file.
- autoEnableCodemode follows the fresh read.
- An integration test drives a real AgentSessionWrapper whose
  SettingsManager reports trusted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(mcp): list MCP servers from files only

Settings › MCP and the trust dialog need to show what each mcp.json
declares, and for a project that nobody has trusted yet that is exactly
the point: the command an entry would run has to be checkable before
anyone trusts the folder. GET /api/mcp (optional ?cwd=) therefore reads
the files and nothing else. It never builds a transport, resolves a
${VAR}, runs a !command, calls oauthSettings(), or touches
McpOAuthCredentialStore, which creates mcp-auth.json and a lock just to
read it. The signed-in hint is a raw parse of mcp-auth.json, keyed as the
SDK keys it (String(new URL(url))).

lib/mcp-config-read.ts reads the global <agentDir>/mcp.json and the
project <cwd>/.pi/mcp.json separately. loadMcpConfig() cannot serve: it
skips an untrusted project's file and merges by name, hiding the global
entry a project entry replaces. Each file is parsed as the SDK parses it
(no BOM stripped, the same shape check, autoEnableCodemode a boolean), and
each entry checked with the SDK's validateMcpServerConfig(); an invalid
entry is still listed with the SDK's reason. Without the internals,
entries are listed unvalidated and a leading ! still marks a command. A
global entry is shadowedByProject only when the project's entry would
load. The project file is repository-controlled, so its real path (a link
in the file or in .pi/) must stay inside the allowed roots, a dangling
link is refused rather than read as no file, and the resolved path is
opened once with O_NOFOLLOW | O_NONBLOCK and checked through fstat, so a
FIFO is never read and nothing past 1 MiB is parsed. The global file is
the user's: a link is followed wherever it leads.

Nothing literal reaches the browser. Env and header values are sent as
names only; command, args and url go through lib/mcp-secrets.ts, a pure
module the importer will share. Traps found in review:
- Checking only whitespace-free values left a spaced token whole: a full
  command line in `command`, `--auth-header "Bearer ghp_…"`, a bare key in
  the query string. A value of several words is now masked word by word,
  whitespace kept, and Bearer / Basic / Token or a credential `Name:`
  makes the next word a secret. Connection strings (`Password=…;`) and
  `--auth user:pass` are masked too.
- command, args and url are used as written; the SDK expands nothing
  there. Only placeholder-shaped values count as references in them:
  ${NAME}, or $NAME in environment-variable capitals, which wrappers such
  as mcp-remote or sh -c expand. `$Passw0rd` is masked. The fields the SDK
  resolves keep its own syntax ($name of any case, $$ and $! escapes).
- V8 quotes up to ten characters on either side of an unexpected token
  instead of giving a position, and the old filter missed the form with a
  leading ellipsis, so a single-quoted password reached the panel whole.
  The message now keeps the token only when it is punctuation and gives
  the position that context stood for, in V8's own wording.
configKey, which later statuses compare against, is an HMAC of the
entry's canonical JSON under a random per-process key, never the JSON
itself (every literal value) nor a plain hash (a weak password in an
otherwise visible entry would not survive it).

transport and usesOAuth follow the SDK's transport, which picks HTTP
whenever the key `url` is present. The validator passes
{ type: "stdio", command, url } as stdio, but it connects over HTTP and
runs its header !commands, so it is reported as HTTP. Only an entry the
validator refuses, which never connects, gets the validator's reading.

The response also says whether MCP can run, as a typed reason:
operator-disabled (PI_WEB_DISABLE_MCP), internals-unavailable,
builtin-disabled (with the settings path). readBuiltinExtensionSwitches()
asks the SDK's DefaultPackageManager with only the two `extensions` lists,
so -builtin:mcp and `!builtin:*` patterns resolve as the loader resolves
them and no package is installed or looked up. Code mode reports the
global preference, -builtin:codemode, and peekCodemodeSandbox(): the
settled self-test result, or not-checked, never starting the self-test.
Route refusals are a typed McpRefusalReason (cwd-invalid, cwd-denied,
cwd-not-directory, internal) for the panel to translate.

ProjectTrustStatus gains decision, decisionPath and inherited from
ProjectTrustStore.getEntry(), so exact trust, trust through a parent, an
explicit false and no decision can be told apart, also for a fresh
folder. A folder that requires no trust never failed on a broken
trust.json before, so that failure goes to decisionError instead of
throwing. mayReadProjectConfigNow() and projectTrustReloadOptions() check
for trust-requiring resources first, so neither locks trust.json for a
fresh folder on every prompt. trustProject() builds the status it returns
from what it wrote: reading trust.json back could fail on the lock, and
POST /api/project-trust would then report a saved decision as a 500 and
skip rebuilding the cwd's wrappers.

findWebPasswordReference() is split into findWebPasswordField() and the
exported resolvedConfigValues() walk, which now skips non-string values so
it is safe on unvalidated entries; its messages are unchanged.
canonicalJson() is exported from mcp-host.

Tests pin:
- Both files described with exact shapes and no literal secret in the
  output; an untrusted project listed with its command; !command fields
  labelled and never run (a touch marker stays absent); PI_WEB_PASSWORD
  flagged, an escaped $$ not; no mcp-auth.json or lock created.
- Unparsable, wrong-shape and bad autoEnableCodemode files, BOM included,
  each with a message that quotes nothing from the file: long and short
  files, single-quoted secrets mid-file and on later lines, exact
  positions.
- Shadowing only by a loadable project entry; invalid entries with the
  SDK's reason; a url beside type stdio reported as HTTP.
- Project link policy (dangling, outside, allowed sibling root, linked
  .pi folder, directory, too large, FIFO); global link followed.
- Masking of URLs (userinfo, named and bare query parts, path tokens),
  args, spaced values and whole command lines, connection strings,
  placeholders versus $Passw0rd, and literalSecretFields on the same.
- On a trusted merge the per-file reader agrees with loadMcpConfig().
- Trust status exact / inherited / false / none, through a link, and an
  unreadable store for a fresh folder; trustProject() succeeds when every
  read after the write fails.
- peekCodemodeSandbox() never starts the self-test; the builtin switches
  resolve -builtin:mcp, project overrides and patterns from files.
- The route: no cwd, cwd refusals, MCP off with servers still listed,
  builtin-disabled, Code mode preference and an unreadable settings file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(trust): list a project's MCP servers in the trust dialog

ADR 0006 gave up per-entry approval of a project's .pi/mcp.json in
exchange for visibility: trusting the folder connects every entry, in Pi
Web and in the pi CLI, so the trust dialog has to show what that means
before anyone clicks. GET /api/project-trust now returns the trust
status plus mcpFile and mcpServers, read by readProjectMcpServers() the
same file-only way GET /api/mcp reads them: no transport is built, no
${VAR} expanded, no !command run, the OAuth store never opened, and env
and header values reach the browser as names only. replacesGlobal marks
a project entry that replaces the user's global server of its name once
trusted, the counterpart of shadowedByProject.

The dialog fetches that listing each time it opens (no-store, 10 s
timeout, late answers dropped), because the file can change after the
page loaded. Trust stays disabled until it answers, so the list cannot
appear under a click already on its way; a failed or timed-out listing
stops holding it back. Each server shows its transport, its command line
or URL, the working directory, refused entries with the SDK's reason,
entries turned off in the file, the fields holding a !command, and the
host variables it reads. File problems are translated per reason; for
link-outside and too-large the dialog says the servers are not listed
but Pi still loads them once trusted, since the SDK follows links and
has no size cap.

Traps found in review:
- A header such as `Authorization: Bearer ${GITHUB_TOKEN}` or an
  oauth.clientSecret of `${AWS_SECRET_ACCESS_KEY}` sends a host variable
  to the repository's URL on every connection, and the dialog showed
  only the URL. McpServerInfo gains variableReferences: the variables
  each resolved field reads, named through the SDK's
  getConfigValueEnvVarNames() (or templateVariableNames(), a local port
  pinned against it, without internals), never expanded. They resolve
  from Pi Web's whole process.env, not the sanitized environment a stdio
  server starts from. HTTP entries say "sends ... to this server", stdio
  entries "passes ... to the command". A refused entry shows neither
  these nor its !command fields, since it never connects.
- Repository text was shown raw with white-space: pre-wrap, so a run of
  newlines pushed `curl … | sh` out of view and a U+202E override made
  `sj.revres` read as `server.js`. revealHiddenCharacters() turns
  controls, format characters (bidi, zero-width, tags), separators,
  variation selectors, blank fillers and every space but U+0020 into
  \u{XXXX} escapes. It never uses \n, which a Windows path like C:\new
  would collide with. An argument holding one is quoted, the entry gets a
  warning line, and the CSS collapses white space again.
- The command was shown unquoted on the belief that the SDK hands it to a
  shell. It spawns it with cross-spawn and no shell, so
  `./tools/lint --check` is one file name. The command is now quoted by
  the same rule as the arguments.
- An unreadable trust.json answered 500 before the listing ran. The pi
  CLI holds its lock while writing and the store gives up after about
  200 ms, so the dialog enabled Trust with nothing listed, and a click
  once the lock was gone trusted the folder unseen. The listing no longer
  depends on the store: GET answers 500 trust-unreadable with the listing
  beside it, and the dialog shows both.
- The PI_WEB_PASSWORD line read as a reassurance ("Pi Web will not
  connect it"). The pi CLI shares the decision and does not refuse the
  variable, so the line is now a warning that the CLI would send it.
- POST refusals were English sentences the dialog showed in every
  locale. Every refusal now carries a reason: request-denied (403) and
  content-type (415), checked as the plugins route checks them,
  cwd-invalid / cwd-not-directory / cwd-denied, trust-not-required and
  session-busy (409), internal (500). AppShell keeps { error, reason }
  and the dialog renders mcp.reason.<code>, showing the raw error only
  as the diagnostic of an internal or network failure. The fresh status
  goes back to AppShell through onStatus, so a folder trusted elsewhere,
  or no longer needing trust, says so, offers Close instead of Trust,
  and loses its stale restricted-mode banner.

All layout moved from inline styles to .project-trust-* classes in
app/settings.css: only the body scrolls, the trust error and buttons sit
outside it, and the backdrop pads with the safe area, with the iOS
standalone fallback Settings uses. All strings are in en, zh-CN and
zh-TW. The dialog uses trust.mcp.* keys, and the shared mcp.transport.*,
mcp.field.*, mcp.server.*, mcp.fileProblem.* and mcp.reason.* keys are
ready for Settings › MCP to reuse.

Tests pin:
- the route lists an untrusted folder's entries from the file, with
  masked args and URL, names only, commandFields, variableReferences and
  replacesGlobal. The `!touch` marker never runs, neither a literal nor a
  referenced host value reaches the body, and the agent dir gains no
  mcp-auth.json or trust write.
- the listing survives an unparsable and a locked trust.json, and every
  GET and POST refusal carries its reason code.
- POST checks origin and content type, refuses a folder with nothing to
  trust, and trusts the folder on success.
- the reader names variables but runs nothing, and the local parser
  agrees with the SDK's on escapes and malformed references.
- the display helpers quote the command, escape newline padding and
  U+202E, and leave Windows backslashes alone.
- the dialog renders each server's text, the hidden-character warning,
  the PI_WEB_PASSWORD warning, translated POST failures, the
  already-trusted / no-longer-required notices without a Trust button,
  and the trust-store failure beside the list. Every key it uses exists,
  no inline style is left, the CSS collapses white space, and AppShell
  keeps the reason and follows onStatus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(mcp): parse pasted MCP server configs

Settings › MCP will add a server from whatever the user copied: an
endpoint URL, an install link, a command line, a `pi | claude | codex |
gemini mcp add` line, or another client's JSON. lib/mcp-import.ts turns
that text into pi mcp.json entries, `{ ok: true, servers, notes }` or
`{ ok: false, notes }`, each server `{ name, config, fields, notes,
source, rawPi }` plus originalName and scopeHint. It is pure, so the
browser preview and the server's re-parse of the same text agree; the
server still validates the filled-in entry with the SDK before writing
(S12). Notes are `{ code, params }` from MCP_IMPORT_NOTE_CODES (84 codes,
each with a severity in MCP_IMPORT_NOTE_SEVERITY), never sentences. The
formats live in mcp-import-core/json/cli/links.ts; lib/shell-words.ts
splits a command line without a shell, and lib/jsonc.ts is the
JSON-with-comments reader models-config-store already had, now also
dropping block comments (byte-identical to the SDK's stripJsonComments on
everything pi accepts).

Other clients store literals, but pi resolves `${NAME}`, `$NAME` and a
leading `!command` in env values, header values and oauth.clientSecret.
Literals there are escaped (`$` to `$$`, a leading `!` to `$!`):
unescaped, a pasted `"!curl … | sh"` header would run on the Pi Web host
at every connection. command, args, url and cwd are used raw by the SDK
and are never escaped. `pi mcp add` and the rawPi toggle keep values as
written, and a `!command` is then noted. References (`${env:X}`,
`{env:X}`, `%X%`, Gemini's `$X`) become `${X}` only in those three fields;
pi substitutes nothing elsewhere, so a reference in command, args or url
becomes a field to fill in, except a leading `$HOME`/`${userHome}` (`~`)
and `${workspaceFolder}` (`.`). Every config is built fresh from known
keys and every dropped key is noted: validateMcpServerConfig returns the
object it was given and would accept milliseconds as seconds. Timeouts are
read in the unit of the client that wrote them (Gemini milliseconds, Cline
seconds), and a guessed unit is a warning. URL userinfo moves into a Basic
Authorization header, because pi's fetch refuses a URL with credentials.

`pi mcp add` is a port of the module-private SDK cli.js grammar
(maxPositionals 2, `-l`, an option on the wrong transport refused,
splitting at the first `=`, `--help` anywhere, an empty name refused). The
integration test runs the real runMcpCommand into a temp dir for 34 argv
cases and 5 mixed-quoting command lines and compares the written entry
byte for byte. One leniency: an invalid name is sanitized with a note
instead of refused, since the panel lets the user edit it.

Traps found in review:
- parseMcpImport and fillMcpImportFields never throw, because the server
  route re-parses the request body and a throw is a 500. A malformed
  percent-escape in URL userinfo (`https://%zz@…`) threw URIError from an
  unguarded decodeURIComponent, and `$'\U110000'` threw RangeError from
  String.fromCodePoint; bash prints such an escape as text, and so does
  shell-words now. An unbounded `…_here` placeholder pattern took
  quadratic time on a long `a-a-a-…` paste; its run is bounded.
- Secret classification is lib/mcp-secrets.ts, not a second classifier.
  The importer's own one missed `--header "Authorization: Bearer sk-…"`
  arguments, userinfo in an argument (`postgresql://admin:pw@db/prod`),
  run-together names (PGPASSWORD, NGROK_AUTHTOKEN) and a token in a URL
  path, so a project-scope refusal built on it could be bypassed.
  findLiteralSecretPaths is literalSecretFields with per-argument paths,
  and password fields use the same rules.
- A name pi accepts is kept as written (`_x`, `my--server`, 60
  characters): sanitizing valid names broke `pi mcp add` parity. A name
  the source chose that mcp.json already holds is kept and noted
  (`name-taken` with a suggestedName), so S12 can answer its typed 409
  instead of silently adding `name-2`; only derived names avoid
  takenNames.
- Quoted and unquoted text is joined before a CLI's grammar reads it:
  `'A=$'B` is the one argument `A=$B`, and reading the parts separately
  turned that pi reference into a literal.
- Management commands (`pi mcp remove x`, `claude mcp login x`, `pi install
  npm:…`) are refused (`cli-not-a-server`): imported as a stdio server,
  the Test after Add would run them on the host. `claude mcp serve` and
  `codex mcp-server` still import.
- A legacy `/sse` address is refused also when a CLI's transport is
  guessed from the URL (claude or gemini without `-t`, codex `--url`); an
  explicit http transport and `pi mcp add` keep it.
- github.com serves no MCP endpoint: the registry index and account pages
  are explained (`github-page`) like repository pages.
- An example path keeps its `--flag=` or `NAME=` prefix; only the path is
  asked for.
- `${userHome}` in a resolved field becomes `${HOME}` with a warning
  (`home-variable-translated`): Windows does not set HOME.
- fillMcpImportFields takes `{ reference: "NAME" }` to store `${NAME}`
  instead of a literal secret (ADR decision 8), only for fields whose
  values pi resolves; a reference is not a secretPath.

lib/mcp-secrets.ts is S2's helper, byte-identical to the file in
26e409edc (sha256 4f1b1a95…bb70), so this slice builds and tests on its
own; on feat/mcp-settings-p2 it is the same file.

Tests: jsonc (comments, SDK byte parity), shell-words (quoting, `$'…'`
incl. code points past U+10FFFF, continuations, refusals, Windows paths),
mcp-import (table tests per format with real payloads; SSE and WebSocket
refusals; explained links; base64; escaping and the pi toggle;
references; placeholders and prefixed example paths; names kept, taken
and deduplicated; timeout units; fills with values and references;
literal secrets agreeing with literalSecretFields; management commands;
every prefix of every sample, malformed escapes included, parses and
fills without throwing; a 200 000-character paste in linear time; every
note code written and given a severity), and mcp-import.integration
against the real SDK (`pi mcp add` parity, escape round-trips through
resolveConfigValueOrThrow, configValueEnvVarNames vs
getConfigValueEnvVarNames, the validator port on 38 cases, filled configs
accepted, a foreign `!touch marker` never run, a reference resolved from
the environment).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): list MCP servers read-only in Settings › MCP

ADR 0006 P2 needs one place where the servers sessions connect are
visible before anything can change them. Settings › MCP is that place:
it lists the global mcp.json and, with a project, its .pi/mcp.json from
GET /api/mcp, which reads the files and nothing else. No transport is
built, no ${VAR} expanded, no !command run, and env and header values
reach the browser as names only. This slice is read-only; switches,
Test, sign-in and Add come in later slices.

The section needs no project. Without one the panel lists the global
file alone; a project adds its group. settingsSectionRequiresProject()
in lib/settings-navigation.ts is now the one place that decides which
sections need a project: SettingsPanel's tabs, the phone picker, the
effect that falls back to General (which hard-coded skills, agents and
plugins) and AppShell's sidebar shortcuts (which hard-coded
`section !== "models"`) all read it. The remembered row is kept per
project, or under the bare "mcp" key without one, which no project key
(always a JSON array) can collide with.

The sidebar starts with a Code mode row, so the 190px phone sidebar
always shows it, then Project (only with a project) and Global with an
n/m count of entries turned on, read to a screen reader as a sentence.
Each row's state comes from the files alone, most important first:
refused by pi, refused for PI_WEB_PASSWORD, off in the file, project not
trusted, replaced by the project's entry, MCP off, on. "On" means a
session would connect it, never that one did. The status dot is
aria-hidden, so the state is in the row's accessible name, in a short
visible badge and in the detail pane.

The detail pane shows what GET sends: the masked command line or URL,
the working directory, env and header names, the fields that run a shell
command on every connection, the host variables an entry reads, the
OAuth hint, the exposure, the file, and the disclosure that sessions
register these servers through Pi Web's MCP host, so /mcp in a chat
shows them with the scope "extension". Repository text goes through
revealHiddenCharacters(), as in the trust dialog. Notices cover MCP off
with its reason, an untrusted project (no Trust button yet), an
inherited false naming its folder, "Trusted through <path>" for
inherited trust (the trust dialog never opens for such a project, so
this is where that trust is visible), and file problems in the footer.
A project folder the route refuses does not hide the global servers: the
panel loads again without the cwd and the Project group says so.

Traps:
- Project servers count as read only while trust.decision === true, the
  MCP host's mayReadProjectConfigNow() rule, never trust.trusted, which
  is true for a folder that needs no trust. A global entry reads
  "replaced" only under the same condition.
- A .pi/mcp.json that is a dangling link is listed with link-dangling,
  but the SDK's existsSync() follows the link, finds nothing, and the
  folder needs no trust. The panel said "not trusted" there, and the
  Trust button a later slice adds would only have met
  trust-not-required. A folder that needs no trust now gets no trust
  notice unless a decision marks it untrusted; the footer explains the
  file.
- Code mode was described as turning on automatically even where it
  cannot. With autoEnableCodemode false (merged as loadMcpConfig()
  merges it: the project file's value where that file is read, else the
  global file's, else true) the MCP extension never activates codemode
  under Automatic. With -builtin:codemode there is no codemode tool, yet
  the host keeps the exposure because codemodeAvailable reads only the
  sandbox. In both cases a codemode-exposure server's tools can be
  called only through an active tool search. The Tools line and the
  Code mode pane now say so, naming the file.
- Refresh is disabled while a load runs, so a GET that never answered
  left the panel loading with no way to retry. A load now ends after
  MCP_OVERVIEW_TIMEOUT_MS (15 s, both requests together) with a
  translated timeout. The deadline settles the race itself, so a fetch
  that ignores its signal cannot outlast it, and the caller's signal is
  forwarded by hand because AbortSignal.any() needs Safari 17.4.
- With every server hidden by a file problem, the detail pane said "No
  MCP servers yet" beside a group saying "Not listed: see the file
  problem below". It now says the file problems hide them.

What the tests pin: the row-state order and the decision rule; groups
with and without a project and why each lists nothing; selection kept
across reloads; MCP-off, trust (including the dangling link and
inherited trust) and file-problem notices; the autoEnableCodemode merge
and every Code mode reach warning; the empty-state wording; the load's
no-store fetch, its cwd fallback, the forwarded abort and the deadline
with a fetch that never answers; rendered panes for stdio, HTTP, refused
and PI_WEB_PASSWORD entries with hidden characters escaped; that every
key the panel uses exists in the English locale (the registry test
checks the three locales agree); that AppShell and SettingsPanel ask
settingsSectionRequiresProject(); and that every .mcp-* rule in
app/settings.css matches a class the panel renders and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): choose Code mode Automatic or Always on in Settings › MCP

ADR 0006 gives Code mode one choice, and Settings › MCP is the only
place to make it. The Code mode pane now holds a switch between
Automatic and Always on that sends PUT /api/tools/settings { codemode },
still the only writer of the global defaultTools, under the lock pi's
SettingsManager takes. The choice applies to sessions started
afterwards and nothing is reloaded: pi applies defaultTools when it
creates a session, and a reload carries the tool set pi rebuilt from
the previous one, so a reload would only look like it did nothing. The
pane shows what the route read back after writing, then loads the
overview again whatever the outcome, since a timed-out save may still
land and a refused one may mean the file no longer parses. The save
shares the overview's 15 s deadline (withinDeadline()), so a request
that never answers cannot keep the switch disabled. Unlike the
PowerShell switch in General, whose error sits in its Windows-only
block, the save error is shown on every platform.

/api/tools/settings now gives every refusal a reason code
(request-denied 403, content-type 415, invalid-request 400, also for
the PowerShell 404 off Windows, internal 500), which the pane
translates; the English error is shown only as the diagnostic of an
internal or network failure. The PowerShell UI still reads only error.

GET /api/mcp reports a trusted project whose .pi/settings.json
defaultTools decides Code mode for its own sessions whatever the global
choice (codemode.projectOverride, naming the file): a list with a plain
name replaces the global list, +codemode and all, and a modifier-only
list is appended to it, so a +codemode or -codemode there has the last
word. projectCodemodePreference() does not reimplement that merge: it
hands the project's raw text to the SDK's own SettingsManager once
beside each global choice, and an override is exactly the two runs
agreeing, malformed values included ("defaultTools": null resolves to no
tools at all). "Trusted" is trust.trusted, the rule a session applies
when it decides whether to load project settings. The override is said
whether or not it agrees with the global choice, and the
autoEnableCodemode warnings follow the effective choice, while the
switch and the sidebar row keep showing the global choice they save.

Traps:
- Always on is disabled with a visible reason while the sandbox failed
  its self-test or the global extensions set -builtin:codemode. It
  used to be weighed against builtinDisabled, which merges the trusted
  project's extensions list: a project turning Code mode off for itself
  blocked a global choice that works in every other folder, and whether
  the switch was offered changed with the folder Settings was opened
  from. readBuiltinExtensionSwitches() now resolves the global list a
  second time on its own whenever a trusted project has a list (each
  switch's `global`), and the overview reports it as
  codemode.globalBuiltinSettingsPath. A project-only -builtin:codemode
  keeps Always on available, and the Extension line says the choice
  still applies to sessions in other folders; the row and the Tools
  lines still say Code mode is unavailable for this project's sessions.
  A sandbox nobody has checked yet keeps Always on available.
- A FIFO at a trusted project's .pi/settings.json hung GET /api/mcp:
  readFileSync() waits until something writes to it and, being
  synchronous, stalls the whole server. Guarding only the new Code mode
  read was not enough, because readBuiltinExtensionSwitches() reads the
  same file earlier in the same request. Both now go through
  readRegularFileText() (lib/regular-file.ts): opened once with
  O_NONBLOCK, checked through fstat, a byte cap (1 MiB) for the project
  file. A file that is not regular throws in the switches read, which
  the overview already treats as an unreadable file (every built-in on),
  and reports no override.
- The project file is read without pi's lock, as the switches read
  does, so a write caught halfway reads as unparsable and reports
  nothing until the next load. Creating SettingsManager on the project
  instead would leave a transient settings.json.lock in the
  repository's .pi/ on every GET.

What the tests pin: the override table against the SDK's own merge
(plain lists, +codemode / -codemode in either order, unrelated
modifiers, an empty list, null, a string, a byte-order mark, an
unparsable file) and through the route (untrusted, exact and inherited
trust, an explicit false, no cwd); that reading the project file takes
no lock and skips a directory and a FIFO; readRegularFileText() on
missing paths, links, dangling links, the byte cap, a directory, a FIFO
and /dev/zero; the switches read refusing a directory, a file past the
cap and a FIFO instead of blocking; the per-switch global answer, and
through the route a project-only -builtin:codemode and a project that
turns a global one back on; Always on's reasons and the Extension line's
wording for each case; the pane's two options, pressed state, the
"applies to sessions started afterwards" line, saving disabling both,
the reason as aria-describedby text, and a failed save shown with its
reason, diagnostic or timeout with no isWindows gate; the save's PUT
body, the stored value, refusal reasons, network and HTML answers, the
deadline and the forwarded abort; the container saving only a change,
ignoring answers after close and reading the overview back; and the
route's typed refusals, with the existing Code mode and PowerShell
tests unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): trust a project from Settings › MCP

Settings › MCP said an untrusted project's servers do not connect but
offered no way to change that. Its trust notice now has a Trust… button
that opens the page's trust dialog, the same one the restricted-mode
banner opens, above Settings. AppShell still owns trust: it passes its
status for the folder Settings shows (projectTrustCwd, the cwd its dialog
trusts) and openProjectTrustDialog through SettingsPanel to McpConfig.

The button appears only where trusting can work, from the panel's own
fresh status (mcpProjectTrustable()): the folder requires trust and is not
trusted. An unreadable trust.json keeps its notice without a button,
since trusting would fail the same way, and so does an explicit false on
a folder that requires no trust (a dangling .pi/mcp.json link), where
POST /api/project-trust answers trust-not-required. Without a handler the
notice has no button, as before.

After trusting, the panel loads GET /api/mcp again in place: the load
effect depends on projectTrustReloadKey() of AppShell's status
(components/settings-ui-helpers.ts), so a changed decision (the dialog
trusted the folder, or read a decision made elsewhere) reloads it, and an
equal status re-read by the dialog does not. Remounting on a key instead
would flash Loading over the list and drop the pane's state, such as a
Code mode save under way.

Traps:
- The other sections went stale. Settings keeps every section it has
  shown mounted and only hides it, and until now trust could not change
  while it was open (the banner sits under its backdrop). Trusting from
  MCP left Skills and Plugins saying "not loaded" with the Project scope
  disabled until Settings was reopened. SettingsPanel now passes the
  page's status to every section whose answer depends on trust, and each
  reloads in place when the key changes: Skills and Plugins their list,
  from a second effect so the cwd effect still does the first load and
  the selection and update checks survive; Agents its model list, since
  GET /api/models leaves out an untrusted project's extensions. They stay
  keyed on cwd alone: a remount would drop an install under way or a
  profile draft. Models takes nothing, because models.json, auth and
  /api/models/enabled (SettingsManager.create() without a trust option)
  do not follow trust.
- Escape stacking. SettingsPanel closes on an Escape that reaches
  document in the bubble phase with defaultPrevented unset. The dialog
  now handles Escape itself (it did not before) through
  lib/stacked-dialog.ts: a capture-phase listener on document, which runs
  before every bubble-phase listener, marks the key handled and stops
  propagation. One Escape closes only the dialog. While trusting, Escape
  is ignored like Cancel and the backdrop, but still stopped; an Escape
  that cancels an IME composition is stopped without closing anything.
  SettingsPanel's handler moved into the same module
  (listenForPanelEscape), unchanged.
- Escape also stopped the agent. The window-level Stop shortcut checked
  only for a textarea or input, so the Escape that closed Settings with
  focus on a button also aborted a running agent; so did closing the
  Mermaid viewer, which only calls preventDefault(). handleGlobalEscape()
  in hooks/useKeyboardShortcuts.ts now skips an Escape something nearer
  already marked handled. Every Escape handler that calls preventDefault()
  closes something of its own, so none relied on the stop.
- Focus. It moves to the dialog element itself (tabIndex -1, no outline),
  so a screen reader reads its title and a stray Enter presses nothing,
  and goes back to the opener when the dialog closes if the opener is
  still on the page. After a successful trust the opener is Trust…, and
  the reload removes it with its notice, which drops focus to body behind
  the modal. McpConfig then moves it to the selected sidebar row
  (focusIfLost(), only when focus fell to the page);
  ConfigSidebarItem passes a ref to its button for that.

What the tests pin: lib/stacked-dialog.test.mjs dispatches keydowns in
the DOM's capture / target / bubble order, with the real
handleGlobalEscape() on the window: Escape closes Settings alone and
leaves a running agent alone, with Settings closed an unhandled Escape
still stops it, and while the dialog is open Escape closes only the
dialog (focus on the dialog, on its button, or on body), whatever order
the listeners were added in; a busy dialog and an IME composition close
nothing; focus moves in and back, a detached or unfocusable opener is
skipped, and focusIfLost() moves focus only when it fell to the page.
hooks/useKeyboardShortcuts.test.mjs pins the shortcut on its own (fields
and handled keys skipped, nothing without a run). The Trust button
appears for an untrusted folder that requires trust (also under an
ancestor's false) and not for an unreadable store, a dangling link's
false, trusted or inherited-trusted folders, or without a handler. The
reload key changes with the decision and not with a re-read; Skills,
Agents and Plugins get the page's status and reload in place, keyed on
cwd alone; the selected row carries the focus ref; the dialog renders
with tabindex="-1" and registers its listener once; AppShell renders the
dialog after Settings, z-index 1100 over 1000, and threads the status and
opener through SettingsPanel.

Also checked by hand, outside the suite, in headless Chromium against
the running dev server at 1280 and 390 px, with the trust POST answered
by the test so trust.json was never written: Trust… opens the dialog over
Settings, Escape and Cancel close only the dialog, and a second Escape
closes Settings. Pressing Enter on Trust… and trusting leaves focus on
the selected row, and the Plugins and Skills sections visited before load
once more each and drop their "not loaded" notices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(mcp): switch, remove and undo MCP servers from Settings

Settings › MCP listed both mcp.json files but could change nothing in
them. Each server's detail header now has Remove and a switch, each
group heading's n/m count is a group switch, and a removal can be
undone for 60 seconds. Open sessions apply a change at their next
message, because their MCP host reads the files before every prompt;
nothing reloads.

Writes go through a new writer, lib/mcp-config-file.ts, never the SDK's
add/update/removeMcpServerConfig(). Those write in place with no lock,
no atomic replace and no mode, and they throw untyped errors. The writer
keeps the SDK editor's bytes exactly: it parses the file (no BOM
stripped), applies the edit, and writes the document back with the
indentation of its first indented line ("  " when none) and always a
trailing newline. `enabled: true` and `exposure: "codemode"` delete
their key, and unknown keys stay. It adds four things:
- An in-process queue per real path, then a proper-lockfile lock (about
  3 s of retries, then a typed `locked`). A compromised lock is logged
  instead of thrown from a timer, which would take the server down.
- An atomic write: a temporary file beside the real file, renamed over
  it, so a symlink stays a symlink.
- Modes: 0600 for the global file and a 0700 agent folder created on the
  way. A project file keeps its mode, or is 0644 when new.
- Typed refusals that leave the file untouched. A JSON error never
  quotes the file.
An edit that changes nothing writes nothing.

Link rules. The global file is the user's: a link is followed wherever
it leads, a dangling one included. The project file uses the reader's
rule, now shared from lib/mcp-config-read.ts (locateProjectMcpConfig,
projectMcpConfigCreatePath, readResolvedConfigFile): its real path must
be inside the allowed roots, and a dangling link (file or .pi) is
refused rather than taken for a missing file.

POST /api/mcp takes enable, disable, remove, undo and a bulk
set-enabled with a result per server. Its guards are the plugins
route's (origin, JSON content type, cwd). MCP off by PI_WEB_DISABLE_MCP,
or SDK modules that cannot load, makes the panel read-only (409
mcp-off). `-builtin:mcp` does not, because a project can turn MCP back
on. A project server needs a decision that trusts the folder (403
project-untrusted), the MCP host's rule, never `trusted`. Turning on an
entry that references PI_WEB_PASSWORD is refused (409 web-password).
Every answer is the overview GET would give. Undo (lib/mcp-undo.ts)
holds the raw entry in process memory under a random token, with an
unref'd 60 s timer. Only the token, name, scope, path and the remaining
milliseconds reach the browser. Undo puts the entry back at its index,
never replaces a name added since (409 undo-name-taken), and a failed
undo keeps the hold for the time it has left.

Traps:
- A server named __proto__ or constructor. The writer reads names with
  Object.hasOwn() and writes them with defineProperty. A plain
  servers[name] patch would write `enabled` into Object.prototype.
- The group switch could deadlock. "On only while every row is" kept a
  group holding a switched-off PI_WEB_PASSWORD entry partial forever,
  because the switch never turns that entry on. It always read off,
  every click asked to turn the group on and sent nothing, and the group
  could never be switched off from its heading. The panel now passes
  `checked` from mcpGroupSwitchChecked(): some server is on, and every
  server the switch can turn on is. ConfigSidebarGroupSwitch takes that
  prop; Skills and Plugins keep the old rule.
- Entries that are not objects ("name": "text", null, an array). They
  were listed with a working switch, and the writer answered…
* feat(settings): add a Context tab for editing agent context files

Settings › Context edits the seven files Pi reads its instructions from
(https://pi.dev/docs/latest/configuration): the agent directory's AGENTS.md,
SYSTEM.md and APPEND_SYSTEM.md, and the project's AGENTS.md,
.pi/SYSTEM.md, .pi/APPEND_SYSTEM.md and AGENTS.override.md. Every card shows
its resolved absolute path, its text, and whether Pi loads it at all.

- `lib/context-files.ts` resolves each entry the way the SDK does, including
  the AGENTS.md / AGENTS.MD / CLAUDE.md / CLAUDE.MD fallback, so a card names
  the file Pi actually discovers. It reports precedence too: a project's
  `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md` replaces the agent directory's,
  and an override replaces AGENTS.md / CLAUDE.md in the same directory only.
- `GET`/`PUT /api/context` take an entry id, never a path: both routes
  resolve the file themselves, so no request can reach a file Pi does not
  read. A project file that resolves outside the allowed roots is refused, as
  `.pi/mcp.json` is, which the cwd check alone would not cover for a link.
- Context files are discovered without project trust, so nothing consults the
  trust store. Settings › Context works without a project: the agent
  directory's three entries stay editable and the local ones say why they wait.
- Only AGENTS.override.md is deletable, since it exists to replace its
  siblings.

* chore(demo): mirror Settings › Context and answer /api/context in the mock

Carries the Context tab into the demo: `components/ContextConfig.tsx`, its
helper (typing a refusal code as a string, since the demo's `api-types.ts`
carries no `McpRefusalReason`), the section in `SettingsPanel`, the `context-*`
rules in `settings.css`, the three locale files, and `lib/settings-navigation.ts`
(identical to the root copy on the sync branch).

The mock answers `GET`/`PUT /api/context` from `mock/data/context.ts`, which
keeps the seven files in memory so editing, creating and deleting work until the
page reloads, and applies the same precedence rules to the fixed demo layout.
Its `SettingsUi.tsx` copy gains `ConfigNotice`, `ConfigDetailGrid` and
`ConfigScopeTag`, which the panel renders.

* docs: summarize the Settings › Context tab PR

The durable report the task asked to leave behind: the seven entries and their
resolved paths, the design decisions behind the API and the panel, the demo
mirror, and the validation results with the pre-existing host failures listed.

* fix(context): keep a failed save's alert across the refetch it starts

The refresh a refused save triggers cleared `saveError` unconditionally, so a
refusal the reloaded listing has no trace of — a too-large write, a transient
409/500 — was invisible within a frame: the user saw a normal card and could
believe the save landed. The refresh now clears the alert only when it is a
manual Refresh, a project change or a successful save.

The test runs the hook's own `refresh` (taken from the source) and asserts the
alert survives the automatic refetch and goes on a manual one.

* fix(context): report the loaded state a project system prompt really has

Pi's `discoverSystemPromptFile()` prefers a project `.pi/SYSTEM.md` or
`.pi/APPEND_SYSTEM.md` only while the project is trusted; for an untrusted
project the agent directory's file is what sessions read. The listing shadowed
unconditionally and defaulted `effective: true` on files that do not exist, so
a card could claim Pi loads a file it never reads.

`readContextFiles()` now takes `isProjectTrusted()` (the route passes
`getProjectTrustStatus().trusted`, read once, counting an unreadable
`trust.json` as untrusted) and reports an untrusted project file as
`effective: false, requiresTrust: true`, which the card words as "Waits for
project trust". A file that is not there is no longer `effective`, so the
Precedence row only shows for a file that exists and the sidebar state says
"Not created yet" alone.

* fix(context): report a dangling link as not-a-file, not as a link outside the roots

`localPathAllowed()` resolves the nearest existing ancestor and falls back to
`realpathSync()` on the file itself; for a link to nothing that throws, so a
project entry whose link points at a removed target was reported `outside-roots`
(403 `link-outside`) instead of `not-a-file` (409), contradicting the reason
list in lib/api-types.ts and the MCP routes' distinct `link-dangling`.

Classify a directory or a link to nothing before the root check: neither can be
read or written, so nothing that used to be refused is now allowed.

* fix(context): ask before a Save drops a truncated file's tail

A context file over 256 KiB is loaded as a truncated head and the editor holds
only that, so a Save wrote back the head and destroyed the rest while the soft
notice was the only warning. Save on a truncated file now confirms first, naming
the size the write would lose, as Delete already does.

* fix(context): drop the unused context.state.blocked key

No code references it (stateText uses the context.block.* keys directly), and
the parity test cannot flag an extra key, so it sat in all six message files.

* test(context): write the block key literally in the locale test

It was built as "context.preload.notAFile".replace("preload", "block"), which
obscures what is asserted for no gain.
* feat(context): delete any of the seven context files

Delete was limited to AGENTS.override.md, which left the six other files
with no way back to the state Pi ships: the editor could empty a file but
not take it away. It now removes the file at any entry's resolved path,
behind one confirmation that names that absolute path and what Pi reads in
its place, per entry.

The listing no longer carries a deletable flag: a file that is not there is
what hides the button. Removing a file that is already gone is refused 409
not-a-file rather than answered with the same listing, so a Delete that did
nothing cannot read as a removal that landed. The symlink and allowed-root
refusals of a write cover removal too, they already ran before it.

A removal changes nothing else on the read path: the next listing reports
the entry missing and the existing effective/shadowedBy machinery makes the
fallback visible, which is how a deleted project .pi/SYSTEM.md puts the
agent directory's file back.

* chore(demo): mirror the wider Context Delete in the mocked route

demo/components/ContextConfig.tsx is copied over the root file byte for
byte, with the demo's four documented deviations in its helper (no
McpRefusalReason in the demo). The mock's PUT /api/context loses the
deletable gate, so all seven entries delete in the demo as they do in the
app, and mock/data/context.ts drops the field with it. The demo README says
so.

* docs: record the wider Context Delete in the PR summary

The entry table loses 'the only deletable entry', Design replaces 'only the
override is deletable' with the per-entry rule and the new refusal, and the
i18n count follows the key set (47 → 55 each).

* docs(demo): restore the refusal-code mirror comment
…NpmLock (#24)

* fix(deps): bump the earendil SDK packages to 1.0.1

1.0.1 drops hasShrinkwrap, so pi-coding-agent's tree de-nests: 136
nested duplicates hoisted, chord/pi-codemode/pi-mcp/esbuild/quickjs-wasi
promoted to top level. Carries @anthropic-ai/sdk 0.129.0 and
brace-expansion 5.0.12.

* fix(nix): build npm deps from importNpmLock instead of a pinned hash

npmDepsHash + npmDepsFetcherVersion go away: importNpmLock reads
package-lock.json and supplies the matching npmConfigHook, so there is
no hash to maintain. makeWrapper is now propagated by npmInstallHook,
so the callPackage arg was redundant; npmBuildScript was the default.
…taging (#22)

* build(release): run the bot on the workflow token

release-please keeps one accumulating release PR; the owner's merge is the release.

Drop the GitHub App: release-please-action's own action.yml defaults token to
${{ github.token }}, so the bot can run on the default GITHUB_TOKEN. Accepted
because publish is needs-chained in the same run (no on:push:tags workflow exists),
and the repo ruleset (Default: deletion/non-ff/pull_request, no bypass actors, no
required status checks) makes a checkless release PR mergeable.

The npmDepsHash caretaker is gone too: #24 moved package.nix to importNpmLock, so
there is no fixed-output hash left to refresh.

* docs(release): document the release-please ritual

One accumulating release PR, owner-merged; publish is needs-chained in the same
run. The only one-time setup left is the npm trusted publisher — the App is gone
(default GITHUB_TOKEN), and the nix side needs no hash maintenance (importNpmLock).

* build(release): publish under the release environment

* build(release): stage releases instead of publishing them

* docs(release): refresh the node-24 npm bundle facts and playbook wording

* build(release): drop the npm pin, rely on the node-24 toolcache floor

* build(release): bump actions to their latest majors (checkout v7, setup-node v7, release-please v5)

---------

Co-authored-by: dreadster3 <dreadster3@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
chore(release): add demo to version control
…e tags (#62)

Combines all open GitHub Actions update PRs (#55, #56, #57, #58, #59, #60)
into a single change, pinning each `uses:` ref to the latest release tag
within its major instead of the floating major tag:

- actions/checkout:        v4 -> v7.0.1  (ci.yml, demo-pages.yml; release.yml v7 -> v7.0.1)
- actions/setup-node:      v4 -> v7.0.0  (ci.yml, demo-pages.yml; release.yml v7 -> v7.0.0)
- actions/configure-pages: v5 -> v6.0.0  (demo-pages.yml)
- actions/upload-pages-artifact: v3 -> v5.0.0 (demo-pages.yml)
- actions/deploy-pages:    v4 -> v5.0.1  (demo-pages.yml)
- actions/upload-artifact: v4 -> v7.0.1  (ci.yml)

Tags were resolved via git ls-remote against each upstream repository.
Co-authored-by: dreadster3-renovate[bot] <337759419+dreadster3-renovate[bot]@users.noreply.github.com>
Co-authored-by: dreadster3-renovate[bot] <337759419+dreadster3-renovate[bot]@users.noreply.github.com>
Co-authored-by: dreadster3-renovate[bot] <337759419+dreadster3-renovate[bot]@users.noreply.github.com>
* feat(projects): delete a project's sessions

Adds POST /api/projects/delete plus the sidebar's type-to-confirm
dialog. Scope: sessions data only.

* fix(projects): count nested subagents, resolve their dir

* test(projects): drop unused test arg

* fix(projects): refuse live wrappers, group under the real sessions root

F1: refuse any alive wrapper, not only a running one.
F2: group against the scanner's non-realpath sessions dir; realpath
both sides of the containment check.
F10: count a nested child only once its carrying tree is gone.

* fix(projects): show full path, report partial deletes, notify app

F3: name leftovers and keep the dialog open; the partial key lives.
F5: full project path in the dialog.
F6: onSessionDeleted for every deleted session.
F7: synchronous guard against a double submit.
F8: symlink refusal gets its own copy.
F4: mirror the CSS block into the demo. F12: demo 405.

* fix(projects): keep the partial report on screen

Capture the project when the dialog opens: the success refresh drops
it from the list, which unmounted the dialog mid-report. Hide the
counts once the delete has run, and freeze the field.

* test(projects): drop unused test args

* chore(projects): post-review polish (R1-R4)

R1: 409 copy says the session is still open (or running).
R2: focus the Close button when the partial report appears.
R3: note why the captured session ids are frozen.
R4: annotate nestedChildren as SessionInfo[].

---------

Co-authored-by: worker <worker@pi-web.invalid>
….json

The demo lockfile's @tailwindcss/oxide-wasm32-wasi@4.3.3 entry declared
bundleDependencies but was missing its nested inBundle child entries
(@emnapi/*, @napi-rs/wasm-runtime, @tybys/wasm-util, tslib). Every npm
run that rebuilds the demo tree tries to repair this by refetching the
tarball, and with remote fetch gating enabled (as in Renovate's npm
environment) the fetch is refused with EALLOWREMOTE.

This is why Renovate has been unable to regenerate demo/package-lock.json
for every dependency update PR (renovate/artifacts FAILURE, "Artifact
update problem" comments), pushing manifest bumps without lockfile
updates and breaking the Demo workflow's `npm ci` with
"lock file's katex@0.16.47 does not satisfy katex@0.19.0".

The lockfile entry is removed and re-resolved via
npm install --package-lock-only, which writes the entry back complete
with all six bundled children — matching the structure the root
package-lock.json already has for tailwindcss 4.2.2. Diff is purely
additive (66 new lines, no version changes).

With this repair, Renovate's npm artifact command succeeds even with
remote fetches disabled, so demo/package-lock.json will be updated in
lockstep with demo/package.json going forward.
@dreadster3

Copy link
Copy Markdown
Author

Created in the wrong repository by accident — closing.

@dreadster3 dreadster3 closed this Oct 6, 2026
@dreadster3
dreadster3 deleted the fix/demo-lockfile-wasm-bundled-entries branch October 6, 2026 00:15
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