diff --git a/.dockerignore b/.dockerignore index 34031308..043a95a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -53,7 +53,7 @@ node_modules/ # Seccomp profile — Docker reads this on the host at runtime # (via docker-compose security_opt), NOT from inside the image -seccomp-profile.json +deploy/seccomp-profile.json # OS junk .DS_Store diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 3a413886..cc026e92 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -24,20 +24,13 @@ body: description: Minimal steps, logs, or a stack trace that demonstrate it. validations: required: true - - type: textarea - id: expected-actual - attributes: - label: Expected vs. actual - description: What you expected to happen, and what actually happened. - validations: - required: true - type: textarea id: acceptance attributes: label: Acceptance description: How we'll know it's fixed — verifiable criteria. validations: - required: true + required: false - type: input id: env attributes: diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index abc6a3b8..cf97eb56 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -25,6 +25,12 @@ jobs: with: node-version: "20" - name: Verify workspace config (.beads / .automaker / owned runners) + # Fork-friendly (#1534): the .beads/.automaker/owned-runner baseline is the + # protoLabs *fleet* standard, so it runs across the fleet (protoLabsAI/*) — + # keeping gina/protoTrader/roxy conformant — but is skipped on external forks, + # which are free to diverge. The job stays green either way (the server.py + # guard below still runs everywhere). + if: startsWith(github.repository, 'protoLabsAI/') # The npm package is still 1.0.0 (predates verify-workspace-config), # so run the script directly. It's zero-dependency (node builtins # only), so no `npm install` is needed. Switch to diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 066762bf..288a9f7f 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -19,26 +19,35 @@ name: Desktop Build # updater bundles, and the updater-manifest fan-in job composes latest.json # (the manifest the in-app updater polls) onto the release — uploaded last. # -# Fires on EVERY semver tag push (patch included) alongside release.yml's Docker build, -# and on manual dispatch. Patches used to be skipped to save CI minutes, but this repo is -# public so GitHub-hosted runners (incl. the 10×/2× macOS/Windows legs) are free + -# unlimited — so every release, patch or minor, ships fresh desktop binaries and an -# updated latest.json (no more hand-attaching a patch's binaries). macOS builds are signed -# only when the full Apple secret set is present; Linux/Windows are always unsigned. See -# ADR 0010 (UI tiers / desktop). +# Runs on MANUAL DISPATCH ONLY (workflow_dispatch) — deliberately NOT on tag pushes. +# The macOS (10×) and Windows (2×) legs are this repo's only paid CI, and building the +# full matrix on EVERY semver tag (dozens/month at current release cadence) was the +# dominant cost — so desktop drops are now on-demand instead of per-release. Tag pushes +# still ship the Docker image + a (non-Latest) GitHub Release via release.yml; only the +# desktop binaries wait for a dispatch. +# +# Two dispatch modes, keyed off the `tag` input: +# • WITH a `tag` (a vX.Y.Z) → a real desktop RELEASE: binaries attach to that GitHub +# Release, the fan-in composes latest.json, and the release is promoted to 'Latest' +# (release.yml creates releases --latest=false; the promotion happens here). This is +# how a desktop update reaches users' in-app updater. +# • WITHOUT a tag (dispatched from a branch) → a TEST build: bundles upload as workflow +# artifacts only; no release, no latest.json, no 'Latest' change. +# +# macOS builds are signed only when the full Apple secret set is present; Linux/Windows +# are always unsigned. The repo guard keeps forks from running the matrix. See ADR 0010 +# (UI tiers / desktop) and docs/guides/releasing.md § Desktop. on: - push: - tags: - - 'v*.*.*' workflow_dispatch: inputs: tag: - description: 'Tag/ref to build against (blank = current ref)' + description: 'Tag to publish a desktop release for (vX.Y.Z). Blank = test build (artifacts only, no release).' required: false concurrency: - group: desktop-build-${{ github.ref }} + # Keyed on the dispatched tag (or ref) so two release dispatches don't collide. + group: desktop-build-${{ inputs.tag || github.ref }} cancel-in-progress: false permissions: @@ -47,12 +56,11 @@ permissions: jobs: build: name: ${{ matrix.target }} - # Build on every semver tag push (patch included) + any manual dispatch. The old - # patch-skip was a CI-cost guard; it's unnecessary on a public repo where GH-hosted - # runners are free, and it meant patch desktop fixes never reached users (their - # binaries weren't attached and latest.json kept pointing at the last minor). The - # repo guard keeps forks from running the matrix. - if: github.repository == 'protoLabsAI/protoAgent' + # Manual dispatch only (see the `on:` block) — the tag-push trigger was retired + # because the macOS/Windows matrix on every release was the repo's dominant CI cost. + # Fork-friendly (#1534): a fork can dispatch its own (unsigned) build; the repo + # clause keeps forks from auto-building should a tag trigger ever return. + if: github.repository == 'protoLabsAI/protoAgent' || github.event_name == 'workflow_dispatch' # Per-leg runner, overridable by repo variable so the expensive macOS/Windows # legs can move off GitHub-hosted runners (10×/2× billing multipliers) onto # Namespace profiles WITHOUT editing this file — the moment the org provisions @@ -199,8 +207,8 @@ jobs: SIGNED="unsigned (dev)" if [ "${RUNNER_OS}" = "macOS" ]; then # tauri-bundler signs as soon as *any* Apple secret is set, then fails - # if the set is incomplete. Require the full set; otherwise unset all so - # a dispatch build falls back cleanly to unsigned. + # if the set is incomplete. Require the full set for a tagged (publish) + # dispatch; a test build (no tag) unsets all and falls back to unsigned. if [ -n "${APPLE_CERTIFICATE}" ] && [ -n "${APPLE_CERTIFICATE_PASSWORD}" ] \ && [ -n "${APPLE_SIGNING_IDENTITY}" ] && [ -n "${APPLE_API_ISSUER}" ] \ && [ -n "${APPLE_API_KEY}" ] && [ -n "${APPLE_API_KEY_BASE64}" ] \ @@ -210,8 +218,8 @@ jobs: python3 -c 'import base64,os,sys; open(sys.argv[1],"wb").write(base64.b64decode(os.environ["APPLE_API_KEY_BASE64"]))' "$KEYFILE" export APPLE_API_KEY_PATH="${KEYFILE}" SIGNED="Developer ID (signed + notarized)" - elif [ "${{ github.event_name }}" = "push" ]; then - echo "::error::Semver-tag desktop releases require the full Apple signing secret set." + elif [ -n "${{ inputs.tag }}" ]; then + echo "::error::A tagged desktop release (tag=${{ inputs.tag }}) requires the full Apple signing secret set." exit 2 else unset APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \ @@ -335,15 +343,17 @@ jobs: if [ "${{ steps.build.outputs.signed }}" = "true" ]; then FLAG="--require-signed"; fi scripts/verify-macos-desktop.sh $FLAG dist-desktop/*.dmg - - name: Upload dispatch artifact - if: github.event_name == 'workflow_dispatch' + - name: Upload test-build artifact + # No tag → a test build: bundles are downloadable workflow artifacts only. + if: inputs.tag == '' uses: actions/upload-artifact@v6 with: name: protoAgent-${{ steps.version.outputs.version }}-${{ matrix.target }} path: dist-desktop/* - name: Attach to release - if: github.event_name == 'push' + # A tag → publish: attach binaries to that GitHub Release (fan-in adds latest.json). + if: inputs.tag != '' shell: bash env: GH_TOKEN: ${{ github.token }} @@ -351,29 +361,27 @@ jobs: # Fan-in: compose latest.json from every leg's signed updater bundle and # upload it LAST, so the manifest never points at assets that don't exist - # yet. Runs only on semver tags, and only once ALL legs succeeded (a partial - # manifest would skew versions across platforms). If the release carries no - # updater bundles (key wasn't present), it skips — in-app updates are simply - # disabled for that release. + # yet. Runs only for a tagged (publish) dispatch, and only once ALL legs + # succeeded (a partial manifest would skew versions across platforms). If the + # release carries no updater bundles (key wasn't present), it skips — in-app + # updates are simply disabled for that release. updater-manifest: name: updater manifest (latest.json) needs: build - # Runs on every tag push (patch included) so the in-app updater's latest.json is - # refreshed for every release. Gated to `push` so a manual dispatch (which builds - # artifacts, with no release to attach to) doesn't try to compose a manifest; - # `needs: build` ties it to a successful matrix. + # Only for a publish dispatch (a `tag` was supplied) — a test build (no tag) has no + # release to attach a manifest to. `needs: build` ties it to a successful matrix. if: >- - github.event_name == 'push' && github.repository == 'protoLabsAI/protoAgent' + inputs.tag != '' && github.repository == 'protoLabsAI/protoAgent' runs-on: ubuntu-latest # workspace-config: allow-hosted-runner public repo — GH-hosted is free timeout-minutes: 10 steps: # Checkout at the tag so we can read the rolled CHANGELOG.md section for the - # in-app updater notes (below). By tag-push time CHANGELOG.md already holds the + # in-app updater notes (below). By publish time CHANGELOG.md already holds the # dated `## [VERSION]` section — the bump PR (prepare-release.yml) merged before # the tag. Without this the job has no working tree (it only `gh release`s). - uses: actions/checkout@v5 with: - ref: ${{ github.ref_name }} + ref: ${{ inputs.tag }} - name: Setup Node uses: actions/setup-node@v5 @@ -386,7 +394,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG="${{ github.ref_name }}" + TAG="${{ inputs.tag }}" VERSION="${TAG#v}" mkdir -p updater-dist gh release download "$TAG" --repo "${{ github.repository }}" --dir updater-dist \ @@ -397,12 +405,19 @@ jobs: exit 0 fi # Updater notes, best → worst source: - # 1. The curated CHANGELOG.md section for this version (the human-written - # "### Added/Changed/Fixed" markdown) — what the in-app UpdateNotice renders. - # 2. The GitHub Release body (auto-generated commit subjects) — used when the - # CHANGELOG section is empty (e.g. a patch with no [Unreleased] bullets). - # 3. A generic placeholder, if both are empty. - NOTES="$(python3 scripts/changelog.py notes "$VERSION" 2>/dev/null || true)" + # 1. The LLM-themed notes posted to Discord — release.yml uploads them as the + # `release-notes.md` asset, so the in-app UpdateNotice renders the SAME text as + # the Discord embed (one changelog, one voice — #1516). + # 2. The curated CHANGELOG.md section for this version (the human-written + # "### Added/Changed/Fixed" markdown) — used when the LLM step didn't run + # (e.g. a fork with no gateway key, or the Discord step failed). + # 3. The GitHub Release body (auto-generated commit subjects) — if both are empty. + # 4. A generic placeholder, if all are empty. + NOTES="" + if gh release download "$TAG" --repo "${{ github.repository }}" -p release-notes.md -D "$RUNNER_TEMP" --clobber 2>/dev/null; then + NOTES="$(cat "$RUNNER_TEMP/release-notes.md" 2>/dev/null || true)" + fi + [ -z "$NOTES" ] && NOTES="$(python3 scripts/changelog.py notes "$VERSION" 2>/dev/null || true)" [ -z "$NOTES" ] && NOTES="$(gh release view "$TAG" --repo "${{ github.repository }}" --json body -q .body 2>/dev/null || true)" [ -z "$NOTES" ] && NOTES="protoAgent ${TAG} — see the GitHub Release for details." npx --yes -p '@protolabsai/release-tools@latest' build-updater-manifest \ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1c40b299..415f5969 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -24,11 +24,16 @@ permissions: env: REGISTRY: ghcr.io # Normalise the image name — GHCR paths are case-sensitive and Docker refuses - # uppercase; github.repository would otherwise yield a mixed-case value. - IMAGE_NAME: protolabsai/protoagent + # uppercase; github.repository would otherwise yield a mixed-case value. A fork + # that opts in (see the job guard) sets its own `IMAGE_NAME` repo variable. + IMAGE_NAME: ${{ vars.IMAGE_NAME || 'protolabsai/protoagent' }} jobs: build-and-push: + # Fork-friendly (#1534): the hardcoded default pushes to protoLabs' GHCR, which + # forks can't write to. Only the canonical repo runs by default; a fork opts in + # by setting the `DOCKER_PUBLISH_ENABLED` repo variable (+ its own `IMAGE_NAME`). + if: github.repository == 'protoLabsAI/protoAgent' || vars.DOCKER_PUBLISH_ENABLED == 'true' runs-on: ubuntu-latest # workspace-config: allow-hosted-runner public repo — GH-hosted is free timeout-minutes: 30 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a7a4796d..e978a1f2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,8 +7,11 @@ on: # PR-level gate (#976): vitepress fails the build on parse errors AND dead links, # so running docs:build here catches doc breakage at review time instead of letting # it merge green and only go red on the main-only deploy. Build-only — no deploy. + # Unfiltered on purpose: `build` is a REQUIRED check in the main ruleset, and a + # path-filtered required check never reports on non-matching PRs, blocking them + # forever. Running everywhere (~30s) is the price of making red docs unmergeable + # (a dead link once automerged and froze the Pages deploy on every main push). pull_request: - paths: ['docs/**', 'package.json', 'package-lock.json', '.github/workflows/docs.yml'] workflow_dispatch: permissions: @@ -41,8 +44,11 @@ jobs: deploy: needs: build - # Never deploy from a PR — PRs only validate the build above. - if: github.event_name != 'pull_request' + # Never deploy from a PR — PRs only validate the build above. Fork-friendly + # (#1534): deploys to protoLabs' GitHub Pages, so only the canonical repo runs + # it — a fork without Pages configured would otherwise get a deploy error. The + # `build` job above is unguarded on purpose: forks want it (catches VitePress breakage). + if: github.event_name != 'pull_request' && github.repository == 'protoLabsAI/protoAgent' runs-on: ubuntu-latest # workspace-config: allow-hosted-runner public repo — GH-hosted is free environment: name: github-pages diff --git a/.github/workflows/marketing-deploy.yml b/.github/workflows/marketing-deploy.yml index ec5da7d8..2883e81b 100644 --- a/.github/workflows/marketing-deploy.yml +++ b/.github/workflows/marketing-deploy.yml @@ -26,6 +26,9 @@ concurrency: jobs: deploy: name: Build & deploy to Cloudflare Pages + # Fork-friendly (#1534): deploys to protoLabs' Cloudflare Pages project and needs + # CLOUDFLARE_* secrets no fork has — so only the canonical repo runs it. + if: github.repository == 'protoLabsAI/protoAgent' runs-on: ubuntu-latest # workspace-config: allow-hosted-runner public repo — GH-hosted is free steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81badd20..88a2052d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,9 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: protolabsai/protoagent + # Overridable (#1534): a fork that opts into RELEASE_ENABLED sets its own + # `IMAGE_NAME` repo variable to push to its GHCR instead of protoLabs'. + IMAGE_NAME: ${{ vars.IMAGE_NAME || 'protolabsai/protoagent' }} jobs: release: @@ -145,13 +147,14 @@ jobs: NOTES: ${{ steps.notes.outputs.notes }} run: | printf '%s' "$NOTES" > "$RUNNER_TEMP/release-notes.md" - # --latest=false: do NOT promote to 'Latest' yet. The in-app updater fetches + # --latest=false: do NOT promote to 'Latest'. The in-app updater fetches # releases/latest/download/latest.json, so a release must not become 'Latest' - # until its latest.json exists — otherwise update checks 404 in the window - # before the desktop build's fan-in uploads it. The desktop fan-in flips this - # release to 'Latest' once latest.json is up (desktop-build.yml). Patch tags - # skip the desktop build entirely, so they stay non-Latest and 'Latest' - # correctly remains on the last .0 (the one carrying latest.json). + # until its latest.json exists — otherwise update checks 404. Desktop builds + # are now MANUAL (desktop-build.yml runs on workflow_dispatch, not on tags), so + # every tagged release stays non-Latest here; it's promoted to 'Latest' only + # when someone dispatches a desktop build for that tag and its fan-in uploads + # latest.json. 'Latest' therefore tracks the last DESKTOP release (the one the + # updater can actually use), not the newest tag. gh release create "${{ steps.version.outputs.tag }}" \ --title "${{ steps.version.outputs.tag }}" \ --notes-file "$RUNNER_TEMP/release-notes.md" \ @@ -164,11 +167,28 @@ jobs: # scripts/post-release-notes.mjs. - name: Generate + post release notes + id: relnotes continue-on-error: true - uses: protoLabsAI/release-tools@b7c3531dd67accb57ca09242f47d8c0eb1ace8eb # v1.1.0 + uses: protoLabsAI/release-tools@d6bfc09ce7d17da3f196f3b54afde5830b8a6398 # v2.4.0 — first version with the out-file input (#1516/#1615) with: version: ${{ steps.version.outputs.tag }} previous-version: ${{ steps.version.outputs.prev_tag }} + # Persist the SAME themed notes it posts to Discord (one generation writes the file + # AND posts the embed), so the in-app updater can render the identical text — one + # changelog, one voice (#1516). Uploaded as a release asset below; the (later, + # manual) desktop build prefers it for latest.json. + out-file: release-notes.md env: GATEWAY_API_KEY: ${{ secrets.GATEWAY_API_KEY }} DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + + # Attach the LLM notes to the release so desktop-build.yml's latest.json fan-in can read + # them (it runs on a later manual dispatch, so the asset must live on the release, not a + # step output). Best-effort + guarded: a fork with no GATEWAY_API_KEY writes no file, the + # upload is skipped, and the updater falls back to the curated CHANGELOG.md section. + - name: Persist LLM release notes as an asset + if: hashFiles('release-notes.md') != '' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ steps.version.outputs.tag }}" release-notes.md --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a56955..28e47dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,430 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.81.0] - 2026-07-02 + +### Changed +- **`seccomp-profile.json` moved to `deploy/`.** The Docker hardening profile now + lives with the other deployment assets instead of at the repo root; + `docker-compose.yml` and `.dockerignore` reference the new path. No behavior + change — Docker reads the profile from the host at compose time. +- **The in-app update notes now match the Discord release announcement** ([#1516]). + The desktop updater's "what's new" card renders the same LLM-themed release notes + that are posted to Discord — one changelog, one voice — instead of the raw + `CHANGELOG.md` section. The release pipeline persists the generated notes as a + `release-notes.md` release asset (the *same* generation that posts the Discord + embed), and the desktop build's `latest.json` fan-in prefers it, falling back to + the curated `CHANGELOG.md` section when the notes step didn't run (e.g. a fork + with no gateway key). Takes effect from the next release. + +[#1516]: https://github.com/protoLabsAI/protoAgent/issues/1516 + +## [0.80.0] - 2026-07-02 + +### Added +- **Remote fleet members are fully manageable from the console** (ADR 0042 §I). + You can now **add a remote by URL** (name + URL + optional token) — the only way + to register a token-gated remote, since network discovery can't carry a + credential — and **edit a member in place** (its URL, token, or name; the id and + slug survive, so open windows don't break), which is how you fix a rotated or + wrong token. Adds surface the server's register-time reachability (an offline + peer is added with an honest "not reachable yet" toast, not a silent dead row), + and an offline remote now reads **"unreachable"** with a warning dot rather than + a neutral "stopped" (a remote's `running` *is* its reachability probe). New + `PATCH /api/fleet/remotes/{ident}` + `supervisor.update_remote` back it, re-running + the same SSRF-egress / collision checks as add and re-probing on save. +- **A down or mis-tokened fleet agent gives you a way out instead of a boot hang.** + A focused member that can't be reached now shows a targeted boot-gate recovery: + a **remote whose box is offline** (a 502, which a remote returns instead of the + local peer's 409) offers *Return to host* / *Try again*, and a **remote whose + stored token is wrong** (a 401) shows *"can't authenticate — update its token in + Settings ▸ Agents"* with *Return to host*. Critically, a proxied member's 401 no + longer trips the **hub's** global token prompt (which asked for, and would + overwrite, the hub's own token) — a member-scoped 401 is recognized as that + member's credential problem. +- **Background results are pushed, indexed, and worker-disposable** + ([ADR 0070](docs/adr/0070-background-results-push-resume.md), backend). When a + background job (ADR 0050) reaches a terminal state: **(D1)** the server + **push-resumes the origin session** — a terse self-A2A nudge turn (the + spawner's self-POST mechanics, factored into a shared + `BackgroundManager._send_a2a_message`) whose drain attaches the pending + ``, so the agent reviews the report and briefs the operator + immediately instead of the report waiting for the session's next manual turn. + New config `background.auto_resume` (default on; Settings ▸ Background). + Guarded: never for canceled jobs, `background:*` origins (no resume chains), + or incognito-spawned jobs; never-raises — a delivery failure falls back to the + ADR 0050 Activity idle-wake and the report still drains exactly-once on the + next manual turn. When the resume fires, the Activity wake is skipped (one + briefing turn, in the right place — never both). A mid-turn origin session is + safe: A2A turns serialize per thread. **(D2)** a substantial completed report + (> 800 chars) is **indexed into the knowledge store** keyed to the origin + session (`source_type: background_report`, trust tier 2 — agent-derived; + chunked by `add_document`; never for incognito-spawned or chained + background-origin jobs — a worker identity is never memory), and the drain + notification shrinks (cap 6 000 → + 3 000 chars) with a pointer to `memory_recall` + the console report card. + **(D3)** worker transcripts are **disposable**: `background:*` sessions skip + session-summary persistence, the `` digest filters worker + files (legacy ones included), and retirement harvest skips worker threads — + the jobs DB is the system of record. The `task`/`task_batch` tools propagate + the turn's **incognito** flag onto the job row (new `origin_incognito` + column, migrated in place). New `GET /api/background/{job_id}` returns one + job's full row (strict `bg-<12 hex>` id validation) for the console report + card. + +### Changed +- **The Work surface is card-first — no tabs** (console). The right-rail Work + hub drops its Overview/Goals/Watches/Tasks/Schedule tab strip: the landing is + always the **Overview**, now four live cards (Goals · **Watches**, previously + missing from the roll-up · Tasks · Schedule) in a responsive grid. Each card + IS the navigation — whole-card click-through (keyboard-accessible, + selection-guarded, raised-card hover like the chat report card) into the + unchanged panel under a slim **"← Overview"** back bar (Escape backs out too, + when no dialog is open). Cards carry an icon + count Badge header, a muted + one-line **pulse** ("2 driving · iteration 3/6", "1 watching · 1 met today", + "0 ready · 1 in progress", "next in 25m"), a StatusDot micro-list, and a + corner **"+" quick-add** that opens the same creator the panel uses — the + Goals inline `
` form became a shared `GoalCreateDialog` (one form, + two hosts: the panel's new "New goal" header action and the overview + quick-add; identical `/api/goals` payload), and `TaskCreateDialog` / + `ScheduleModal` are reused directly. Watches has **no** quick-add (watches + are agent-created; its empty state says so). Liveness: one surface-level SSE + subscription set (`goal.*`, `watch.*`, `task.changed`, `scheduler.fired`) + keeps every card fresh whichever view is open, plus a gentle 60s + schedule poll while the overview is mounted (the scheduler bus has no push + for agent-side add/cancel). +- **The background report card is a real card** + ([ADR 0070](docs/adr/0070-background-results-push-resume.md) D4, console). + A finished background job's report no longer renders as the DS system-message + pill (near-black inset fill, 100px radius, a quiet ghost "Read full report" + link): it's now a raised card — `--pl-color-bg-raised` surface, 1px border, + real corners, drop shadow — with a header row (report title + "Background + report"), an excerpt **clamped to ~7 lines with a bottom fade-out mask** (a + teaser, not the content — the fade applies only when the text actually + overflows, so a short report's final line stays fully readable), and a clear + **"Open report"** CTA into the document + viewer; the whole card is click-to-open (selection-guarded). The viewer now + fetches the full report **by id** via the new `GET /api/background/{id}` + (`api.backgroundJob`), replacing the list-and-filter hack — kept only as a + fallback when the by-id route 404s (pre-0070 servers / deleted rows). Card + styling uses stacked specificity (`.pl-message--system.chat-report …`) so the + DS default can't win by stylesheet load order. +- **`/compact` is now behind the `chat.compact` developer flag** (ADR 0068, + first real flag in the registry). The command shipped in #1558 but is still + pre-release: on the prod channel it no longer appears in the slash menu and + `POST /api/chat/sessions/{id}/compact` refuses with 403; the dev channel (and + `PROTOAGENT_FLAG_CHAT_COMPACT=1`, `?flag:chat.compact=on`, or the Settings ▸ + Developer toggle) keeps it on. The client slash-command seam (ADR 0061) gains + an optional `flag:` tag — the host lists and dispatches a tagged command only + while its flag resolves ON, so forks can flag-gate their own commands the + same way. + +### Security +- **`tools.disabled` now actually removes any tool — including `run_command` — and + the filesystem knobs are per-agent Settings toggles.** The operator denylist used + to be applied only inside `get_all_tools()`, while the filesystem tools (incl. the + dual-use `run_command`), plugin/MCP extras, delegation and late-seam tools were + appended after it — so `tools.disabled: [run_command]` silently did nothing. The + filter now runs over the **fully assembled** toolset in `create_agent_graph` (and + the out-of-graph manual-subagent runner), before the deferred `search_tools` + index is built, and the graph build syncs the denylist from its own config (eval + sweeps / scripts no longer inherit a stale process global). New **Settings ▸ + Capabilities ▸ Filesystem** exposes `filesystem.enabled` / `allow_run` (the + per-agent `run_command` kill switch — the tool is never built when off) / + `run_requires_approval` / `bypass_allowed`, and **Settings ▸ Capabilities ▸ + Tools** exposes the `tools.disabled` list; all hot-reload on save. `filesystem. + projects` now round-trips through `config_to_dict` like the other registries. + +- **Fleet: the hub no longer lends a remote member's token over an unauthenticated + WebSocket, and live events now work through a token-gated hub.** Two proxy-auth + gaps in remote fleet members (ADR 0042 §I), both only reachable on a non-loopback + (tailnet/LAN) hub — a loopback desktop hub was never exposed: + - The hub's default-deny auth is an HTTP middleware (Starlette `BaseHTTPMiddleware` + skips non-HTTP scopes), so the `/agents//*` **WebSocket** proxy route ran + with no hub auth. For a *remote* member the hub would attach that member's stored + bearer and proxy any caller into its authenticated sockets (e.g. a terminal + plugin's PTY). WebSocket proxying to a **remote** member is now refused (close + 1008); host/local-peer sockets — which carry no hub-stored credential — are + unaffected. Live plugin views into a remote member should use `delegate_to` / a + direct connection. + - The SSE token for `/api/events` was fetched slug-routed (signed by the *member*), + but the proxied stream is validated at the **hub's** middleware first — so on a + bearer-gated hub, live events 401'd for every non-host member. The console now + fetches a **hub-signed** SSE token; the hub validates it, then forwards with the + member's own credential so the member accepts it downstream. + +## [0.79.0] - 2026-07-01 + +### Added +- **Memory-regression evals** (`memory-regression` category, + [ADR 0069](docs/adr/0069-memory-delivery-layer.md) D10) — three ADR 0012 + harness probes for the memory delivery layer: a **knowledge-update** case + (seed a fact, seed its supersede, assert the newer value wins and the stale + one is not restated), an **abstention** case (ask about an adjacent-but-absent + fact, rubric-judge that it declines rather than fabricates), and an OWASP + ASI06 **poisoning replay** (ingest a doc with an embedded instruction payload, + then a later benign turn — assert both that the payload token never appears in + the reply *and*, store-side, that the "save a memory that …" payload never + persisted). Adds two reusable, unit-tested assertions: `forbidden_patterns` + (substrings that must be absent from the reply) and + `verify_kb.max_chunks_containing` (`{contains, max, domain?}` — bound a + marker's chunk count). They run under the same live-gateway + + `PROTOAGENT_INSTANCE` scoping as the existing `memory_ingest` cases; see + [the evals guide](docs/guides/evals.md). +- **Memory inspector console surface** — a new core "Memory" rail view + ([ADR 0069](docs/adr/0069-memory-delivery-layer.md) D7, console half) over the + `/api/memory/*` REST surface: **Sessions** (the summaries behind the + `` digest — row click opens the full `recall_session` render in + the document viewer; per-row delete with confirm + toast), **Hot memory** (the + always-on `domain="hot"` chunks — edit/delete per row), and **Injections** (the + per-turn D6 record: which digest sessions / hot chunks / RAG chunks entered + which model call, filterable by session — the poisoning-forensics readout; a + session row jumps straight to its filtered injections). +- **Incognito thread toggle in the console** (ADR 0069 D3b, console half) — a + per-chat-tab incognito mode that stamps `incognito` into the A2A message + metadata on **every** send while ON (the backend flag is per-message; a mixed + thread would leak earlier incognito content into a later turn's summary — the + desktop non-streaming `/api/chat` fallback carries it too). Toggle via the + `/incognito` slash command or the chat-tab context menu ("Turn incognito + on/off"); start a thread private via "New incognito chat"; while ON the tab + shows an eye-off glyph and the composer a clickable "incognito" chip (click to + turn off). Persisted with the session. +- **Trust-tiered injection** ([ADR 0069](docs/adr/0069-memory-delivery-layer.md) + D8). Every knowledge chunk now ranks into a deterministic trust tier by its + `source_type` (`knowledge/trust.py`): 3 = operator-authored (console + routes), 2 = agent-derived (extracted facts, harvest summaries, + `memory_ingest`, compaction archives — the agent write paths now stamp + themselves), 1 = ingested/external (web, YouTube, PDF, media) **and + unknown/unstamped**. The per-turn auto-injected RAG recall down-weights low + tiers (stable post-score sort — a low-trust hit never outranks a + higher-trust one, in-tier relevance preserved), and a new + `knowledge.inject_min_trust` key (default `1` = nothing excluded) can + exclude tiers from auto-injection entirely (`2` drops ingested/external + content, `3` = operator rows only). The tier is visible everywhere: injected + lines and `memory_recall`/`memory_list` citations carry a + `trust: operator|agent|external` label, and tool-driven recall is never + gated — excluded content stays reachable on demand. See + [the knowledge guide](docs/guides/knowledge.md#trust-tiers-adr-0069-d8). +- **Hot-memory write visibility** (ADR 0069 D8). Every write that creates a + `domain="hot"` chunk (injected in front of the model *every* turn) now + emits a `memory.hot_written` event on the plugin event bus (ADR 0039) with + `{chunk_id, source, source_type, preview}` — agent tool, operator route, or + plugin SDK alike — so the console notification path and any bus subscriber + can surface it. An optional confirm gate, `knowledge.hot_write_confirm` + (default `false`), makes the agent's own `memory_ingest` refuse hot writes + with instructions to ask the operator; operator console surfaces are + unaffected. +- **Supersede-don't-delete staleness** ([ADR 0069](docs/adr/0069-memory-delivery-layer.md) + D9). The `chunks` table gains a nullable `invalidated_at` column (additive + migration, namespace precedent): when the session-end fact pass extracts a + fact that *revises* a stored one (same subject, changed details — a + deterministic token-overlap band, never an LLM freshness judgment), the old + row is stamped `invalidated_at` and the new row inserted — history kept for + audit, nothing updated in place or deleted. Default retrieval excludes + invalidated rows everywhere (plain/hybrid/layered `search` — both hybrid + rankings — `list_chunks`, hot-memory injection, `memory_recall`), with an + `include_invalidated=True` escape hatch for audit tooling. Auto-injected RAG + lines now end with the chunk's stored date (`(stored 2026-07-01)`) as a + deterministic in-context recency signal. Operator deletes (`forget_memory`, + the inspector's DELETE routes) stay **hard** deletes — explicit intent beats + history-keeping. See [the knowledge guide](docs/guides/knowledge.md#staleness-supersede-dont-delete). +- **Namespace-scoped auto-injection** (`knowledge.inject_namespaces`, + [ADR 0069](docs/adr/0069-memory-delivery-layer.md) D3a). When set, the per-turn + auto-injected RAG recall only considers chunks in the listed namespaces (`""` + matches un-namespaced chunks); default unset = unfiltered, so box-commons + sharing keeps working. Filters both the keyword and vector rankings on hybrid + stores; tool-driven `memory_recall` is deliberately unscoped. See + [the knowledge guide](docs/guides/knowledge.md#memory-delivery-controls-adr-0069). +- **Incognito threads** (ADR 0069 D3b) — a per-message `incognito` flag + (`POST /api/chat` body field, or A2A message metadata on the streaming path) + that skips session-summary persistence, memory injection (prior-session + digest, hot memory, RAG) for that turn, and the retire-time conversation + harvest (the transcript never enters the knowledge store); the skill index + still injects. Carried through graph state (`ProtoAgentState.incognito`), + stamped explicitly each turn. Backend only — the console thread toggle rides + a later lane. +- **Per-turn memory-injection record** (ADR 0069 D6) — every model call that had + memory auto-injected appends an id-attributed row (digest session ids, hot-memory + chunk ids, RAG chunk ids, approx tokens) to an instance-scoped SQLite log + (`observability/injection_log.py`, `/memory-injections.db`), + readable via `GET /api/memory/injections?session_id=&limit=` (newest-first). + Makes "why did it say that?" answerable and gives memory-poisoning forensics a + paper trail: store row → source session → the turns it entered. +- **Memory-inspector REST surface** (`/api/memory/*`) — the audit surface for the + memory delivery layer ([ADR 0069](docs/adr/0069-memory-delivery-layer.md) D7, + API half; console UI follows). List/get/delete the persisted **session + summaries** behind the `` digest (list rows reuse the digest + derivation, get returns the same render `recall_session` expands, ids share its + path-traversal-safe guard) and list/edit/delete **hot memory** — the + `domain="hot"` chunks injected every turn (edits are pinned to `hot`, deletes + only resolve hot ids). Bearer-gated like every operator route; documented in + [Operator REST API](docs/reference/operator-api.md). +- **`recall_session(session_id)` starter tool** — expands one entry from the + prior-sessions digest into that session's full persisted summary (reasoning- + stripped, capped), with a path-traversal-safe id guard. The on-demand + counterpart to the new digest below. + ([ADR 0069](docs/adr/0069-memory-delivery-layer.md)) +- **Knowledge Base view — collapsible source grouping + Shift+click quick-delete.** Chunks + from the same ingested source (a YouTube transcript, a multi-page doc) now collapse under + one section header showing the source title, type, and chunk count — with a Collapse/Expand-all + toolbar toggle. Only sources with ≥2 loaded chunks group; single/sourceless chunks stay flat + (no regression). Open state persists per source; an active search force-expands so matches stay + visible. And **Shift+click** a chunk's delete button now removes it immediately (no confirm), + matching the chat-tab quick-delete — the plain click still confirms. (#1575, #1582) +- **One-command install** — `curl -fsSL .../scripts/install.sh | sh` takes a fresh + machine from zero to a running, configured protoAgent. It checks prerequisites + (Docker + curl), pulls `ghcr.io/protolabsai/protoagent:latest`, runs it + (loopback-published, named volume, `restart:unless-stopped`, `PROTOAGENT_UI=console`), + then drives a **CLI config wizard over the same `/api/config/*` endpoints as the + browser setup wizard** (gateway URL, silent API-key entry, live model probe + + validation, agent name). Idempotent (re-run updates the image, keeps data, offers + to re-run the wizard), works over a plain SSH session (no-TTY → start + finish in + the browser), and POSIX-sh (`| sh`). On non-amd64 hosts (Apple Silicon) it targets + the amd64 image under emulation with a clear notice. Versioned at + [`scripts/install.sh`](scripts/install.sh); see + [Deploy with Docker → one-command install](docs/guides/deploy-docker.md#one-command-install). +- **Agent archetypes are a data-driven registry** (ADR 0042). The new-agent picker + setup + wizard now read their built-in starter types from `config/archetype-catalog.json` + (served by `GET /api/archetypes`) instead of a hardcoded list — so archetypes can be added + or removed **without a code change**, and a fork/instance overrides the set by dropping its + own `archetype-catalog.json` in the live config dir (same override rule as + `plugin-catalog.json`/`mcp-catalog.json`). Ships **Basic** + **Custom**; installed bundles + that declare an `archetype:` block still self-register on top, now **deduped** by id + bundle + URL so a card can't appear twice. + +### Changed +- **CI workflows are fork-friendly** (#1534). Org-specific / expensive workflows no longer + auto-run or fail on forks: `docker-publish` (opt in with `DOCKER_PUBLISH_ENABLED` + your own + `IMAGE_NAME`), `marketing-deploy` and the GitHub Pages **docs deploy** (canonical-repo only — + the docs *build* still runs on fork PRs), and the `desktop-build` (a fork can `workflow_dispatch` + its own unsigned build). The `checks.yml` `workspace-config` step now runs fleet-wide + (`protoLabsAI/*`) and is skipped on external forks, so a fresh fork gets green CI out of the box; + the `server.py` guard still runs everywhere. `release.yml`/`docker-publish.yml` `IMAGE_NAME` is + now an overridable repo variable. Lint, tests, fleet-integration, live-smoke, web-e2e, issue-gate, + and secret-scan are unchanged (they're already fork-safe). See + [Customize & deploy → un-freeze the release pipeline](docs/guides/customize-and-deploy.md#3-un-freeze-the-release-pipeline). +- **Fleet "New agent" now applies the archetype's persona**, not just its tools. Creating an + agent from an archetype writes its base `SOUL.md` into the new workspace (`POST /api/fleet` + gained a `soul` field), so a bundle agent arrives with its persona wired in. +- **Cross-session memory injection is now an attributed digest inside an + untrusted-reference envelope** ([ADR 0069](docs/adr/0069-memory-delivery-layer.md) + phase 1). `` lists one line per recent session — id · timestamp · + surface · topic (from the user's first message) · message count — instead of + verbatim message text, under a header stating these are *other, separate* + sessions; expand one on demand with `recall_session`. All auto-injected memory + (the digest, hot-memory facts, knowledge-retrieval hits) is wrapped in a single + `` envelope marking it as possibly-stale reference data, never + instructions. Fixes a fresh thread confidently narrating other threads' history + as "the conversation so far", shrinks per-turn injection from ~2 000 tokens of + raw chatter to a ~10-line digest, and removes untrusted ingested content from + the prompt's trusted voice (OWASP ASI06 memory-poisoning posture). +- **Memory rows carry provenance.** Harvested conversation summaries and extracted + facts now store their source session id in the `source` column, and + `memory_recall` / `memory_list` cite `(src: , , ns: )` + per hit — the substrate for the ADR 0069 injection-record and trust-tier phases. +- **The docs build now gates every PR.** The `Deploy docs to GitHub Pages` `build` + job (vitepress parse + dead-link check) runs on all PRs (~30 s, path filter + removed) and is a **required status check** on `main` — a dead docs link can no + longer auto-merge and freeze the Pages deploy. + +### Fixed +- **Fleet agents now actually start in the desktop app, and a boot failure tells + you why.** Creating a new agent (the ADR 0042 new-agent flow) silently produced + a dead member in the frozen desktop build: members were spawned as + ` -m server …`, but in the PyInstaller sidecar `sys.executable` + *is* the server entrypoint, so every spawn died at argparse with + `unrecognized arguments: -m server` — visible only in the workspace's + `agent.log`, which nothing surfaced. The spawn (and the workspace bundle + installer) is now frozen-aware, and `supervisor.start()` watches the fresh + process: if it exits during boot, the state entry is reaped and the API returns + a readable 400 carrying the exit code + the fresh `agent.log` tail (the console + already toasts it; `fleet up` prints it per-member and keeps going). +- **Settings `string_list` fields can now carry an empty-string entry** — a + literal `""` (or `''`) token in the comma-separated editor parses to the empty + string and round-trips back as `""`. Needed for + `knowledge.inject_namespaces`, where the `""` sentinel means "the + un-namespaced rows" (ADR 0069 D3a); previously the editor silently dropped it. +- **Chat code blocks: no empty header gap, a distinct lighter well, and no panel-stretch.** + A fenced block with no language no longer renders an empty ~32px header band (the copy + action now floats over the code's top-right); the code well is lifted onto the lighter + `--pl-color-bg-raised` token so it reads distinctly from system-message/report cards and + select inputs (which use the darkest `--pl-color-bg-inset`); and the block + assistant + markdown column are hard-capped to the message width so a long unwrapped line scrolls + inside the block instead of widening the chat panel. +- **Chat session-identity hygiene** ([ADR 0069](docs/adr/0069-memory-delivery-layer.md) D4). + Omitting `session_id` on `POST /api/chat` now mints a unique per-call id instead of + pooling every caller into one shared `api-default` thread; an empty session id skips + session-memory persistence instead of pooling into `unknown.json`; the streaming and + non-streaming chat paths share one `a2a:` thread-id prefix (previously the same + session split into two histories; existing `chat:*` REST threads orphan once); and + the non-streaming turn now holds the per-thread lock, closing a checkpointer + lost-update race with concurrent streaming/compact/rewind on the same session. +- **Artifact plugin (0.15.0) — pointer lock now works in games/canvas/3D artifacts.** + `requestPointerLock()` from generated code threw *"Pointer lock requires the window to have + focus"*: pointer-lock is a Permissions-Policy feature that must be delegated via `allow=` at + **every** iframe nesting level, and the policy was missing even though the `allow-pointer-lock` + sandbox token was present. The console plugin-view iframe now sends `allow="… ; pointer-lock"` + and the nested artifact iframe carries `allow="pointer-lock"` too. +- **Artifact plugin (0.15.0) — SVG / Mermaid now render crisply instead of pixelating on zoom.** + The graphic viewport CSS-transform-scaled a `will-change` raster layer, so WKWebView (the desktop + app's Safari engine) rasterized the SVG at 1× then GPU-scaled the bitmap → blurry on zoom-in. It's + replaced by a **crisp fit-to-window**: the `` scales as a vector to fit the frame (no transform, + no raster layer). Pan/zoom + the zoom buttons are intentionally dropped in favor of a sharp fit. +- **Artifact plugin (0.15.0) — the selected artifact + version now survive a tab switch.** Switching + console tabs unmounts/remounts the plugin-view iframe, reloading the shell fresh; it used to snap + back to the latest artifact. The selection (`{selId, selVer, followNewest}`) is now persisted to + `localStorage`, with a fallback to auto-follow-newest if the pinned artifact was deleted. +- **A stale untracked plugin copy no longer shadows a newer bundled one.** The loader let the + live/installed plugins dir override the bundled tree *unconditionally*, so a plugin that was + once git-installed and later bundled in-tree (e.g. the artifact plugin @ 0.11.3 vs the bundled + 0.14.0) stayed stuck on the old installed copy forever — it never updated with the app. Now an + **untracked** live copy only wins when it's **not older** than the bundle; an intentional + install/override (tracked in `plugins.lock`) still wins at any version. +- **Plugin install `git clone --no-hardlinks`** — cloning a plugin from a local path hardlinked + source objects and intermittently failed with `fatal: hardlink different from source` (a known + local-clone race that also silently ignores `--depth`); a no-op for the normal remote/network + clone. Fixes recurring CI flakiness in the installer tests. +- **Console dev server no longer defaults to the prod backend.** `apps/web/vite.config.ts` proxied + `npm run dev` / `npm run preview` backend calls to `:7870` (the default/prod instance the desktop + app runs) by default — so browser-based console development silently read and **wrote your real + `~/.protoagent` data**. It now defaults to the **isolated dev instance `:7871`** (`scripts/dev.sh`), + which is fail-safe (a clean "can't connect" when no dev backend is up, never a silent prod hit), + and prints a **loud red guard** if `PROTOAGENT_API_BASE` is ever pointed at `:7870`. +- **Removed the broken built-in "Project Manager" archetype.** It pointed at + `protoLabsAI/pm-stack`, which was split/renamed (→ `product-stack`, `leadEngineer`, + `portfolio-manager-stack`); the stale URL no longer installed what the card promised. Those + stacks self-register as archetypes when installed, and the URL is now a data edit rather than + a code constant. +- **Instance collision warning no longer false-alarms on a shared box root** (#1552). The boot + "another instance can clobber your chat/knowledge/stores" warning keyed on the shared + `box_root`, so it fired for any second process on the machine — including a `dev` instance + (`~/.protoagent/dev`) that keeps entirely separate data. It now keys on **`instance_root`**: + it warns only when another live process shares *this* instance's data root (a genuine + clobber, e.g. the same instance run twice), and stays silent for box-only co-residents with + distinct `PROTOAGENT_INSTANCE` ids. +- **Setup wizard: the archetype bundle-install result is no longer swallowed.** Finishing setup + unmounts the wizard, so the "tools are ready" / "couldn't auto-enable — turn them on in + Settings ▸ Plugins" outcome now shows as a **toast** (survives the unmount) instead of an + in-wizard message the user never saw. +- **Setup wizard: picking a persona-less archetype no longer blanks the editor.** A bundle + archetype whose manifest declares no inline `soul:` now seeds the persona step with the base + SOUL as a fallback, rather than clearing the SOUL textarea. + +## [0.78.0] - 2026-07-01 + ### Added +- **Developer panel — view & toggle developer flags** (#1506, ADR 0068). A new **Settings ▸ Developer** + section (surfaced only off prod — a dev build, a non-prod `developer.channel`, or a `?dev` reveal — + so production operators never see it) lists every registered flag with its tier + resolved state and + lets you flip it **per-device** (device-local overrides, *Reset* to clear). Backed by `GET /api/flags`; + `useFlag(id)` gates console UI, and `?flag:=on|off` gives a shareable per-load override. +- **Developer flags — backend foundation** (#1506, ADR 0068, slice 1). A small local/static + feature-flag system to gate pre-release functionality: `runtime/flags.py` with a `Flag` registry + (`off`·`dev`·`beta`·`on` tiers) and `flag_enabled(id)` / `resolved_flags()`. Enablement resolves + a flag's tier against a runtime **channel** (`prod ⊂ beta ⊂ dev`) — derived from the dev sandbox + instance, a `PROTOAGENT_CHANNEL` env, or the new **`developer.channel`** setting — with a + `PROTOAGENT_FLAG_` env override on top. No flags ship yet; the `/api/flags` route and the + Developer panel are later slices. - **Chat composer: terminal-style input history** (#1496). Press **↑** to recall previously-sent messages into the composer (newest first), **↓** to walk back toward your in-progress draft — just like a shell. Recalled messages are editable before resending; history only triggers at the top/bottom @@ -41,6 +464,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 operator surface** (plugin install, config rewrite, subagent runs, goal/watch set-paths) with `403`. Opt-in — unset ⇒ single-token mode, unchanged. - **Console set-goal form** (#1510) and **live `goal.iteration` progress** in the Goals panel (#1498). +- **Chat: slash commands trigger mid-input + render as command bubbles** (#1530, #1528, #1529). Type + `/` at **any** caret position (not only the first character) to open the command popover; arrow-key + navigation auto-scrolls the focused item into view; an issued command renders as a distinct user + bubble (subtle tint + monospace + `/` badge) so it stays legible in the transcript. +- **Agent switcher: always available, with a Fleet-settings shortcut** (#1544, #1556). The agent-name + dropdown in the header now shows even for a single agent (not only in a multi-agent fleet), so **New + agent** and a new **Fleet settings** link (→ the fleet management dialog) are always one click away. + The brand logo stays a plain mark. +- **Chat: `Cmd/Ctrl+O` toggles the latest tool-call block** (#1526, ADR 0063). Expands the newest tool + call, then collapses it and walks upward through older ones on repeat; a reasoning-only turn is a + no-op. Rebindable in **Settings ▸ Keyboard**. +- **Chat: `/compact` — summarize + archive a long thread** (#1527). Compresses the current conversation + into a summary and rewrites the live context to *[summary + recent messages]* so the agent keeps + context at a fraction of the token cost, while the **full raw transcript is archived to searchable + memory** (recallable via `memory_recall`). Never-lossy: it refuses rather than drop history it + couldn't archive, and keeps tool-call/response pairs intact across the cut. ### Changed - **Knowledge panel: Upload / Add now open in a dialog** (#1502) instead of expanding inline in the @@ -55,6 +494,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 had to emit is retired for the `update_goal_plan` / `abandon_goal` tools. - The A2A-streaming and non-streaming **goal drive loops are unified** (#1497), fixing a fresh-context thread-id drift. +- **Settings sub-panels share one container** (#1545) — the Keyboard and Delegates panels now render + through the same `SettingsSubPanel` chrome (DS `PanelHeader` + scrolling body) as the schema-driven + and other bespoke panels, so header/padding/scroll can't drift per panel. +- **`wait` tool output is conversational** (#1536) — the tool returns a concise summary (e.g. "Wait + scheduled: 5 minutes. Will resume to: …") instead of the raw "Yielding for 300s — you'll be + re-invoked at …", and its docstring tells the agent to paraphrase rather than echo it verbatim. +- **Desktop app builds are on-demand** (#1547) — `desktop-build.yml` now runs on manual dispatch only, + not on every version tag (the macOS/Windows matrix was the dominant CI cost). Cut a desktop release + with `gh workflow run desktop-build.yml -f tag=vX.Y.Z` (attaches binaries + `latest.json`, promotes + to Latest); tag pushes still publish the Docker image + a non-Latest GitHub Release. ### Security - **RCE-via-chat closed** (#1492) — a `/goal` chat message can no longer arm a `command` / `test` / `ci` @@ -70,6 +519,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Docs - New **ADR 0066** (federation token + operator channel) and **ADR 0067** (watch primitive); **ADR 0030** marked superseded. New **Watches** guide; the goal-mode + plugins guides updated for the drive-only model. +- **PROTO.md § Run it**: agent-launched throwaway test servers should be **fully isolated** (own + `PROTOAGENT_BOX_ROOT`, not just an instance id) to avoid clobbering / the desktop co-residence warning + (#1553, #1552); the releasing + desktop docs updated for the manual desktop-build flow (#1547). ## [0.77.0] - 2026-07-01 diff --git a/PROTO.md b/PROTO.md index d112ed0c..0860c85c 100644 --- a/PROTO.md +++ b/PROTO.md @@ -38,6 +38,18 @@ Everything below is the shared protoAgent runtime — unchanged. chat/tasks/knowledge. The default instance is `~/.protoagent/default/` on `:7870`, untouched. `scripts/dev-reset.sh` wipes just the sandbox. Use this for feature testing instead of the default instance. +- **Spinning up a throwaway test server while the user's real instance(s) run + (e.g. an agent booting a PR build for review): FULLY isolate it — own box root + too, not just an instance id.** Plain `dev.sh` shares the box root (`~/.protoagent`), + which is data-safe but trips the desktop's co-residence warning (#1552) and can + collide on box-level resources (mDNS advertise, scheduler owner-lock). Instead: + `PROTOAGENT_BOX_ROOT=/tmp/pa- PROTOAGENT_INSTANCE= python -m server + --port ` — nothing under `~/.protoagent` is shared or touched. Tradeoff: a + fresh box root does **not** inherit box config (`host-config.yaml` gateway/model + defaults), so seed a gateway in that instance if the test needs model-backed + features; pure-console/UI review works as-is. (Serving a worktree's own + `apps/web/dist`: `cd && … python -m server` — `_bundle_root()` anchors + to the loaded `server/` package, so it serves that checkout's build.) - **Factory-reset the default instance:** `scripts/reset.sh` wipes the **prod** instance back to a clean slate (next boot runs the setup wizard) — for testing the fresh-user flow via CLI (there is no in-app reset). **Always `--dry-run` first** to @@ -55,6 +67,14 @@ Everything below is the shared protoAgent runtime — unchanged. the source of truth; `uv.lock` is tracked). `uv sync` to install. - **Console deps:** `npm ci` at the repo root (npm workspaces; the web app is `@protoagent/web`). +- **Console dev loop (frontend):** `npm run dev` (HMR) / `npm run preview` (built dist) serve + the console on `:5173` and **proxy all backend calls (`/api`, `/a2a`, events, `/agents`, + `/plugins`, `/_ds`) to `PROTOAGENT_API_BASE`, default `http://127.0.0.1:7871`** — the + ISOLATED dev instance from `scripts/dev.sh`, **not** the default/prod `:7870` the desktop app + runs. So the correct loop is *`scripts/dev.sh` (backend, :7871) + `npm run dev` (frontend)* — + both isolated, so dev testing never touches your `~/.protoagent` data. Vite prints a loud red + guard if you ever point `PROTOAGENT_API_BASE` at `:7870`. (Historically it defaulted to + `:7870`, which silently crossed dev traffic into the prod/desktop instance.) ## Must pass before opening a PR diff --git a/README.md b/README.md index 2982d9e3..1205e9eb 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,21 @@ through every wizard step with screenshots. Once you're happy and want to ship it as your own image in your own GHCR: [Customize & deploy](./docs/guides/customize-and-deploy.md). +## One-command install (Docker) + +No clone, no Python — for a fresh box you just SSH'd into. Pulls the published +image, runs it, and walks a CLI wizard (the same `/api/config/*` endpoints the +browser wizard uses) to configure a provider, model, and agent name: + +```bash +curl -fsSL https://raw.githubusercontent.com/protoLabsAI/protoAgent/main/scripts/install.sh | sh +``` + +Re-running updates the image (the data volume is preserved) and offers to +re-run the wizard. Works over a plain SSH session — with no TTY it starts the +container and points you at the console to finish. See +[Deploy with Docker → one-command install](./docs/guides/deploy-docker.md#one-command-install). + ## Run headless The web console is optional — protoAgent is an **API-first agent server**. Run it diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..b7819994 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,27 @@ +# Roadmap + +Where protoAgent is headed, kept honest and light. This is the source of truth for the +marketing site's `/roadmap` page — `scripts/roadmap.py build` parses it into +`sites/marketing/data/roadmap.json`. Group items under `## Planned`, `## In progress`, or +`## Shipped`; each bullet is a short **title** — one-line detail with an optional `(#issue)` +or `(vX.Y.Z)` reference. + +## Planned + +- **One-command install** — a `curl | sh` bootstrap with an interactive CLI config wizard. (#1520) +- **Migrate from Hermes** — a script that imports an existing Hermes agent into protoAgent. (#1515) +- **Migrate from OpenClaw** — a script that imports an existing OpenClaw agent into protoAgent. (#1514) +- **Federation token follow-ups** — management UI, peer rotation, and fleet integration for ADR 0066 tokens. (#1504) +- **Rewind a chat thread** — jump a conversation back to an earlier message and branch from there. (#1535) + +## In progress + +- **Plugin management from the rail** — uninstall a plugin from the rail context menu and a plugin-management settings panel. (#1522) +- **Plugin version + update** — show the installed version inline with an "update if available" action. (#1521) +- **Live Work panel** — reflect goal, task, and schedule changes as they happen, without a manual refresh. (#1537) + +## Shipped + +- **/compact** — summarize and archive a long chat thread in a single command. (v0.78.0) +- **Developer flags** — gate pre-release work behind local feature flags, with a Settings ▸ Developer panel. (v0.78.0) +- **Watches** — supervise many external conditions at once as a first-class primitive. (v0.78.0) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 5ed1b7ed..9cf653ea 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -54,8 +54,13 @@ relaunches — agent data (`PROTOAGENT_CONFIG_DIR`, workspaces) is untouched. ## Platforms & CI -`.github/workflows/desktop-build.yml` builds all three platforms on semver tags -(and on manual dispatch, which uploads workflow artifacts instead): +`.github/workflows/desktop-build.yml` builds all three platforms on **manual dispatch +only** (`workflow_dispatch`) — the tag-push trigger was retired because the macOS (10×) +and Windows (2×) legs were the repo's dominant CI cost. Dispatch **with** a `tag` input +(`gh workflow run desktop-build.yml -f tag=vX.Y.Z`) publishes a real release: binaries +attach to that GitHub Release, `latest.json` is composed, and the release is promoted to +`Latest`. Dispatch **without** a tag is a test build (workflow artifacts only). See +`docs/guides/releasing.md` § Desktop. | Platform | Artifact | Signing | |---|---|---| diff --git a/apps/web/e2e/chat-stop.spec.ts b/apps/web/e2e/chat-stop.spec.ts new file mode 100644 index 00000000..b47a886b --- /dev/null +++ b/apps/web/e2e/chat-stop.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; + +// Composer Stop (#1617). While a turn streams, the Stop control must actually +// halt it: a server-side A2A CancelTask on the wire — the only thing that stops +// the turn when the local stream can't be aborted (the desktop relay ignores the +// abort signal; a re-attached slot has no controller at all) — plus the thread +// settled locally so no bubble is left `streaming`. Before the fix, Stop was a +// silent no-op whenever the slot's live taskId state was empty. + +test("Stop during a streaming turn sends CancelTask for the turn's task and settles the thread", async ({ + page, +}) => { + await page.goto("/app/", { waitUntil: "load" }); + const composer = page.getByPlaceholder(/Message protoAgent/i); + await expect(composer).toBeVisible(); + + // Start a turn the mock HOLDS OPEN — the surface stays "streaming", the state + // where the kit renders the dedicated Stop control beside Send. + await composer.fill("hold the turn open"); + await composer.press("Enter"); + const stop = page.getByRole("button", { name: "Stop" }); + await expect(stop).toBeVisible(); + + // Stop must put a CancelTask for THIS turn's task id on the wire — prove the + // RPC, not just the local settle (the server cancel is what halts generation). + const cancelled = page.waitForRequest( + (r) => + r.method() === "POST" && + r.url().endsWith("/a2a") && + (r.postData() || "").includes('"CancelTask"'), + ); + await stop.click(); + const req = await cancelled; + expect(req.postData()).toContain("task-e2e-1"); + + // Locally settled: composer back to idle (Stop gone, send placeholder back), + // and no assistant bubble left spinning. + await expect(stop).toBeHidden(); + await expect(page.getByPlaceholder(/Message protoAgent/i)).toBeVisible(); + await expect(page.locator(".pl-message--assistant .spin")).toHaveCount(0); +}); diff --git a/apps/web/e2e/commands.spec.ts b/apps/web/e2e/commands.spec.ts index ed9bb156..9b79428c 100644 --- a/apps/web/e2e/commands.spec.ts +++ b/apps/web/e2e/commands.spec.ts @@ -6,7 +6,7 @@ import { SLASH_COMMANDS } from "./fixtures.mjs"; // (GET /api/chat/commands) and autocompletes them as you type "/name". // Deterministic client-side commands (ADR 0057) surface FIRST, then the server skills. -const CLIENT_SLASH = ["/new", "/clear", "/effort", "/bypass"]; +const CLIENT_SLASH = ["/new", "/clear", "/compact", "/effort", "/incognito", "/bypass"]; test.beforeEach(async ({ page }) => { await page.goto("/app/", { waitUntil: "load" }); @@ -27,6 +27,22 @@ test("slash menu opens and lists the client + server commands", async ({ page }) expect(names).toContain("/research-and-brief"); }); +test("a flag-gated command (/compact, ADR 0068) vanishes when its flag is forced off", async ({ page }) => { + // The ?flag: query override is the shareable "try this build" layer — here it turns the + // chat.compact flag OFF over the mock server's enabled state, so /compact must not list. + await page.goto("/app/?flag:chat.compact=off", { waitUntil: "load" }); + const composer = page.getByPlaceholder(/Message protoAgent/i); + await composer.fill("/"); + + const menu = page.locator(".slash-menu"); + await expect(menu).toBeVisible(); + const names = await menu.locator(".slash-name").allInnerTexts(); + expect(names).toEqual([ + ...CLIENT_SLASH.filter((n) => n !== "/compact"), + ...SLASH_COMMANDS.map((c) => `/${c.name}`), + ]); +}); + test("filtering narrows the menu and selecting completes the command", async ({ page }) => { const composer = page.getByPlaceholder(/Message protoAgent/i); await composer.fill("/go"); diff --git a/apps/web/e2e/delegates.spec.ts b/apps/web/e2e/delegates.spec.ts index 8314084f..4bfe1512 100644 --- a/apps/web/e2e/delegates.spec.ts +++ b/apps/web/e2e/delegates.spec.ts @@ -13,8 +13,10 @@ async function openIntegrations(page) { test("lists configured delegates with type + secret badges", async ({ page }) => { await openIntegrations(page); + // The panel title now renders in the shared SettingsSubPanel header (#1545) as a DS + // PanelHeader heading (outside .delegates-section); the rows stay inside it. + await expect(page.getByRole("heading", { name: "Delegates" })).toBeVisible(); const panel = page.locator(".delegates-section"); - await expect(panel.getByText("Delegates", { exact: true })).toBeVisible(); const row = panel.locator(".subagent-row", { hasText: "opus" }); await expect(row).toBeVisible(); await expect(row.getByText("openai", { exact: true })).toBeVisible(); // DS Badge (#832) diff --git a/apps/web/e2e/developer-flags.spec.ts b/apps/web/e2e/developer-flags.spec.ts new file mode 100644 index 00000000..d453e077 --- /dev/null +++ b/apps/web/e2e/developer-flags.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from "@playwright/test"; + +// Developer flags panel (ADR 0068). The mock serves channel "dev", so the Developer section +// appears under Settings ▸ This console; toggling a flag persists a device-local override that +// a Reset clears. + +test("Developer panel lists flags and a toggle persists as an override", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByTestId("settings-widget").click(); + await expect(page.locator(".settings-overlay")).toBeVisible(); + + // Off prod (mock channel = dev) the Developer section is present. + await page.locator(".settings-overlay .pl-sidenav").getByRole("tab", { name: "Developer", exact: true }).click(); + const panel = page.getByTestId("developer-panel"); + await expect(panel).toBeVisible(); + await expect(panel.getByText("chat.new_dashboard")).toBeVisible(); + await expect(panel.getByText("chat.experimental_widget")).toBeVisible(); + + // Toggle the beta flag → an "overridden" badge + a Reset appear. + const row = panel.locator('[data-key="flag.chat.new_dashboard"]'); + await expect(row.getByText("overridden")).toHaveCount(0); + await row.locator(".pl-switch").click(); + await expect(row.getByText("overridden")).toBeVisible(); + + // Reset → the override clears. + await row.getByRole("button", { name: "Reset" }).click(); + await expect(row.getByText("overridden")).toHaveCount(0); +}); diff --git a/apps/web/e2e/fixtures.mjs b/apps/web/e2e/fixtures.mjs index 98fe37c7..2e2835bf 100644 --- a/apps/web/e2e/fixtures.mjs +++ b/apps/web/e2e/fixtures.mjs @@ -111,7 +111,7 @@ export const FLEET = { export const ARCHETYPES = [ { id: "basic", label: "Basic", icon: "Sparkles", blurb: "A plain agent — add tools later.", bundle: null, soul: "# Identity\n\nI am a general-purpose agent." }, - { id: "pm-stack", label: "Project Manager", icon: "LayoutGrid", blurb: "PM tools + board.", bundle: "https://github.com/protoLabsAI/pm-stack", soul: "# Identity\n\nI am a project-management agent." }, + { id: "product-stack", label: "Product Manager", icon: "Compass", blurb: "Research, strategy, and specs — rendered inline.", bundle: "https://github.com/protoLabsAI/product-stack", soul: "# Identity\n\nI am a product-management agent." }, { id: "custom", label: "Custom", icon: "PenLine", blurb: "Write your own — fill in a template.", bundle: null, soul: "# Identity\n\n_Describe your agent in one paragraph._" }, ]; @@ -175,6 +175,40 @@ export const GOALS = { ], }; +// Watches (ADR 0067) — varied statuses so the Work overview card renders an active +// count, a met-today pulse fragment, and tinted status badges. Times are epoch SECONDS. +// Mirror the controller's write order: the met path sets `finished_at` and skips the +// `last_checked` update, so a finished watch's last_checked is the PREVIOUS check. +// "met today" derives from finished_at on the local day, so keep it near now. +export const WATCHES = { + enabled: true, + watches: [ + { + id: "watch-1", + condition: "CI is green on main", + status: "active", + verifier: { type: "llm" }, + last_checked: Math.floor(Date.now() / 1000) - 120, + }, + { + id: "watch-2", + condition: "The staging deploy finishes", + status: "met", + verifier: { type: "llm" }, + last_checked: Math.floor(Date.now() / 1000) - 900, + finished_at: Math.floor(Date.now() / 1000) - 600, + }, + { + id: "watch-3", + condition: "Inbox zero before Friday", + status: "expired", + verifier: { type: "llm" }, + last_checked: Math.floor(Date.now() / 1000) - 7500, + finished_at: Math.floor(Date.now() / 1000) - 7200, + }, + ], +}; + export const NOTES_WORKSPACE = { version: 1, workspaceVersion: 1, @@ -235,6 +269,25 @@ export const SETTINGS_SCHEMA = [ { key: "runtime.autostart_on_boot", label: "Autostart on boot", type: "bool", section: "Runtime", restart: true, description: "Install/remove the boot LaunchAgent.", options: [], value: false, default: false, scope: "agent", source: "agent" }, ], }, + // The per-agent run_command controls + the operator tool denylist — edited via the + // Tools panel's QuickSetting chips (no generic Capabilities panel renders these). + { + section: "Filesystem", + category: "Capabilities", + fields: [ + { key: "filesystem.enabled", label: "Filesystem tools", type: "bool", section: "Filesystem", restart: false, description: "", options: [], value: true, default: true, scope: "agent", source: "agent" }, + { key: "filesystem.allow_run", label: "Allow run_command", type: "bool", section: "Filesystem", restart: false, description: "", options: [], value: true, default: true, scope: "agent", source: "agent", depends_on: { key: "filesystem.enabled" } }, + { key: "filesystem.run_requires_approval", label: "Require approval per command", type: "bool", section: "Filesystem", restart: false, description: "", options: [], value: true, default: true, scope: "agent", source: "agent", depends_on: { key: "filesystem.allow_run" } }, + { key: "filesystem.bypass_allowed", label: "Allow /bypass", type: "bool", section: "Filesystem", restart: false, description: "", options: [], value: true, default: true, scope: "agent", source: "agent", depends_on: { key: "filesystem.run_requires_approval" } }, + ], + }, + { + section: "Tools", + category: "Capabilities", + fields: [ + { key: "tools.disabled", label: "Disabled tools", type: "string_list", section: "Tools", restart: false, description: "", options: [], value: [], default: [], scope: "agent", source: "agent" }, + ], + }, // A plugin-contributed group (ADR 0019/0059) — tagged with plugin_id so the // Plugins surface folds it into the "Demo Plugin" Installed row (Configure). { @@ -687,6 +740,81 @@ export const KNOWLEDGE_CHUNKS = [ created_at: "2026-06-02T09:00:00+00:00", tier: "private", // private to this agent → eligible for Share (promote) }, + // A multi-chunk ingested source (#1575) → renders as one collapsible section + // ("… · 3 chunks"). ids 11/12 above stay flat (single/no source), so the + // list + promote/unshare specs are unaffected. + { id: 13, heading: "", content: "Switchbacks keep the grade walkable.", preview: "Switchbacks keep the grade walkable.", + domain: "media", source: "Hiking with Kevin — Christina Mariani", source_type: "youtube", finding_type: null, + created_at: "2026-06-04T10:00:00+00:00", tier: null }, + { id: 14, heading: "", content: "Bring more water than you think.", preview: "Bring more water than you think.", + domain: "media", source: "Hiking with Kevin — Christina Mariani", source_type: "youtube", finding_type: null, + created_at: "2026-06-04T10:01:00+00:00", tier: null }, + { id: 15, heading: "", content: "The summit view pays off the climb.", preview: "The summit view pays off the climb.", + domain: "media", source: "Hiking with Kevin — Christina Mariani", source_type: "youtube", finding_type: null, + created_at: "2026-06-04T10:02:00+00:00", tier: null }, +]; + +// Memory inspector (ADR 0069 D7) — session-summary digest rows, hot-memory chunks, +// and the per-turn injection record the Memory surface renders. +export const MEMORY_SESSIONS = [ + { + session_id: "chat-1750000000000-abc123", + timestamp: "2026-06-30T18:00:00+00:00", + surface: "chat", + topic: "plan the memory hardening rollout", + message_count: 12, + size_bytes: 2048, + }, + { + session_id: "sched-hourly-report", + timestamp: "2026-06-30T17:00:00+00:00", + surface: "background", + topic: "compile the hourly ops report", + message_count: 4, + size_bytes: 512, + }, +]; + +export const MEMORY_SESSION_RENDERED = + '\n' + + " \n" + + " plan the memory hardening rollout\n" + + " Here is the phased plan…\n" + + " \n" + + ""; + +export const MEMORY_HOT = [ + { + id: 31, heading: "Operator timezone", content: "The operator works in US/Pacific.", + preview: "The operator works in US/Pacific.", + domain: "hot", source: "chat-1750000000000-abc123", source_type: "extracted", finding_type: null, + created_at: "2026-06-29T10:00:00+00:00", + }, + { + id: 32, heading: "", content: "Weekly report goes out Fridays at 9am.", + preview: "Weekly report goes out Fridays at 9am.", + domain: "hot", source: "console", source_type: "operator", finding_type: null, + created_at: "2026-06-28T09:00:00+00:00", + }, +]; + +export const MEMORY_INJECTIONS = [ + { + ts: "2026-06-30T18:05:00+00:00", + session_id: "chat-1750000000000-abc123", + digest_session_ids: ["sched-hourly-report"], + hot_chunk_ids: [31, 32], + rag_chunk_ids: [11], + approx_tokens: 420, + }, + { + ts: "2026-06-30T17:01:00+00:00", + session_id: "sched-hourly-report", + digest_session_ids: [], + hot_chunk_ids: [31], + rag_chunk_ids: [], + approx_tokens: 120, + }, ]; // Delegate registry (ADR 0025) — types schema + a sample roster for the panel. diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 79b74e3e..ad57e7b2 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -55,8 +55,8 @@ test("New agent → archetype picker → create returns to the list", async ({ p await openAgents(page); await page.getByRole("button", { name: "New agent" }).click(); await expect(page.getByRole("heading", { name: "New agent" })).toBeVisible(); - await expect(page.locator(".pl-radiocard")).toHaveCount(2); // DS RadioCard, from GET /api/archetypes - await page.locator(".pl-radiocard", { hasText: "Project Manager" }).click(); + await expect(page.locator(".pl-radiocard")).toHaveCount(2); // DS RadioCard, from GET /api/archetypes (Custom filtered out) + await page.locator(".pl-radiocard", { hasText: "Product Manager" }).click(); await page.getByLabel("Agent name").fill("newbot"); await page.getByRole("button", { name: /Create/ }).click(); await expect(page.locator(".fleet-row", { hasText: "newbot" })).toBeVisible(); diff --git a/apps/web/e2e/incognito.spec.ts b/apps/web/e2e/incognito.spec.ts new file mode 100644 index 00000000..af64e8e9 --- /dev/null +++ b/apps/web/e2e/incognito.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from "@playwright/test"; + +// Incognito threads (ADR 0069 D3b): while a tab's toggle is ON, EVERY message it +// sends carries metadata.incognito — the backend flag is per-message, so a single +// missed send would leak that turn into memory. Asserted at the WIRE level by +// capturing the /a2a request bodies. + +test.beforeEach(async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await expect(page.getByPlaceholder(/Message protoAgent/i)).toBeVisible(); +}); + +function captureA2ABodies(page: import("@playwright/test").Page): { metadata?: Record }[] { + const messages: { metadata?: Record }[] = []; + page.on("request", (req) => { + if (!req.url().endsWith("/a2a") || req.method() !== "POST") return; + try { + const body = JSON.parse(req.postData() || "{}"); + if (body?.method === "SendStreamingMessage") messages.push(body.params?.message ?? {}); + } catch { + // non-JSON /a2a traffic — not a chat turn + } + }); + return messages; +} + +test("/incognito toggles the tab: chip shows, every send carries metadata.incognito, off stops stamping", async ({ page }) => { + const sent = captureA2ABodies(page); + const composer = page.getByPlaceholder(/Message protoAgent/i); + + // Turn incognito ON via the slash command (bare /incognito toggles). The system + // note confirms the local action took effect. + await composer.fill("/incognito"); + await composer.press("Enter"); // picks the highlighted client command → runs it + await expect(page.locator(".chat-note", { hasText: "Incognito ON" })).toBeVisible(); + // The composer chip is the persistent visual indicator while ON. + await expect(page.locator(".composer-incognito-toggle")).toBeVisible(); + + // BOTH messages sent while ON carry the flag (per-message contract). + await composer.fill("first private message"); + await composer.press("Enter"); + await expect(page.locator(".pl-message--user", { hasText: "first private message" })).toBeVisible(); + await expect(page.locator(".pl-message--assistant").first()).toBeVisible(); + await composer.fill("second private message"); + await composer.press("Enter"); + await expect(page.locator(".pl-message--user", { hasText: "second private message" })).toBeVisible(); + await expect.poll(() => sent.length).toBe(2); + expect(sent[0].metadata?.incognito).toBe(true); + expect(sent[1].metadata?.incognito).toBe(true); + + // Clicking the chip turns incognito OFF; the next send carries NO incognito flag. + await page.locator(".composer-incognito-toggle").click(); + await expect(page.locator(".composer-incognito-toggle")).toHaveCount(0); + await composer.fill("back to normal"); + await composer.press("Enter"); + await expect(page.locator(".pl-message--user", { hasText: "back to normal" })).toBeVisible(); + await expect.poll(() => sent.length).toBe(3); + expect(sent[2].metadata?.incognito).toBeUndefined(); +}); + +test("the chat-tab context menu offers New incognito chat + a per-tab toggle", async ({ page }) => { + const tabs = page.locator(".pl-tabbar__tab"); + const menu = page.locator(".pl-menu"); + + // Right-click the current tab → toggle incognito on from the menu. + await tabs.first().click({ button: "right" }); + await expect(menu).toBeVisible(); + await menu.getByText("Turn incognito on", { exact: true }).click(); + await expect(page.locator(".composer-incognito-toggle")).toBeVisible(); + // The tab itself shows the incognito glyph while ON. + await expect(page.locator(".session-incognito-icon")).toBeVisible(); + + // Re-open the menu: the entry now offers to turn it off. + await tabs.first().click({ button: "right" }); + await menu.getByText("Turn incognito off", { exact: true }).click(); + await expect(page.locator(".composer-incognito-toggle")).toHaveCount(0); + + // "New incognito chat" opens a NEW tab already in incognito. + await tabs.first().click({ button: "right" }); + await menu.getByText("New incognito chat", { exact: true }).click(); + await expect(tabs).toHaveCount(2); + await expect(page.locator(".composer-incognito-toggle")).toBeVisible(); + await expect(page.locator(".session-incognito-icon")).toBeVisible(); +}); diff --git a/apps/web/e2e/knowledge.spec.ts b/apps/web/e2e/knowledge.spec.ts index 36732d5f..6861b46b 100644 --- a/apps/web/e2e/knowledge.spec.ts +++ b/apps/web/e2e/knowledge.spec.ts @@ -51,3 +51,51 @@ test("layered knowledge shows tier badges and shares / unshares the commons", as await dialog.getByRole("button", { name: "Unshare", exact: true }).click(); await expect(surface.getByLabel("unshare entry 11", { exact: true })).toHaveCount(0); }); + +test("Shift+click deletes a chunk immediately, plain click still confirms (#1582)", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByRole("button", { name: "Knowledge" }).click(); + const surface = page.getByTestId("knowledge-store"); + await expect(surface).toBeVisible(); + + const del12 = surface.getByLabel("delete entry 12", { exact: true }); + await expect(del12).toBeVisible(); + + // Plain click → the confirmation dialog (safe path preserved). Back out; chunk stays. + await del12.click(); + const confirm = page.getByRole("dialog", { name: "Delete this knowledge entry?" }); + await expect(confirm).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(confirm).toHaveCount(0); + await expect(del12).toBeVisible(); + + // Shift+click → no dialog, chunk removed immediately (chat-tab quick-delete parity). + await del12.click({ modifiers: ["Shift"] }); + await expect(page.getByRole("dialog", { name: "Delete this knowledge entry?" })).toHaveCount(0); + await expect(surface.getByLabel("delete entry 12", { exact: true })).toHaveCount(0); +}); + +test("groups a multi-chunk source into a collapsible section (#1575)", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByRole("button", { name: "Knowledge" }).click(); + const surface = page.getByTestId("knowledge-store"); + await expect(surface).toBeVisible(); + + // The 3-chunk YouTube source collapses under one header (title + count), closed by default. + const header = surface.getByRole("button", { name: /Hiking with Kevin/ }); + await expect(header).toBeVisible(); + await expect(header).toContainText("3 chunks"); + await expect(header).toHaveAttribute("aria-expanded", "false"); + await expect(surface.getByText("Switchbacks keep the grade walkable.")).toHaveCount(0); // hidden while collapsed + + // Loose chunks (single/no source) still render flat — no regression. + await expect(surface.getByText("Releases are cut manually via workflow_dispatch.")).toBeVisible(); + + // Clicking the header expands it → the chunks render; clicking again collapses. + await header.click(); + await expect(header).toHaveAttribute("aria-expanded", "true"); + await expect(surface.getByText("Switchbacks keep the grade walkable.")).toBeVisible(); + await expect(surface.getByText("The summit view pays off the climb.")).toBeVisible(); + await header.click(); + await expect(surface.getByText("Switchbacks keep the grade walkable.")).toHaveCount(0); +}); diff --git a/apps/web/e2e/memory-inspector.spec.ts b/apps/web/e2e/memory-inspector.spec.ts new file mode 100644 index 00000000..82a93389 --- /dev/null +++ b/apps/web/e2e/memory-inspector.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from "@playwright/test"; + +// Memory inspector (ADR 0069 D7): the rail surface auditing the memory DELIVERY +// layer — session-summary digest rows, hot memory, and the per-turn injection +// record — with delete flows that confirm + toast. + +// The delete tests MUTATE the shared mock (a session row / hot chunk is removed), +// so run serially and reset the memory fixtures before each test — the same +// hermeticity guard the knowledge spec uses. +test.describe.configure({ mode: "serial" }); +test.beforeEach(async ({ page }) => { + await page.request.post("/api/__test__/memory/reset"); + await page.goto("/app/", { waitUntil: "load" }); + await page.getByRole("button", { name: "Memory" }).click(); + await expect(page.getByTestId("memory-surface")).toBeVisible(); +}); + +test("Sessions panel lists digest rows and opens the full summary in the reader", async ({ page }) => { + const surface = page.getByTestId("memory-surface"); + + // Digest-derived rows: id, surface badge, topic, message count. + await expect(surface.getByText("chat-1750000000000-abc123")).toBeVisible(); + await expect(surface.getByText("plan the memory hardening rollout")).toBeVisible(); + await expect(surface.getByText("background", { exact: true })).toBeVisible(); + await expect(surface.getByText(/12 msgs/)).toBeVisible(); + + // Row click → the document viewer shows the FULL rendered summary (verbatim pre). + await surface.getByText("plan the memory hardening rollout").click(); + const pre = page.locator(".memory-session-pre"); + await expect(pre).toBeVisible(); + await expect(pre).toContainText('plan the memory hardening rollout"); +}); + +test("deleting a session summary confirms, toasts, and drops the row", async ({ page }) => { + const surface = page.getByTestId("memory-surface"); + + await surface.getByLabel("delete session sched-hourly-report").click(); + const dialog = page.getByRole("dialog", { name: "Delete this session summary?" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Delete summary" }).click(); + + // Transient result feedback rides a DS toast (console convention). + await expect(page.locator(".pl-toast", { hasText: "Session summary sched-hourly-report deleted." })).toBeVisible(); + await expect(surface.getByText("sched-hourly-report")).toHaveCount(0); +}); + +test("Hot memory panel lists always-on chunks; delete confirms and toasts", async ({ page }) => { + const surface = page.getByTestId("memory-surface"); + await surface.getByRole("tab", { name: "Hot memory" }).click(); + + await expect(surface.getByText("The operator works in US/Pacific.")).toBeVisible(); + await expect(surface.getByText("Weekly report goes out Fridays at 9am.")).toBeVisible(); + // Provenance badge (ADR 0069 D5): the source session that wrote the row. + await expect(surface.getByText("chat-1750000000000-abc123")).toBeVisible(); + + await surface.getByLabel("delete hot entry 32").click(); + const dialog = page.getByRole("dialog", { name: "Delete this hot-memory entry?" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Delete entry" }).click(); + + await expect(page.locator(".pl-toast", { hasText: "Hot-memory entry deleted." })).toBeVisible(); + await expect(surface.getByText("Weekly report goes out Fridays at 9am.")).toHaveCount(0); +}); + +test("editing a hot-memory entry saves via PUT, toasts, and shows the revision", async ({ page }) => { + const surface = page.getByTestId("memory-surface"); + await surface.getByRole("tab", { name: "Hot memory" }).click(); + + await surface.getByLabel("edit hot entry 31").click(); + const field = surface.getByLabel("hot entry 31 content"); + await expect(field).toHaveValue("The operator works in US/Pacific."); + await field.fill("The operator works in US/Eastern."); + await surface.getByRole("button", { name: "Save", exact: true }).click(); + + await expect(page.locator(".pl-toast", { hasText: "Hot-memory entry updated." })).toBeVisible(); + // The list refetches and shows the new revision (the backend re-adds + deletes, + // pinning domain="hot" — the row content is what must survive). + await expect(surface.getByText("The operator works in US/Eastern.")).toBeVisible(); + await expect(surface.getByText("The operator works in US/Pacific.")).toHaveCount(0); +}); + +test("Injections panel shows the per-turn record and filters by session id", async ({ page }) => { + const surface = page.getByTestId("memory-surface"); + await surface.getByRole("tab", { name: "Injections" }).click(); + + // Both fixture rows, with their injected ids visible. + const table = surface.locator(".memory-injections"); + await expect(table.locator("tbody tr")).toHaveCount(2); + await expect(table.getByText("31, 32")).toBeVisible(); // hot chunk ids + await expect(table.getByText("sched-hourly-report").first()).toBeVisible(); + + // Filtering to one session narrows the table. + await surface.getByLabel("filter injections by session id").fill("sched-hourly-report"); + await expect(table.locator("tbody tr")).toHaveCount(1); + await expect(table.getByText("chat-1750000000000-abc123")).toHaveCount(0); +}); + +test("a session row's injections jump pre-filters the Injections panel", async ({ page }) => { + const surface = page.getByTestId("memory-surface"); + await surface.getByLabel("injections for chat-1750000000000-abc123").click(); + + // Landed on the Injections tab, filter applied, only that session's rows shown. + await expect(surface.getByLabel("filter injections by session id")).toHaveValue( + "chat-1750000000000-abc123", + ); + await expect(surface.locator(".memory-injections tbody tr")).toHaveCount(1); +}); diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index 1ca712af..7740bfa2 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -31,10 +31,15 @@ import { SLASH_COMMANDS, PLAYBOOKS, KNOWLEDGE_CHUNKS, + MEMORY_HOT, + MEMORY_INJECTIONS, + MEMORY_SESSIONS, + MEMORY_SESSION_RENDERED, SUBAGENTS, TELEMETRY_INSIGHTS, TELEMETRY_SUMMARY, TELEMETRY_TURNS, + WATCHES, WORKFLOW_RUN_RESULT, WORKFLOWS, } from "./fixtures.mjs"; @@ -95,6 +100,15 @@ let playbooks = clonePlaybooks(); const cloneKnowledge = () => JSON.parse(JSON.stringify(KNOWLEDGE_CHUNKS)); let knowledgeChunks = cloneKnowledge(); +// Memory inspector (ADR 0069 D7) — sessions + hot chunks are MUTATED by the delete +// specs, so serve working copies each memory test resets via +// POST /api/__test__/memory/reset. +const cloneMemory = () => ({ + sessions: JSON.parse(JSON.stringify(MEMORY_SESSIONS)), + hot: JSON.parse(JSON.stringify(MEMORY_HOT)), +}); +let memory = cloneMemory(); + // Fleet state is the one slice of the mock backend the specs MUTATE (create / // stop / rename / add-remote). Isolate it PER SPEC so parallel files and serial- // group retries can't observe each other's writes: every `x-e2e-fleet` request @@ -121,13 +135,18 @@ function handleApiGet(pathname, fleet = FLEET) { case "/api/subagents": return { subagents: SUBAGENTS }; case "/api/tools": + // run_command ships toggled OFF (still listed — disabled tools stay in the + // catalog); `disabled` is the RAW denylist and carries a stale name with no + // live tool (ghost_tool) so specs can assert a row toggle preserves it. return { tools: [ - { name: "web_search", description: "Search the web.", source: "core", category: "General" }, - { name: "memory_recall", description: "Search long-term memory.", source: "core", category: "Memory" }, - { name: "echo__ping", description: "Echo ping.", source: "mcp", category: "echo" }, + { name: "web_search", description: "Search the web.", source: "core", category: "General", enabled: true }, + { name: "memory_recall", description: "Search long-term memory.", source: "core", category: "Memory", enabled: true }, + { name: "run_command", description: "Run a shell command.", source: "core", category: "Filesystem", enabled: false }, + { name: "echo__ping", description: "Echo ping.", source: "mcp", category: "echo", enabled: true }, ], count: 3, + disabled: ["run_command", "ghost_tool"], }; case "/api/chat/commands": return { commands: SLASH_COMMANDS }; @@ -135,6 +154,8 @@ function handleApiGet(pathname, fleet = FLEET) { return SCHEDULER_JOBS; case "/api/goals": return GOALS; + case "/api/watches": + return WATCHES; case "/api/notes/workspace": return { workspace: NOTES_WORKSPACE }; case "/api/tasks/status": @@ -256,6 +277,18 @@ function handleApiGet(pathname, fleet = FLEET) { commons: knowledgeChunks.filter((c) => c.tier === "commons").length, }, }; + case "/api/flags": + // Developer flags (ADR 0068). channel "dev" so the Developer panel is visible in e2e. + return { + channel: "dev", + flags: [ + { id: "chat.new_dashboard", description: "Preview of the redesigned dashboard.", tier: "beta", owner: "kj", remove_by: "v1.0", enabled: true, source: "channel" }, + { id: "chat.experimental_widget", description: "An in-progress widget.", tier: "dev", owner: "kj", remove_by: "", enabled: true, source: "channel" }, + // The REAL chat.compact flag (runtime/flags.py) — enabled so commands.spec sees + // /compact in the slash menu; the flag-off path is covered via ?flag:chat.compact=off. + { id: "chat.compact", description: "/compact — summarize + archive a chat thread.", tier: "dev", owner: "kj", remove_by: "2026-09-01", enabled: true, source: "channel" }, + ], + }; default: return null; } @@ -401,6 +434,26 @@ const server = createServer(async (req, res) => { if (req.method === "GET") { // Mid-turn steering: turn-end reconcile reads the still-queued items. if (/^\/api\/chat\/sessions\/[^/]+\/steer$/.test(pathname)) return sendJson(res, { pending: [] }); + // Memory inspector (ADR 0069 D7) — needs the query string (injections filter), + // so it's handled here rather than in the pathname-only handleApiGet switch. + if (pathname === "/api/memory/sessions") return sendJson(res, { sessions: memory.sessions }); + { + const m = pathname.match(/^\/api\/memory\/sessions\/([^/]+)$/); + if (m) { + const sid = decodeURIComponent(m[1]); + const s = memory.sessions.find((x) => x.session_id === sid); + if (!s) return sendJson(res, { detail: "no session summary with that id" }, 404); + return sendJson(res, { session: { ...s, trace_id: null, rendered: MEMORY_SESSION_RENDERED } }); + } + } + if (pathname === "/api/memory/hot") return sendJson(res, { enabled: true, chunks: memory.hot }); + if (pathname === "/api/memory/injections") { + const sid = url.searchParams.get("session_id") || ""; + const rows = sid + ? MEMORY_INJECTIONS.filter((r) => r.session_id === sid) + : MEMORY_INJECTIONS; + return sendJson(res, { injections: rows }); + } const payload = handleApiGet(pathname, fleetFor(req)); if (payload !== null) return sendJson(res, payload); return sendJson(res, { detail: "not mocked" }, 404); @@ -464,6 +517,38 @@ const server = createServer(async (req, res) => { knowledgeChunks = cloneKnowledge(); return sendJson(res, { ok: true }); } + if (pathname === "/api/__test__/memory/reset" && req.method === "POST") { + memory = cloneMemory(); + return sendJson(res, { ok: true }); + } + // Memory inspector writes (ADR 0069 D7): delete a session summary / edit + delete + // a hot chunk — mutate the working copy so the panels visibly update. + { + const m = pathname.match(/^\/api\/memory\/sessions\/([^/]+)$/); + if (m && req.method === "DELETE") { + const sid = decodeURIComponent(m[1]); + const i = memory.sessions.findIndex((x) => x.session_id === sid); + if (i < 0) return sendJson(res, { detail: "no session summary with that id" }, 404); + memory.sessions.splice(i, 1); + return sendJson(res, { deleted: true, session_id: sid }); + } + const h = pathname.match(/^\/api\/memory\/hot\/(\d+)$/); + if (h && req.method === "DELETE") { + const id = Number(h[1]); + const i = memory.hot.findIndex((x) => x.id === id); + if (i < 0) return sendJson(res, { detail: "no hot-memory chunk with that id" }, 404); + memory.hot.splice(i, 1); + return sendJson(res, { enabled: true, deleted: true }); + } + if (h && req.method === "PUT") { + const id = Number(h[1]); + const c = memory.hot.find((x) => x.id === id); + if (!c) return sendJson(res, { detail: "no hot-memory chunk with that id" }, 404); + c.content = String(body.content || ""); + c.preview = c.content; + return sendJson(res, { enabled: true, id: id + 100, replaced: true }); + } + } if (req.method === "POST" && /^\/api\/knowledge\/\d+\/promote$/.test(pathname)) { const id = Number(pathname.split("/").at(-2)); const c = knowledgeChunks.find((x) => x.id === id); @@ -477,6 +562,14 @@ const server = createServer(async (req, res) => { knowledgeChunks.splice(i, 1); // removed from the commons return sendJson(res, { enabled: true, forgotten: true }); } + if (req.method === "DELETE" && /^\/api\/knowledge\/chunks\/\d+$/.test(pathname)) { + // Actually drop the chunk so the list re-render reflects the delete (the + // quick-delete spec asserts it disappears). Reset via /api/__test__/knowledge/reset. + const id = Number(pathname.split("/").at(-1)); + const i = knowledgeChunks.findIndex((x) => x.id === id); + if (i >= 0) knowledgeChunks.splice(i, 1); + return sendJson(res, { enabled: true, deleted: i >= 0 }); + } if (pathname === "/api/settings") { // ADR 0047: a layer-aware save — "agent" (per-agent leaf, default) or "host" // (box-shared host-config.yaml). The mock just echoes which layer it wrote. diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts index 35fa523b..df2a8930 100644 --- a/apps/web/e2e/navigation.spec.ts +++ b/apps/web/e2e/navigation.spec.ts @@ -25,36 +25,35 @@ test("Studio lands directly on Workflows (Run tab removed — run is a chat gest }); // The Work hub (2026-06) consolidates the former Tasks / Goals / Schedule rail surfaces -// into one right-rail "Work" surface with Overview · Goals · Tasks · Schedule tabs. -// Work is the default-active right panel, so it's already open — clicking its rail icon -// when active would TOGGLE it closed, so only click when it's not the active surface. -// In the narrow right panel the DS responsive Tabs collapse the role="tab" strip into a -// native ; pick the tab through it. -const TAB_VALUE: Record = { Overview: "overview", Goals: "goals", Tasks: "tasks", Schedule: "schedule" }; -async function openWorkTab(page, tab: string) { +// into one right-rail "Work" surface. Card-first navigation (2026-07, no tabs): the +// landing is the Overview's four cards (Goals · Watches · Tasks · Schedule); clicking a +// card opens the panel. Work is the default-active right panel, so it's already open — +// clicking its rail icon when active would TOGGLE it closed, so only click when it's not +// the active surface. +async function openWorkView(page, card: string) { const workBtn = page.locator(".pl-rail--right").getByRole("button", { name: "Work", exact: true }); const cls = (await workBtn.getAttribute("class")) ?? ""; if (!cls.includes("--active")) await workBtn.click(); - await page.locator(".pl-tabs__select").first().selectOption(TAB_VALUE[tab]); + await page.getByTestId(`work-card-${card}`).click(); } -test("Work → Schedule tab lists scheduled jobs", async ({ page }) => { - await openWorkTab(page, "Schedule"); +test("Work → Schedule card opens the panel listing scheduled jobs", async ({ page }) => { + await openWorkView(page, "schedule"); await expect(page.getByRole("heading", { name: "Schedule" })).toBeVisible(); await expect(page.getByText("Summarize overnight activity")).toBeVisible(); }); -test("Work → Goals tab lists active goals", async ({ page }) => { - // Goals folded into the Work hub's Goals tab (still renders the GoalsPanel verbatim). - await openWorkTab(page, "Goals"); +test("Work → Goals card opens the panel listing active goals", async ({ page }) => { + // Goals folded into the Work hub (still renders the GoalsPanel verbatim). + await openWorkView(page, "goals"); await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible(); await expect(page.getByText("All tests pass")).toBeVisible(); }); -test("Work → Tasks tab lists tasks issues (query-backed)", async ({ page }) => { - // The tab is labeled "Tasks" but the panel content is still Tasks (TanStack Query / +test("Work → Tasks card opens the panel listing tasks issues (query-backed)", async ({ page }) => { + // The card is labeled "Tasks" and the panel is the Tasks board (TanStack Query / // Suspense, ADR 0013) — folded into the Work hub. - await openWorkTab(page, "Tasks"); + await openWorkView(page, "tasks"); await expect(page.getByRole("heading", { name: "Tasks" })).toBeVisible(); await expect(page.getByText("Wire the telemetry rollup")).toBeVisible(); }); @@ -181,8 +180,8 @@ test("right-click → Move to bottom dock docks the surface at the bottom", asyn // Work now lives in the (icon-only) bottom rail, off the right rail, and its panel opens. await expect(bottomRail.getByRole("button", { name: "Work", exact: true })).toBeVisible(); await expect(rightRail.getByRole("button", { name: "Work", exact: true })).toHaveCount(0); - // Its panel renders — the Work hub's Tabs strip is present in the bottom dock. - await expect(page.locator(".pl-appshell__bottom .pl-tabs").getByRole("tab", { name: "Overview", exact: true })).toBeVisible(); + // Its panel renders — the Work hub's overview card grid is present in the bottom dock. + await expect(page.locator(".pl-appshell__bottom").getByTestId("work-card-goals")).toBeVisible(); }); test("right-click a core surface → Hide removes it; Chat offers no Hide (ADR 0035/0036)", async ({ page }) => { diff --git a/apps/web/e2e/nesting.spec.ts b/apps/web/e2e/nesting.spec.ts index e04cd46b..41585795 100644 --- a/apps/web/e2e/nesting.spec.ts +++ b/apps/web/e2e/nesting.spec.ts @@ -19,6 +19,12 @@ test("subagent child tools collapse inside the task card with a count", async ({ // The child is NOT rendered until you expand — no always-on rail (that's the bounce fix). await expect(page.locator(".pl-toolcard__children")).toHaveCount(0); + // Settle the turn BEFORE expanding: on settle the live-parts view regroups into the + // history card (#1272-76), which REMOUNTS the toolcard — an expansion clicked + // mid-stream is silently dropped (this was a load-sensitive flake under a parallel + // suite, where the click landed inside the live window). + await expect(page.getByText("Delegated research to a subagent and summarized.")).toBeVisible(); + // Expand → the subagent's web_search appears nested in the body. await card.locator(".pl-toolcard__head").click(); await expect(card.locator(".pl-toolcard__children .pl-toolcard__name")).toHaveText("web_search"); diff --git a/apps/web/e2e/quick-settings.spec.ts b/apps/web/e2e/quick-settings.spec.ts index e6ef4308..a2cd79de 100644 --- a/apps/web/e2e/quick-settings.spec.ts +++ b/apps/web/e2e/quick-settings.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; // Contextual quick-settings + the topbar Settings overlay (ADR 0048): a gear icon // opens a dialog editing fields via the same /api/settings path, and the central @@ -60,3 +61,90 @@ test("the chat composer model picker overrides the model per-tab (no global save await page.waitForTimeout(300); expect(settingsWrite).toBe(false); }); + +test("Tools panel: the Shell & filesystem chip disables run_command via /api/settings", async ({ page }) => { + // The per-agent run_command kill switch lives on the Tools capability panel as a + // QuickSetting chip (ADR 0048 §2.2) — same /api/settings write path as the central home. + await page.goto("/app/", { waitUntil: "load" }); + await page.getByTestId("header-menu").click(); + await page.getByTestId("app-drawer").getByRole("button", { name: "Settings", exact: true }).click(); + await page + .locator(".settings-overlay .pl-sidenav") + .getByRole("tab", { name: "Tools", exact: true }) + .click(); + + await page.getByRole("button", { name: "Shell & filesystem tools" }).click(); + const dialog = page.getByRole("dialog", { name: "Shell & filesystem tools" }); + await expect(dialog).toBeVisible(); + // All four gates render, allow_run being the full kill switch. + await expect(dialog.getByText("Allow run_command")).toBeVisible(); + await expect(dialog.getByText("Require approval per command")).toBeVisible(); + + const saved = page.waitForRequest( + (r) => r.url().endsWith("/api/settings") && ["POST", "PUT"].includes(r.method()), + ); + await dialog.locator('[data-key="filesystem.allow_run"] .pl-switch').click(); + await dialog.getByRole("button", { name: "Save" }).click(); + const req = await saved; + const body = req.postDataJSON(); + expect(body.updates["filesystem.allow_run"]).toBe(false); + expect(body.layer ?? "agent").toBe("agent"); // per-agent leaf, not box-wide + await expect(page.locator(".pl-toast").getByText("Saved")).toBeVisible(); +}); + +// The old "Disabled tools" chip (a raw tools.disabled textarea) is gone — every row +// in the list carries an on/off switch writing the same denylist. These specs pin the +// row-toggle contract: the POST edits the RAW denylist (preserving entries it didn't +// touch, incl. stale names with no live tool) and off rows stay listed. +async function openToolsTab(page: Page) { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByTestId("header-menu").click(); + await page.getByTestId("app-drawer").getByRole("button", { name: "Settings", exact: true }).click(); + await page + .locator(".settings-overlay .pl-sidenav") + .getByRole("tab", { name: "Tools", exact: true }) + .click(); +} + +test("Tools panel: the Disabled tools chip is gone (row switches replaced it)", async ({ page }) => { + await openToolsTab(page); + await expect(page.getByRole("button", { name: "Shell & filesystem tools" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Disabled tools" })).toHaveCount(0); +}); + +test("Tools panel: toggling a tool row off appends it to tools.disabled", async ({ page }) => { + await openToolsTab(page); + + // web_search sits in General — the first group, expanded by default. + const row = page.locator(".tools-row", { has: page.getByText("web_search", { exact: true }) }); + await expect(row).toBeVisible(); + + const saved = page.waitForRequest( + (r) => r.url().endsWith("/api/settings") && ["POST", "PUT"].includes(r.method()), + ); + await row.locator(".pl-switch").click(); + const req = await saved; + const body = req.postDataJSON(); + // Appends the toggled name and PRESERVES the rest of the raw denylist — including + // ghost_tool, a stale entry with no live tool row to recompute it from. + expect(body.updates["tools.disabled"]).toEqual(["run_command", "ghost_tool", "web_search"]); + expect(body.layer ?? "agent").toBe("agent"); // per-agent leaf, not box-wide +}); + +test("Tools panel: an off tool stays listed and toggles back on", async ({ page }) => { + await openToolsTab(page); + + // run_command ships disabled in the fixture — still listed (dimmed) under Filesystem. + await page.locator(".pl-accordion__trigger", { hasText: "Filesystem" }).click(); + const row = page.locator(".tools-row", { has: page.getByText("run_command", { exact: true }) }); + await expect(row).toBeVisible(); + await expect(row).toHaveClass(/tools-row--off/); + + const saved = page.waitForRequest( + (r) => r.url().endsWith("/api/settings") && ["POST", "PUT"].includes(r.method()), + ); + await row.locator(".pl-switch").click(); + const req = await saved; + // Re-enabling removes ONLY run_command; the stale ghost_tool entry survives. + expect(req.postDataJSON().updates["tools.disabled"]).toEqual(["ghost_tool"]); +}); diff --git a/apps/web/e2e/report-card.spec.ts b/apps/web/e2e/report-card.spec.ts new file mode 100644 index 00000000..0d9eb0de --- /dev/null +++ b/apps/web/e2e/report-card.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from "@playwright/test"; + +// Background report card (ADR 0070 D4). A finished background job's report renders as a +// real CARD in the spawning chat — raised surface, clamped excerpt with a bottom fade-out, +// an "Open report" CTA — and the document viewer fetches the FULL report BY ID +// (GET /api/background/{id}), never via the legacy list-and-filter. +// +// Harness: seed an open chat session, replace the mock SSE stream with one that emits a +// `background.completed` for that session (BackgroundWatch injects the display-only report +// message), and serve the by-id route with a full result the LIST route does not carry — +// so the viewer showing the full text proves the by-id fetch. + +const JOB_ID = "bg-abcdefabcdef"; +const SESSION = "chat-bg-e2e"; +const TITLE = "Quarterly numbers deep-dive"; +// Long enough that the excerpt must clamp (scrollHeight > clientHeight). +const PREVIEW = Array.from({ length: 40 }, (_, i) => `Preview line ${i + 1} of the trimmed result.`).join("\n\n"); +const FULL_MARKER = "FULL-REPORT-ONLY-SERVED-BY-ID"; +const FULL = `# ${TITLE}\n\n${FULL_MARKER}\n\nThe untruncated report body.`; +// A SHORT report that fits entirely: the fade mask must NOT apply (an unconditional +// mask would ghost the report's last line into a fake truncation). +const SHORT_JOB_ID = "bg-abcdefabcd99"; +const SHORT_TITLE = "Quick store check"; +const SHORT_PREVIEW = "All 4 stores match the drive.\n\nNothing to fix."; + +test("report card: clamped fading excerpt, Open CTA → docviewer, fetched by id", async ({ page }) => { + // An open chat session whose id matches the job's origin_session — BackgroundWatch + // only injects into sessions that are open in this window. + await page.addInitScript( + ([session]) => { + window.localStorage.setItem( + "protoagent.chat.sessions", + JSON.stringify({ + version: 1, + currentSessionId: session, + sessions: [{ id: session, title: "spawner", createdAt: 1, updatedAt: 2, messages: [] }], + }), + ); + }, + [SESSION], + ); + + // SSE: two background.completed frames for the seeded session — a long report (must + // clamp + fade) and a short one (must NOT fade). The stream then closes; EventSource + // reconnects and replays them — BackgroundWatch dedupes, so each card renders exactly + // once even though delivery is repeated. + const frames = [ + { + topic: "background.completed", + data: { + job_id: JOB_ID, + origin_session: SESSION, + status: "completed", + description: TITLE, + result: PREVIEW, // the truncated preview the bus event carries + }, + }, + { + topic: "background.completed", + data: { + job_id: SHORT_JOB_ID, + origin_session: SESSION, + status: "completed", + description: SHORT_TITLE, + result: SHORT_PREVIEW, + }, + }, + ]; + await page.route("**/api/events**", (route) => + route.fulfill({ + status: 200, + headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }, + body: frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""), + }), + ); + + // The by-id route (ADR 0070) carries the FULL result; record its hits. + const byIdHits: string[] = []; + await page.route(`**/api/background/${JOB_ID}`, (route) => { + byIdHits.push(route.request().url()); + return route.fulfill({ + json: { + id: JOB_ID, + status: "completed", + subagent_type: "researcher", + description: TITLE, + origin_session: SESSION, + result: FULL, + }, + }); + }); + // The LIST route must NOT be the card's source — it answers, but without the report. + await page.route("**/api/background", (route) => route.fulfill({ json: { enabled: true, jobs: [] } })); + + await page.goto("/app/", { waitUntil: "load" }); + + // The long-report card: raised-card wrapper, header row (title + subtitle), CTA. + const card = page.locator(".chat-report-card").filter({ hasText: TITLE }); + await expect(card).toBeVisible({ timeout: 15_000 }); + await expect(card.locator(".chat-report-title")).toHaveText(TITLE); + await expect(card.locator(".chat-report-sub")).toHaveText("Background report"); + + // The excerpt is CLAMPED (content overflows the fade window) and carries the + // fade-out mask. + const excerpt = card.locator(".chat-report-excerpt"); + await expect(excerpt).toBeVisible(); + await expect(excerpt).toContainText("Preview line 1"); + expect(await excerpt.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(true); + await expect(excerpt).toHaveClass(/chat-report-excerpt--clamped/); + expect( + await excerpt.evaluate((el) => { + const s = getComputedStyle(el); + return s.maskImage || s.webkitMaskImage || ""; + }), + ).toContain("linear-gradient"); + + // The SHORT report fits entirely — no clamp, and crucially NO fade mask (an + // unconditional mask would ghost its final line into a fake truncation). + const shortExcerpt = page + .locator(".chat-report-card") + .filter({ hasText: SHORT_TITLE }) + .locator(".chat-report-excerpt"); + await expect(shortExcerpt).toBeVisible(); + await expect(shortExcerpt).toContainText("Nothing to fix."); + expect(await shortExcerpt.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false); + await expect(shortExcerpt).not.toHaveClass(/chat-report-excerpt--clamped/); + expect( + await shortExcerpt.evaluate((el) => { + const s = getComputedStyle(el); + return s.maskImage === "none" ? "" : s.maskImage || s.webkitMaskImage || ""; + }), + ).not.toContain("linear-gradient"); + + // The CTA opens the document viewer with the FULL report — which only the by-id + // route serves, so its presence + the recorded hit prove the fetch path. + await card.getByRole("button", { name: "Open report" }).click(); + const viewer = page.locator(".doc-viewer"); + await expect(viewer).toBeVisible(); + await expect(viewer).toContainText(FULL_MARKER); + expect(byIdHits.length).toBeGreaterThan(0); +}); diff --git a/apps/web/e2e/schedule.spec.ts b/apps/web/e2e/schedule.spec.ts index 49d360d9..d5e1d366 100644 --- a/apps/web/e2e/schedule.spec.ts +++ b/apps/web/e2e/schedule.spec.ts @@ -6,13 +6,12 @@ import { expect, test } from "@playwright/test"; async function gotoSchedule(page) { await page.goto("/app/", { waitUntil: "load" }); - // Schedule folded into the Work hub (2026-06): the right-rail Work surface, Schedule tab. - // Work is the default-active right panel; in the narrow panel the DS responsive Tabs - // collapse the role="tab" strip into a native . + // Schedule lives in the Work hub (right rail). Card-first navigation (2026-07, no + // tabs): the overview's Schedule card clicks through to the panel. const workBtn = page.locator(".pl-rail--right").getByRole("button", { name: "Work", exact: true }); const cls = (await workBtn.getAttribute("class")) ?? ""; if (!cls.includes("--active")) await workBtn.click(); - await page.locator(".pl-tabs__select").first().selectOption("schedule"); + await page.getByTestId("work-card-schedule").click(); } async function openScheduleModal(page) { diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index 33129151..b7aec79f 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -72,6 +72,7 @@ test("the settings dialog lists the domain groups (host, no scope toggle)", asyn "Theme", "Chat", "Keyboard", + "Developer", // ADR 0068 — shown off prod (the mock serves channel "dev") ]); await section(page, "Behavior"); await expect(page.locator(".pl-accordion__title").first()).toBeVisible(); diff --git a/apps/web/e2e/tasks.spec.ts b/apps/web/e2e/tasks.spec.ts index b87ebfa0..147031d5 100644 --- a/apps/web/e2e/tasks.spec.ts +++ b/apps/web/e2e/tasks.spec.ts @@ -8,7 +8,8 @@ async function openTasks(page) { const workBtn = page.locator(".pl-rail--right").getByRole("button", { name: "Work", exact: true }); const cls = (await workBtn.getAttribute("class")) ?? ""; if (!cls.includes("--active")) await workBtn.click(); - await page.locator(".pl-tabs__select").first().selectOption("tasks"); + // Card-first Work hub (2026-07, no tabs): the overview's Tasks card IS the navigation. + await page.getByTestId("work-card-tasks").click(); } test("New task opens a dialog; submit is gated on a title, then creates", async ({ page }) => { diff --git a/apps/web/e2e/work-overview.spec.ts b/apps/web/e2e/work-overview.spec.ts new file mode 100644 index 00000000..194aee26 --- /dev/null +++ b/apps/web/e2e/work-overview.spec.ts @@ -0,0 +1,177 @@ +import { expect, test } from "@playwright/test"; + +// The Work hub's card-first overview (2026-07, no tabs): the landing is four live cards +// (Goals · Watches · Tasks · Schedule) that ARE the navigation — whole-card click-through +// to the nested panel, a "← Overview" back bar, live counts + a pulse line per card, and +// a corner "+" quick-add that opens the same creator dialog the panel uses (adding never +// navigates). Watches is the odd one out: agent-created, so no quick-add. + +async function openWork(page) { + await page.goto("/app/", { waitUntil: "load" }); + // Work is the default-active right panel — clicking its rail icon when active would + // TOGGLE the panel closed, so only click when it's not the active surface. + const workBtn = page.locator(".pl-rail--right").getByRole("button", { name: "Work", exact: true }); + const cls = (await workBtn.getAttribute("class")) ?? ""; + if (!cls.includes("--active")) await workBtn.click(); +} + +test("all four cards render with live counts and pulse lines", async ({ page }) => { + await openWork(page); + + const goals = page.getByTestId("work-card-goals"); + await expect(goals.locator(".work-card-head .pl-badge")).toHaveText("1"); + await expect(goals.locator(".work-card-pulse")).toHaveText("1 driving · iteration 1/6"); + await expect(goals.getByText("All tests pass")).toBeVisible(); + + const watches = page.getByTestId("work-card-watches"); + await expect(watches.locator(".work-card-head .pl-badge")).toHaveText("1"); // active only + await expect(watches.locator(".work-card-pulse")).toHaveText("1 watching · 1 met today"); + await expect(watches.getByText("CI is green on main")).toBeVisible(); + // Non-active watches still list, tinted by status. + await expect(watches.locator(".work-row", { hasText: "The staging deploy finishes" }).locator(".pl-badge")).toHaveText("met"); + + const tasks = page.getByTestId("work-card-tasks"); + await expect(tasks.locator(".work-card-head .pl-badge")).toHaveText("1"); + await expect(tasks.locator(".work-card-pulse")).toHaveText("0 ready · 1 in progress"); + await expect(tasks.getByText("Wire the telemetry rollup")).toBeVisible(); + + const schedule = page.getByTestId("work-card-schedule"); + await expect(schedule.locator(".work-card-head .pl-badge")).toHaveText("1"); + await expect(schedule.locator(".work-card-pulse")).toContainText("next"); + await expect(schedule.getByText("Summarize overnight activity")).toBeVisible(); +}); + +test("a card clicks through to its panel; ← Overview (and Escape) return", async ({ page }) => { + await openWork(page); + await page.getByTestId("work-card-goals").click(); + + // Nested view: the full Goals panel under the slim back bar. + await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible(); + await expect(page.getByTestId("work-back")).toBeVisible(); + + // Back → the overview grid again. + await page.getByTestId("work-back").click(); + await expect(page.getByTestId("work-card-goals")).toBeVisible(); + await expect(page.getByTestId("work-back")).toHaveCount(0); + + // Escape (focus inside the Work surface, no dialog open) also backs out. + await page.getByTestId("work-card-watches").click(); + await expect(page.getByRole("heading", { name: "Watches" })).toBeVisible(); + await page.getByTestId("work-back").focus(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("work-card-watches")).toBeVisible(); +}); + +test("Tasks quick-add opens the create dialog without navigating; creating updates the card", async ({ page }) => { + // Stateful tasks feed for THIS page only (the shared mock server's writes are + // generic-ok/stateless): after the POST, the list serves one more issue so the + // invalidate → refetch visibly updates the card. + let created = false; + await page.route("**/api/tasks/issues", async (route) => { + if (route.request().method() === "POST") { + created = true; + return route.fulfill({ json: { issue: { id: "task-9", title: "Overview quick-add", status: "open" } } }); + } + const issues = [ + { id: "bd-1", title: "Wire the telemetry rollup", status: "in_progress", priority: 1, issue_type: "task", created_at: "2026-06-02T09:00:00Z" }, + ...(created ? [{ id: "task-9", title: "Overview quick-add", status: "open", priority: 2, issue_type: "task", created_at: "2026-07-01T09:00:00Z" }] : []), + ]; + return route.fulfill({ json: { issues } }); + }); + + await openWork(page); + const tasks = page.getByTestId("work-card-tasks"); + await expect(tasks.locator(".work-card-head .pl-badge")).toHaveText("1"); + + // The "+" is a quick-add, not a navigation: the dialog opens over the overview. + await page.getByTestId("work-add-task").click(); + await expect(page.getByTestId("task-create-dialog")).toBeVisible(); + await expect(page.getByTestId("work-back")).toHaveCount(0); // still on the overview + + await page.getByTestId("task-create-title").fill("Overview quick-add"); + await page.getByTestId("task-create-submit").click(); + await expect(page.getByTestId("task-create-dialog")).toHaveCount(0); + + // The card updates live: count 1 → 2, and the new row appears. + await expect(tasks.locator(".work-card-head .pl-badge")).toHaveText("2"); + await expect(tasks.getByText("Overview quick-add")).toBeVisible(); +}); + +test("the Watches card has no quick-add (watches are agent-created)", async ({ page }) => { + await openWork(page); + const watches = page.getByTestId("work-card-watches"); + await expect(watches).toBeVisible(); + await expect(watches.locator(".work-card-foot")).toHaveCount(0); + // The other cards do offer one. + await expect(page.getByTestId("work-add-goal")).toBeVisible(); + await expect(page.getByTestId("work-add-task")).toBeVisible(); + await expect(page.getByTestId("work-add-schedule")).toBeVisible(); +}); + +test("an empty card shows the DS Empty with the quick-add as its CTA", async ({ page }) => { + // No active goals for THIS page → the Goals card renders its empty state. + await page.route("**/api/goals", (route) => + route.fulfill({ json: { enabled: true, goals: [] } }), + ); + await openWork(page); + + const goals = page.getByTestId("work-card-goals"); + await expect(goals.locator(".work-card-head .pl-badge")).toHaveText("0"); + await expect(goals.getByText("No active goals")).toBeVisible(); + + // The Empty's action IS the quick-add — it opens the goal dialog, no navigation. + await goals.locator(".pl-empty__action").getByTestId("work-add-goal").click(); + await expect(page.getByTestId("goal-create-dialog")).toBeVisible(); + await expect(page.getByTestId("work-back")).toHaveCount(0); + + // Gated on a condition, like the panel host. + await expect(page.getByTestId("goal-create-submit")).toBeDisabled(); + await page.getByTestId("goal-create-condition").fill("Ship the overview"); + await expect(page.getByTestId("goal-create-submit")).toBeEnabled(); +}); + +test("the Goals PANEL hosts the same create dialog via its New goal header action", async ({ page }) => { + // The other host of the lifted GoalCreateDialog (one form, two hosts): the Goals + // panel's header action — the replacement for the old inline
form. Tasks/ + // Schedule panel create flows have their own specs; this covers the Goals panel's. + let posted: Record | null = null; + await page.route("**/api/goals", async (route) => { + if (route.request().method() === "POST") { + posted = route.request().postDataJSON(); + return route.fulfill({ json: { ok: true, message: "goal set" } }); + } + return route.fallback(); + }); + + await openWork(page); + await page.getByTestId("work-card-goals").click(); + await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible(); + + await page.getByTestId("goal-new").click(); + await expect(page.getByTestId("goal-create-dialog")).toBeVisible(); + await expect(page.getByTestId("goal-create-submit")).toBeDisabled(); // gated on a condition + await page.getByTestId("goal-create-condition").fill("All tests pass twice"); + await page.getByTestId("goal-create-submit").click(); + + // Same payload/endpoint as the old inline form; success closes the dialog + toasts. + await expect(page.getByTestId("goal-create-dialog")).toHaveCount(0); + await expect(page.locator(".pl-toast__title", { hasText: "Goal set" })).toBeVisible(); + expect(posted).toMatchObject({ + session_id: "operator", + condition: "All tests pass twice", + verifier: { type: "llm" }, + }); + // Still in the nested Goals view — creating never navigates. + await expect(page.getByTestId("work-back")).toBeVisible(); +}); + +test("watches empty state explains agent-created watches and offers no CTA", async ({ page }) => { + await page.route("**/api/watches", (route) => + route.fulfill({ json: { enabled: true, watches: [] } }), + ); + await openWork(page); + + const watches = page.getByTestId("work-card-watches"); + await expect(watches.getByText(/The agent sets watches/)).toBeVisible(); + await expect(watches.locator(".pl-empty__action")).toHaveCount(0); +}); diff --git a/apps/web/package.json b/apps/web/package.json index b53c3131..7ecbe0fd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@protolabsai/design": "^0.5.1", - "@protolabsai/ui": "^0.52.0", + "@protolabsai/ui": "^0.53.1", "@radix-ui/react-dropdown-menu": "^2.1.17", "@tanstack/react-query": "^5.100.14", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 82a999c7..1af8a77c 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -65,6 +65,7 @@ import { BackgroundWatch } from "./BackgroundWatch"; import { ChatResumeWatch } from "./ChatResumeWatch"; import { BackgroundJobs } from "./BackgroundJobs"; import { ProtoLabsIcon } from "./ProtoLabsIcon"; +import { bootGatePhase } from "./bootGate"; import { AuthGate } from "./AuthGate"; import { authRequired, subscribeAuth } from "../lib/auth"; import { TenantGuard } from "./TenantGuard"; @@ -77,8 +78,10 @@ import { InboxWidget } from "../inbox/InboxWidget"; import { ChatSlot } from "./ChatSlot"; import { chatStore, useAnyChatStreaming } from "../chat/chat-store"; import { KnowledgeStore } from "../knowledge/KnowledgeStore"; +import { MemorySurface } from "../memory/MemorySurface"; import { SettingsOverlay } from "../settings/SettingsOverlay"; import { PluginSettingsDialog } from "../plugins/PluginSettingsDialog"; +import { PluginRailManage } from "../plugins/PluginRailManage"; import { AppDrawer } from "./AppDrawer"; import { HamburgerMenu } from "./HamburgerMenu"; import { FleetSwitcher } from "./FleetSwitcher"; @@ -87,7 +90,17 @@ import { type RightPanel, type Surface, } from "../state/uiStore"; -import { api, apiUrl, authToken, is401 } from "../lib/api"; +import { + api, + apiUrl, + authToken, + is401, + isAgentNotRunning, + isAgentUnreachable, + currentSlug, + agentHref, + activateSlugAgent, +} from "../lib/api"; import { PluginView, consoleTheme } from "./PluginView"; import { UtilityWidget } from "./UtilityWidget"; import { AppShell, Header, UtilityBar } from "@protolabsai/ui/app-shell"; @@ -107,7 +120,7 @@ import { useToast } from "@protolabsai/ui/overlays"; import { StatusPill } from "./StatusPill"; import { WorkPanel } from "./WorkPanel"; import { SetupWizard } from "../setup/SetupWizard"; -import { hostRuntimeStatusQuery, runtimeStatusQuery } from "../lib/queries"; +import { hostRuntimeStatusQuery, installedPluginsQuery, pluginUpdatesQuery, runtimeStatusQuery } from "../lib/queries"; import { buildViews } from "../lib/viewRegistry"; import { applyNavIntent, usePaletteRegistry } from "./usePaletteRegistry"; import type { NavIntent } from "./usePaletteRegistry"; @@ -277,6 +290,14 @@ export function App() { }); const runtime = runtimeQ.data ?? null; + // Installed inventory + freshness — feed the rail context-menu plugin actions + // (#1521 / #1522) so a plugin icon's menu can show its version and offer Update / + // Uninstall. Lightweight + cached (the freshness poll is TTL-cached server-side); + // both degrade gracefully (retry:false), so a missing/erroring API just hides the + // extra menu items rather than blocking the menu. + const installedPluginsQ = useQuery(installedPluginsQuery()); + const pluginUpdatesQ = useQuery(pluginUpdatesQuery()); + // Tenant uid is the HUB's, never the focused agent's (which changes on every fleet // swap and would wrongly wipe the chat view). Host-pinned, stable, low-churn. const hostUidQ = useQuery({ @@ -368,6 +389,27 @@ export function App() { // copy/escape-hatch swap. STUCK_AFTER_MS=45s — past it, offer "Continue anyway" // so a graph that never compiles can't trap the operator on the loading screen. const bootFailed = !runtime && runtimeQ.isError; + // Focused fleet agent is DOWN (ADR 0042): a non-host slug whose boot probe keeps failing + // past a normal spawn window. Two shapes: a LOCAL peer 409s ("agent not running" — `activate` + // didn't bring it up), a REMOTE member 502s (its box is offline / URL wrong — it never 409s, + // it isn't a local process). Without this the operator waited out the full ~30 retries for a + // generic "isn't responding" gate whose "Continue anyway" just opens a broken app. Detect it + // early (≥6 retries ≈ ~6s at the 1s delay) and offer targeted recovery: return to the host + // console, or try starting/reaching it again. `failureReason`/`failureCount` are live during + // retries (TanStack Query v5), so recovery shows before the probe fully gives up. + const focusedSlug = currentSlug(); + const agentDown = + focusedSlug !== "host" && + !runtime && + (isAgentNotRunning(runtimeQ.failureReason) || isAgentUnreachable(runtimeQ.failureReason)) && + runtimeQ.failureCount >= 6; + // Focused REMOTE member's stored token is wrong/missing: its proxied boot probe 401s. That's + // the MEMBER's credential problem, not the hub's — api.ts already keeps it off the global + // AuthGate (which prompts for the HUB token), and the probe's 401 stops the retry loop + // immediately (is401), so we surface a targeted "update its token / return to host" recovery + // instead of the generic "isn't responding" gate. Host-window 401s stay with the AuthGate. + const memberAuthFailed = focusedSlug !== "host" && !runtime && is401(runtimeQ.failureReason); + const agentUnreachable = isAgentUnreachable(runtimeQ.failureReason); // 502 (remote) vs 409 (local peer) // Token-gated first run (#873): the boot probe itself 401s. The BootGate's // "Starting… / isn't responding" copy is wrong for that — and its overlay // (z-1900) would cover the AuthGate dialog (z-1000) — so the gate yields to @@ -381,6 +423,8 @@ export function App() { const t = window.setTimeout(() => setBootStuck(true), 45_000); return () => window.clearTimeout(t); }, [bootReady]); + // Which recovery the boot gate shows (pure precedence — tested in bootGate.test.ts). + const gatePhase = bootGatePhase({ memberAuthFailed, agentDown, unreachable: agentUnreachable, bootFailed, bootStuck }); const [error, setError] = useState(""); // Clear the stale "Load failed" strip once the engine reports ready. The @@ -489,21 +533,10 @@ export function App() { } // Right-click the EMPTY rail background (not an icon) → the "Hidden views" restore menu - // (ADR 0035/0036). The DS AppShell only fires onRailContextMenu on icons, so we catch the - // rail-container right-click here via event delegation (the DS classnames are the contract) - // and hand the resolved hidden-surface labels to the `rail-background` menu. An icon click - // is handled by its own `rail-surface` menu, so bail when the target is a rail button. - const onShellContextMenu = (e: ReactMouseEvent) => { - const t = e.target as HTMLElement; - if (t.closest(".pl-rail__btn")) return; // an icon — its own menu handles it - const railEl = t.closest(".pl-rail") as HTMLElement | null; - if (!railEl) return; // not the rail — leave the default menu - // Which rail was right-clicked → restore a hidden view to THIS dock (not its default). - const side: "left" | "right" | "bottom" = railEl.classList.contains("pl-rail--right") - ? "right" - : railEl.classList.contains("pl-rail--bottom") - ? "bottom" - : "left"; + // (ADR 0035/0036). The DS AppShell's `onRailBackgroundContextMenu` (@protolabsai/ui@0.53.0) + // fires on empty rail space with the resolved side — no more DOM/classname delegation. + // Icon right-clicks go to `onRailContextMenu` (the `rail-surface` menu) instead. + const onRailBackgroundContextMenu = (side: "left" | "right" | "bottom", e: ReactMouseEvent) => { const hidden = (railOrder.hidden ?? []).map((id) => ({ id, label: metaFor(id)?.label ?? id })); openContextMenu("rail-background", e, { side, hidden }); }; @@ -582,6 +615,10 @@ export function App() { // Settings ▸ Workspace ▸ Memory (ADR 0048 S-C). case "knowledge": return ; + // Memory inspector (ADR 0069 D7) — the delivery-layer audit surface: session + // digests, hot memory, per-turn injection record. + case "memory": + return ; // Settings is no longer a rail surface (2026-06 consolidation) — it's a utility-bar // pill opening the settings dialog (SettingsOverlay). Notes is the first-party `notes` // plugin (ADR 0034 S4) — rendered via the default @@ -695,7 +732,7 @@ export function App() { {/* Command palette (⌘K, ADR 0057) — portals over the shell; the same component backs the desktop quick-command (step 4). */} -
+
{/* protoLabs.studio brand bumper — DS Splash (@protolabsai/ui/splash). Holds 2.5s then hands off via the View Transitions API cross-fade (the protoAgent path); shows once per tab session (sessionStorage @@ -742,23 +779,69 @@ export function App() { } title={ - bootFailed - ? `${bootName} isn’t responding` - : `Starting ${bootName}…` + gatePhase === "memberAuth" + ? `Can’t authenticate to “${focusedSlug}”` + : gatePhase === "unreachable" + ? `Agent “${focusedSlug}” is unreachable` + : gatePhase === "notRunning" + ? `Agent “${focusedSlug}” isn’t running` + : gatePhase === "failed" + ? `${bootName} isn’t responding` + : `Starting ${bootName}…` } detail={ - bootFailed - ? "The engine didn’t come up in time. It may still be warming up — give it another moment, then retry." - : bootStuck - ? "This is taking longer than usual. The engine may still be compiling, or it may need attention in Settings." - : "Warming up the engine — first launch (and finishing setup) can take up to a minute. Later launches are quick." + gatePhase === "memberAuth" + ? "This remote member rejected the hub’s stored token (it’s wrong, missing, or was rotated). Return to the host console and update its token in Settings ▸ Agents." + : gatePhase === "unreachable" + ? "This remote member didn’t answer. Return to the host console to keep working, or check its URL and token in Settings ▸ Agents." + : gatePhase === "notRunning" + ? "This fleet agent didn’t start. Return to the host console to keep working, or try starting it again." + : gatePhase === "failed" + ? "The engine didn’t come up in time. It may still be warming up — give it another moment, then retry." + : gatePhase === "stuck" + ? "This is taking longer than usual. The engine may still be compiling, or it may need attention in Settings." + : "Warming up the engine — first launch (and finishing setup) can take up to a minute. Later launches are quick." } action={ - bootFailed ? ( + gatePhase === "memberAuth" ? ( + + ) : gatePhase === "unreachable" || gatePhase === "notRunning" ? ( +
+ + +
+ ) : gatePhase === "failed" ? ( - ) : bootStuck ? ( + ) : gatePhase === "stuck" ? ( @@ -792,6 +875,8 @@ export function App() { setFleetStartNew(true); openGlobalSettings("fleet"); }} + // "Fleet settings" → the fleet management dialog (no new-agent picker). + onManageFleet={() => openGlobalSettings("fleet")} /> } org={runtime?.identity?.org || "protoLabs.studio"} @@ -847,14 +932,31 @@ export function App() { else { setBottomPanel(id); setBottomCollapsed(false); } } }} + onRailBackgroundContextMenu={onRailBackgroundContextMenu} onRailContextMenu={(side, e, id) => { // A plugin view's rail id is `plugin::` — resolve the owning // plugin's id + display name so the menu can offer "Configure…" (ADR 0036/0059). const pluginId = id.startsWith("plugin:") ? id.split(":")[1] : undefined; - const pluginName = pluginId - ? (runtime?.plugins?.find((p) => p.id === pluginId)?.name ?? pluginId) - : undefined; - openContextMenu("rail-surface", e, { id, side, pluginId, pluginName }); + const rec = pluginId ? runtime?.plugins?.find((p) => p.id === pluginId) : undefined; + const pluginName = pluginId ? (rec?.name ?? pluginId) : undefined; + // Version + lifecycle affordances (#1521 / #1522): removable = tracked in the + // writable plugins dir (git-installed / local copy; in-tree built-ins aren't in + // this list and are refused server-side); updatable = the freshness poll says + // this plugin is behind its ref. Both feed the Update / Uninstall menu gating. + const removable = pluginId ? (installedPluginsQ.data?.plugins ?? []).some((pl) => pl.id === pluginId) : false; + const updatable = pluginId + ? Boolean((pluginUpdatesQ.data?.plugins ?? []).find((u) => u.id === pluginId)?.behind) + : false; + openContextMenu("rail-surface", e, { + id, + side, + pluginId, + pluginName, + pluginVersion: rec?.version, + pluginBuiltin: rec?.builtin, + pluginRemovable: removable, + pluginUpdatable: updatable, + }); }} onRailReorder={(next) => { // Chat can dock anywhere now — left, right, or the bottom dock. Its slot mounts @@ -1114,6 +1216,9 @@ export function App() { onClose={closePluginConfig} /> )} + {/* Rail context-menu plugin actions (#1521 / #1522) — fires an Update, or renders the + Uninstall confirm, for a plugin right-clicked on its rail icon. One root mount. */} + ); } diff --git a/apps/web/src/app/BackgroundWatch.tsx b/apps/web/src/app/BackgroundWatch.tsx index 896c0175..647b01e6 100644 --- a/apps/web/src/app/BackgroundWatch.tsx +++ b/apps/web/src/app/BackgroundWatch.tsx @@ -83,9 +83,13 @@ export function BackgroundWatch() { const header = failed ? `Background agent failed — ${desc}` : `Background agent finished — ${desc}`; + // The report CARD renders its own header row (title + "Background report", + // ADR 0070 D4) — a finished job injects just the result preview as the card's + // excerpt so the lede isn't duplicated. Failures keep the explicit failed-lede + // (the card header alone doesn't convey the outcome); no result → lede only. const injected = appendSystem( session, - result ? `${header}\n\n${result}` : header, + failed && result ? `${header}\n\n${result}` : result || header, jobId ? { jobId, title: desc } : undefined, ); toast({ diff --git a/apps/web/src/app/FleetSwitcher.tsx b/apps/web/src/app/FleetSwitcher.tsx index fe91d682..c819914d 100644 --- a/apps/web/src/app/FleetSwitcher.tsx +++ b/apps/web/src/app/FleetSwitcher.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronDown, ExternalLink, Plus } from "lucide-react"; +import { Check, ChevronDown, ExternalLink, Plus, Settings } from "lucide-react"; import type { ReactNode } from "react"; import { Menu, MenuItem, MenuSeparator } from "@protolabsai/ui/menu"; @@ -15,19 +15,28 @@ const slugOf = (a: { id: string; host?: boolean }) => (a.host ? "host" : a.id); // Topbar agent switcher (ADR 0042 slug routing). The focused agent lives in the URL // (/app/agent//), so picking one NAVIGATES there — each window is its own agent, a reload -// can't desync, and you can open a second agent in a new window. Single-agent (just the host) → -// plain name, no dropdown. The dropdown is the DS Menu (#1078): open/close, outside-click, focus -// trap, and the trailing per-row "open in a new window" action all come from @protolabsai/ui. -export function FleetSwitcher({ fallbackName, onNewAgent }: { fallbackName: ReactNode; onNewAgent?: () => void }) { - // Poll so the topbar reflects the fleet live (a newly-added agent makes the switcher appear). +// can't desync, and you can open a second agent in a new window. The dropdown ALWAYS shows (so +// New agent + Fleet settings are reachable even with a single agent); only a hard fleet-API error +// falls back to the plain name. The dropdown is the DS Menu (#1078): open/close, outside-click, +// focus trap, and the trailing per-row "open in a new window" action all come from @protolabsai/ui. +export function FleetSwitcher({ + fallbackName, + onNewAgent, + onManageFleet, +}: { + fallbackName: ReactNode; + onNewAgent?: () => void; + onManageFleet?: () => void; +}) { + // Poll so the topbar reflects the fleet live (a newly-added agent shows up in the list). const fleet = useQuery({ queryKey: queryKeys.fleet, queryFn: () => api.fleet(), retry: false, refetchInterval: 3_000 }); const agents = fleet.data?.agents ?? []; const slug = currentSlug(); // the agent THIS window is on const current = agents.find((a) => slugOf(a) === slug); - // Solo (just the host) or no hub → plain name, no switcher. - if (fleet.isError || agents.length <= 1) return <>{fallbackName}; + // Only a hard fleet-API error hides the switcher; otherwise it's always available. + if (fleet.isError) return <>{fallbackName}; return ( ); })} - + {agents.length > 0 ? : null} } onSelect={() => onNewAgent?.()}> New agent + } onSelect={() => onManageFleet?.()}> + Fleet settings + ); } diff --git a/apps/web/src/app/GoalsPanel.tsx b/apps/web/src/app/GoalsPanel.tsx index 5b7fae77..829a2fb5 100644 --- a/apps/web/src/app/GoalsPanel.tsx +++ b/apps/web/src/app/GoalsPanel.tsx @@ -2,7 +2,7 @@ import "../goals/goals.css"; import { Input, Textarea } from "@protolabsai/ui/forms"; import { Button, Empty } from "@protolabsai/ui/primitives"; -import { useToast } from "@protolabsai/ui/overlays"; +import { Dialog, useToast } from "@protolabsai/ui/overlays"; import { QueryErrorResetBoundary, @@ -10,8 +10,8 @@ import { useQueryClient, useSuspenseQuery, } from "@tanstack/react-query"; -import { Trash2 } from "lucide-react"; -import { Suspense, useEffect, useState, type FormEvent } from "react"; +import { Plus, Target, Trash2 } from "lucide-react"; +import { Suspense, useEffect, useState } from "react"; import { api } from "../lib/api"; import { ago, errMsg } from "../lib/format"; @@ -102,34 +102,40 @@ function GoalsList() { ); } -// Compact operator "set a goal" form, above the list. Collapsed by default (a
-// disclosure) so it stays out of the way — the primary surface is still the goal list. POSTs -// to the operator `/api/goals` (ADR 0066), which accepts any verifier type; the verifier is a -// small JSON textarea (default `{"type":"llm"}`) parsed on submit — invalid JSON shows an -// inline error and doesn't submit. Result is surfaced via the shared DS toast, and the goals -// query is invalidated so the list picks up the new goal. -function NewGoalForm() { - const queryClient = useQueryClient(); - const toast = useToast(); +// Operator "set a goal" dialog (was an inline
form above the list). ONE form, +// two hosts: the Goals panel's "New goal" header action and the Work overview's Goals-card +// quick-add both open it — the host owns open-state + the setGoal mutation (this component +// is fields + validation only, mirroring TaskCreateDialog/ScheduleModal). The payload is +// unchanged: `{session_id, condition, verifier}` POSTed to the operator `/api/goals` +// (ADR 0066), which accepts any verifier type; the verifier is a small JSON textarea +// (default `{"type":"llm"}`) parsed on submit — invalid JSON shows an inline error and +// doesn't submit. +export function GoalCreateDialog({ + open, + onClose, + onCreate, + busy, +}: { + open: boolean; + onClose: () => void; + onCreate: (body: { session_id: string; condition: string; verifier: unknown }) => void; + busy: boolean; +}) { const [sessionId, setSessionId] = useState("operator"); const [condition, setCondition] = useState(""); const [verifier, setVerifier] = useState('{"type":"llm"}'); const [jsonError, setJsonError] = useState(null); - - const set = useMutation({ - mutationFn: (body: { session_id: string; condition: string; verifier: unknown }) => - api.setGoal(body), - onSuccess: (res) => { + // Blank slate each time the dialog opens (keep the session/verifier defaults). + useEffect(() => { + if (open) { + setSessionId("operator"); setCondition(""); - toast({ tone: "success", title: "Goal set", message: res.message || "The agent has a new goal." }); - }, - // A rejected verifier / disabled goal mode comes back as HTTP 400 → request() throws here. - onError: (e) => toast({ tone: "error", title: "Couldn't set goal", message: errMsg(e) }), - onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.goals }), - }); + setVerifier('{"type":"llm"}'); + setJsonError(null); + } + }, [open]); - const submit = (e: FormEvent) => { - e.preventDefault(); + const submit = () => { const cond = condition.trim(); if (!cond) return; let parsed: unknown; @@ -140,26 +146,47 @@ function NewGoalForm() { return; } setJsonError(null); - set.mutate({ session_id: sessionId.trim() || "operator", condition: cond, verifier: parsed }); + onCreate({ session_id: sessionId.trim() || "operator", condition: cond, verifier: parsed }); }; return ( -
- New goal -
- + New goal} + width="min(520px, 94vw)" + footer={ + <> + + + + } + > +
+