Repository navigation
fix(deps): repair incomplete bundled-dep entries in demo/package-lock.json - #1078
Closed
dreadster3 wants to merge 46 commits into
Closed
dreadster3 wants to merge 46 commits into
dreadster3 wants to merge 46 commits into
Conversation
…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)
chore: sync upstream (agegr/pi-web @ b9622a1)
* 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.
* 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.
Author
|
Created in the wrong repository by accident — closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Renovate updates root
package.json+package-lock.jsonanddemo/package.jsonfine, but has never been able to updatedemo/package-lock.json— every Renovate dependency PR (e.g. #41, #44, #35, #47) ships manifest bumps without the demo lockfile update and carries:renovate/artifactsstatus =FAILUREBecause the lockfile is left stale, the Demo workflow's
npm cifails on such PRs with the familiar mismatch (from #41):Root cause
@tailwindcss/oxide-wasm32-wasi(optional wasm32 dep oftailwindcss@4.3.3locked in the demo) ships bundled dependencies (@emnapi/*,@napi-rs/wasm-runtime,@tybys/wasm-util,tslib). The old lockfile entry declaredbundleDependenciesbut was missing its nestedinBundlechild 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-scriptsartifact update), npm tries to repair the inconsistency by refetching the tarball. Withallow-remotefetch gating enabled (as in Renovate's npm environment) the fetch is refused →EALLOWREMOTE→ lockfile update fails. The root lockfile is unaffected because itsoxide-wasm32-wasi@4.2.2entry is complete (all 6 bundled children recorded).Reproduced locally: the exact Renovate command fails in
demo/(crash inidealTree:buildDepsonoxide-wasm32-wasi) but succeeds at the repo root; after this fix it passes indemo/too, even withallow-remote=none.Fix
Delete the incomplete
@tailwindcss/oxide-wasm32-wasi@4.3.3entries fromdemo/package-lock.jsonand re-resolve withnpm 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.jsonwill be updated in lockstep withdemo/package.jsongoing forward. To refresh the existing open Renovate PRs, tick their rebase/retry checkbox (or rename titles to start withrebase!).