Skip to content

chore: sync fork to upstream protoAgent v0.81.0 (80 commits)#3

Merged
mabry1985 merged 82 commits into
mainfrom
chore/sync-upstream-v0.81.0
Jul 2, 2026
Merged

chore: sync fork to upstream protoAgent v0.81.0 (80 commits)#3
mabry1985 merged 82 commits into
mainfrom
chore/sync-upstream-v0.81.0

Conversation

@mabry1985

Copy link
Copy Markdown

Merges upstream protoAgent/main (v0.77.0 → v0.81.0, 80 commits) into roxy.

What came in

Fork delta handling

  • Zero merge conflicts — roxy's customization surface (PROTO.md, README.md, SOUL.md, example config) auto-merged.
  • Config-roundtrip golden trued-up to roxy's example values (identity_name: roxy, plugins_enabled: [delegates, portfolio]) — 26/26 pass.
  • ruff check (CI pin 0.15.10) clean; representative tests (docs plugin, skill index, config roundtrip) 66/66 pass.
  • Tracking fork: adopts upstream version 0.81.0 as-is.

⚠️ Merge this as a MERGE COMMIT (not squash) — squashing flattens the upstream lineage and breaks the merge-base for the next sync.

🤖 Generated with Claude Code

mabry1985 and others added 30 commits July 1, 2026 06:40
…R 0068 slice 1) (protoLabsAI#1533)

* feat(flags): developer-flags backend foundation (protoLabsAI#1506, ADR 0068 slice 1)

Slice 1 of the developer-flags system: the registry + resolution.

- runtime/flags.py — a frozen `Flag` dataclass, the `FLAGS` registry
  (single source of truth, empty for now), and `flag_enabled(id)` /
  `resolved_flags()`. Tiers off<dev<beta<on resolve against a runtime
  `channel` (prod ⊂ beta ⊂ dev); precedence is
  PROTOAGENT_FLAG_<ID> env override > tier-vs-channel > off (fail-closed
  on an unregistered id).
- channel derivation: explicit PROTOAGENT_CHANNEL > the dev sandbox
  instance (PROTOAGENT_INSTANCE=dev, ADR 0065) > the new
  `developer.channel` config field > prod. Read live (graph.sdk.config,
  imported lazily) so a Settings save applies without a restart, and
  degrades to env/default when there's no graph state (ACP/headless).
- `developer.channel` added to LangGraphConfig + the settings schema
  (select prod/beta/dev, section "Developer" → Behavior domain), and the
  config-roundtrip goldens updated.

22 flag unit tests + the config-roundtrip/settings suites green; ruff
clean; import contracts kept (runtime→graph.sdk/infra.paths is allowed).
The /api/flags route (slice 2) and the Developer panel (slice 4) build
on resolved_flags().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: add 'developer' to config_to_dict top-level section set (protoLabsAI#1506)

test_config_io.py::test_config_to_dict_mirrors_yaml_shape hardcodes the
full top-level key set config_to_dict emits; the new developer.channel
field adds a 'developer' section. Verified against the full suite
(2574 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te) (protoLabsAI#1538)

Adding a LangGraphConfig field used to require updating FOUR parallel
hand-maintained lists of the config surface, and they'd already drifted:
- test_config_roundtrip.CONFIG_TO_DICT_GOLDEN (exhaustive nested dict)
- test_config_roundtrip.EMITTED_ATTRS (round-trip attr set)
- test_config_roundtrip.FROM_YAML_EXAMPLE_FIELDS (value map)
- test_config_io section set (top-level key set)

config_to_dict is FIELDS-driven, so the SHAPE ones are redundant with the
schema. Derive them:
- EMITTED_ATTRS = {f.attr for f in FIELDS} | <18 non-FIELDS §B legacy attrs>.
  This FIXES a latent gap: EMITTED_ATTRS was silently missing 7 fields
  (developer_channel, checkpoint_vacuum, commons_path, egress_allowed_hosts,
  identity_org, knowledge_scope, skills_scope) — their round-trip wasn't tested.
- the test_config_io top-level section set = {FIELDS sections} | {subagents, plugins}.
- drop the exhaustive CONFIG_TO_DICT_GOLDEN; replace its test with a focused
  shape + redaction check. Value coverage stays in FROM_YAML_EXAMPLE_FIELDS
  (the flat view of the same cfg) and test_round_trip_preserves_emitted_fields
  (field-agnostic drop/mis-parse guard).

Net: adding a config field now touches ONE golden (FROM_YAML_EXAMPLE_FIELDS,
the value gate), not four. -314/+63 lines. Full suite 2574 passed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…8 slice 2) (protoLabsAI#1539)

Serves runtime.flags.resolved_flags() — the active channel + every
registered developer flag with its resolved enabled state + source — so
the console Developer panel (slice 4) can render and toggle them.
Read-only, gated by the /api/* operator bearer like every operator route.
operator_api/flags_routes.py + registered in server bootstrap next to the
fleet/theme routes. 3 route tests; import contracts kept
(operator_api -> runtime.flags is allowed).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…68 slices 3–4) (protoLabsAI#1540)

The console half of developer flags, on top of the /api/flags route (protoLabsAI#1539):

- flags/flags.ts — useFlag(id) gates console UI (fail-closed while loading);
  useFlags()/useDeveloperChannel() read /api/flags. Two frontend override
  layers on top of the server's channel resolution: a device-local panel
  toggle (createUISlice, persisted per-agent) and a shareable ?flag:<id>=on|off
  query param. Precedence: query-param > panel toggle > server channel state.
- settings/DeveloperPanel.tsx — Settings ▸ Developer: each flag with tier badge
  + resolved state + a per-device Switch + Reset; "Reset all" clears overrides.
- SettingsSurface wires it into "This console" ONLY off prod
  (developerPanelVisible: dev build / non-prod channel / ?dev|?flag: reveal),
  so production operators never see it.
- lib/types + api.flags() + flagsQuery.

Verified: tsc clean; vitest (override store) + full web unit suite 217; a new
developer-flags.spec.ts e2e (panel lists flags, toggle persists an override,
Reset clears) run against the built app; settings.spec section-list updated.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ADR 0068 slice 5) (protoLabsAI#1541)

The cleanup contract (ADR 0068 D6): a developer flag with an ISO-date
remove_by in the past is overdue debt — graduate it to `on` and delete
the flag + old code path. test_no_flag_is_past_its_remove_by fails CI so
a stale gate is visible instead of accreting. Non-date remove_by values
(e.g. a version) are skipped (can't be auto-compared). No-op today (FLAGS
is empty); a guard for as the registry grows.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gutter (protoLabsAI#1542)

Code blocks in chat rendered wonky (a huge left gutter, code shoved sideways, not
responsive). Root cause: streamdown 2.5.0 defaults `lineNumbers: true` and renders the
line-number gutter PURELY via `before:` Tailwind utility classes with NO `data-streamdown`
hook. The console styles streamdown's markdown via the DS `@protolabsai/ui/markdown`
attribute-selector CSS and deliberately purges streamdown's inert Tailwind classes — but the
line-number gutter has no attribute selector to hook, so it can be neither themed by the DS
nor generated by Tailwind → an unstyled, half-broken gutter.

Line numbers are unthemeable in this model, so turn them off (`lineNumbers={false}`). Every
other part of the code block (border, header, body, copy button, shiki colors) is already
themed by the DS CSS and renders clean. Follow-up: the DS `<Markdown>` should default this
off since it can't theme the gutter (protoContent gap).
… slice 6a) (protoLabsAI#1543)

The wrap-and-delete workflow end to end: define a flag in runtime/flags.py,
gate a backend path (flag_enabled) and console UI (useFlag), flip it via
env / ?flag: / the Developer panel, and graduate+delete under the remove_by
cleanup contract. Explains tiers × channel and why a flag is neither a
plugin nor a setting. Wired into the Console & UI guides (sidebar + index)
and nav.json regenerated.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…I#1547)

The macOS (10×) and Windows (2×) legs of desktop-build.yml were the repo's
dominant CI cost — the full three-platform matrix fired on EVERY semver tag
(~99 tags/month at current cadence), even though desktop drops don't need to
track the tag firehose. Retire the `push: tags` trigger; desktop builds are
now on-demand via workflow_dispatch.

Two dispatch modes, keyed off the existing `tag` input:
- WITH a tag (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 still creates releases --latest=false; promotion happens
  in the fan-in). This is the path that reaches users' in-app updater.
- WITHOUT a tag → a test build: workflow artifacts only, no release/manifest.

Tag pushes still ship the Docker image + a (non-Latest) GitHub Release via
release.yml — only the desktop binaries wait for a dispatch. 'Latest' now tracks
the last DESKTOP release (the one carrying latest.json), so the in-app updater
never 404s on a manifest-less release.

Also: the "unsigned release must fail" guard now keys on `inputs.tag` (a tagged
publish still requires the full Apple secret set); fan-in checkout/TAG read from
`inputs.tag` instead of github.ref_name. Docs updated (releasing.md § Desktop,
apps/desktop/README.md, react-tauri-ui.md).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ystem text (protoLabsAI#1536) (protoLabsAI#1548)

The wait tool returned raw "Yielding for 300s — turn ending now. You'll be
re-invoked at <ISO>… to: <then>", which agents recited verbatim into chat as a
raw system/engineering message. Return a concise, human-friendly summary instead
— "Wait scheduled: 5 minutes. Will resume to: <then>." — via a new
_humanize_duration helper (300s → "5 minutes"), and add docstring guidance
telling the agent not to echo the return value verbatim but to paraphrase it
conversationally. The yield/re-invoke scheduling mechanism is unchanged.

Also refreshes the now-stale "Yielding…" references in server/chat.py's
_last_tool_text docstring and the wait-yield test fixtures.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… just an instance id (protoLabsAI#1553)

When an agent boots a local server for someone to test against while their real
instance(s) are running, sharing only the box root (plain dev.sh) is data-safe
but trips the desktop's co-residence warning (protoLabsAI#1552) and collides on box-level
resources (mDNS, scheduler owner-lock). Document the fully-isolated boot
(PROTOAGENT_BOX_ROOT + PROTOAGENT_INSTANCE + free port), its config-inheritance
tradeoff, and the worktree dist-serving note (_bundle_root anchors to the loaded
server package).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… of bricking the console (protoLabsAI#1554)

Adding a fleet member that fails to start (or navigating to a stopped one) left the console
stuck at the boot gate: the focused agent's slug routes EVERY API call through the hub proxy,
which returns 409 "agent not running" for a down member, so the boot probe just retried ~30×
into a generic "isn't responding" gate whose only escape ("Continue anyway") opened a fully
409-broken app. No indication it was the ONE agent, and no way back to a working console.

Now: when a NON-host slug keeps 409'ing past a normal spawn window (≥6 retries ≈ ~6s), the
gate switches to targeted recovery — "Agent '<slug>' isn't running" with **Return to host**
(navigate to the host console, which always works) and **Try to start it** (re-activate +
refetch). Uses TanStack Query v5 `failureReason`/`failureCount` so it appears DURING retries,
not only after the probe gives up. host / 502 / cold-start paths are unchanged (502 stays a
cold-start retry, not a down-agent — a booting proxy will come up).

+ `isAgentNotRunning(err)` helper (409-only, distinct from `isColdStart` which also rides
502/no-response) + unit tests. Build + 218 unit tests green.

NOTE: builds + typechecks; the recovery UI itself wants a quick manual check — point a window
at a stopped agent's slug (/app/agent/<slug>/) and confirm the card + both buttons.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, command bubbles (protoLabsAI#1530, protoLabsAI#1528, protoLabsAI#1529) (protoLabsAI#1549)

protoLabsAI#1530: the slash popover now triggers MID-INPUT at any caret position (not only
when "/" is char 0), via a caret-aware slashTokenAt parser + native caret
listeners; completing inserts/removes only the token so surrounding text is
preserved at start/middle/end.
protoLabsAI#1528: the keyboard-focused item auto-scrolls into view (scrollIntoView
block:nearest tied to the active index).
protoLabsAI#1529: an issued (sent) slash command renders as a distinct user bubble
(violet tint + monospace + "/" badge), detected via slashCommandName which
ignores file paths.

Review fix: slashTokenAt now returns the token's true `end` (scan to next
whitespace), so completing with the caret in the MIDDLE of a token replaces the
whole token instead of leaving a trailing suffix.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…I#1526) (protoLabsAI#1551)

A user-configurable keybinding (default mod+o, scope "chat", allowInInput) that
toggles the latest top-level tool-call block in the current agent output: first
press expands the newest block; pressing again collapses it and walks the cursor
upward (newest → oldest); idle targets the most recent block in the last message;
no tool blocks → no-op. Registers through the ADR-0063 keybinding system so it
appears in Settings ▸ Keyboard and is rebindable; the host preventDefaults the
browser's native Cmd/Ctrl+O while focus is in chat.

The DS ToolCard exposes only uncontrolled `defaultOpen` (no controlled
open/onToggle — DS gap noted), so the action walks the rendered chat DOM and
clicks the disclosure toggle, working uniformly across every render path with no
changes to ToolCalls/WorkBlock/ChatSurface.

Review fix: exclude reasoning cards (.reasoning-card, which render a DS ToolCard
but aren't tool calls) so a reasoning-only turn is a correct no-op and the walk
skips a leading reasoning card.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oLabsAI#1546)

Bumps the DS from 0.52.0 → 0.53.0 and removes the console workarounds it now makes
unnecessary:

- Markdown line numbers: 0.52.1 (protoContent#376) made the DS `<Markdown>` default
  `lineNumbers` off + theme the gutter for Tailwind-purging consumers, so the console
  no longer forces `lineNumbers={false}` (drops the protoLabsAI#1542 workaround).
- Rail-background context menu: 0.53.0 adds `onRailBackgroundContextMenu(side, e)` to
  AppShell — the "Hidden views" restore menu now uses it instead of catching the shell
  right-click and recovering the side via `closest(".pl-rail")` + `classList`.
- Chat-tab context menu: 0.53.0 adds `onTabContextMenu(id, e)` to TabBar — the per-tab
  menu now gets the session id directly instead of mapping the clicked tab to a session
  by DOM sibling index. A minimal background-only wrapper remains for the empty-tab-bar
  "New chat" affordance (which the per-tab hook doesn't cover), bailing on tab hits.

Lockfile bump is minimal (0.53.0 has identical deps to 0.52.0). `npm ci` installs 0.53.0
clean; `tsc` build green; 217 unit tests pass.

NOTE: the context-menu wiring typechecks + builds but I could not click-test right-click
menus — worth a quick smoke: right-click a rail icon, empty rail space, a chat tab, and
empty tab-bar space; plus Rename/Close/New from the tab menu.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l container (protoLabsAI#1544, protoLabsAI#1545) (protoLabsAI#1550)

* feat(console): header brand settings menu + unified settings sub-panel container (protoLabsAI#1544, protoLabsAI#1545)

protoLabsAI#1544: the header brand mark is now a compact settings menu — click or
right-click opens a DS Menu (Radix) with Agent settings / Fleet settings /
Theme, each deep-linking the settings overlay via openGlobalSettings(section).
Keyboard-accessible (Enter/Space/arrows/Esc) with a hover/focus chevron
affordance. New BrandMenu component (the DS app-shell Header exposes no
brand-slot onClick — contribute-back candidate noted).
protoLabsAI#1545: the Keybinds and Delegate panels now render through a shared
SettingsSubPanel container (StagePanel + DS PanelHeader + scrolling body),
matching the sibling panels so the chrome can't drift again. Content unchanged.

Review fix: removed now-dead .settings-group / .settings-group-title CSS
(unreferenced after DelegatesSection moved to the shared container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): fix delegates title locator after SettingsSubPanel rewrap (protoLabsAI#1545)

The Delegates panel title now renders in the shared SettingsSubPanel header (a DS
PanelHeader heading) outside .delegates-section, so the old
`.delegates-section >> text=Delegates` assertion missed it. Assert the heading via
getByRole (the pattern the fleet/knowledge/mcp specs already use); rows stay
scoped to .delegates-section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ool work (protoLabsAI#1555)

Adds [Unreleased] entries for work that merged without its own changelog note:
slash-command UX (protoLabsAI#1530/protoLabsAI#1528/protoLabsAI#1529), header brand menu (protoLabsAI#1544), Cmd/Ctrl+O
tool-block toggle (protoLabsAI#1526), the shared settings sub-panel container (protoLabsAI#1545), the
conversational wait-tool output (protoLabsAI#1536), manual desktop builds (protoLabsAI#1547), and the
isolated-dev-boot / manual-desktop docs (protoLabsAI#1553, protoLabsAI#1552).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… shows + gains Fleet settings (protoLabsAI#1544 follow-up) (protoLabsAI#1556)

protoLabsAI#1544's brand-mark settings menu (Agent · Fleet · Theme deep-links on the header
logo) wasn't the intended UX. Revert it: the header logo is a plain mark again
(delete BrandMenu + its .brand-menu-* CSS, restore the plain ProtoLabsIcon logo).

Instead, the existing FleetSwitcher dropdown (on the agent name) now ALWAYS shows
— not only when >1 agent is in the fleet — so New agent + fleet nav are reachable
even for a solo agent (only a hard fleet-API error falls back to the plain name).
It gains a "Fleet settings" item that opens the fleet management dialog
(openGlobalSettings("fleet")), alongside the existing agent list + New agent.

protoLabsAI#1545 (shared settings sub-panel container) is unaffected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kpoint (protoLabsAI#1527) (protoLabsAI#1558)

* feat(chat): /compact — summarize + archive a thread, rewrite the checkpoint (protoLabsAI#1527)

Server-side /compact: summarize the current chat thread, archive the FULL raw
transcript to the searchable knowledge store (domain=conversation,
namespace=chat-archive:<session_id>), then rewrite the live LangGraph checkpoint
to [RemoveMessage(REMOVE_ALL_MESSAGES), summary, *recent_tail] so the agent keeps
context at a fraction of the token cost. The manual analogue of the automatic
SummarizationMiddleware.

Two hard invariants:
- Never-lossy: rewrite happens ONLY after the raw history is archived AND a
  non-empty summary exists. Refuses (no checkpoint change) on no_store /
  empty_archive / archive_error / no_summary / summary_error / no_checkpointer.
- Message-boundary integrity: the retained tail never orphans a ToolMessage from
  its parent AIMessage(tool_calls) — reuses the middleware's safe-cut.

graph/compaction_op.py (host-free) → server.chat.compact_session (under
_thread_lock) → POST /api/chat/sessions/{id}/compact; /compact client command +
api.compactChatSession. render_transcript gains max_chars=None for the uncapped
archive. Tests: test_compaction_op.py (invariants incl. real-SQLite e2e +
summarizer-exception refuse) + a route test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): add /compact to the slash-menu expected list (protoLabsAI#1527)

/compact registers as a new client slash command, so commands.spec's exact
CLIENT_SLASH list must include it (it sorts after /clear).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rotoLabsAI#1561)

Adds [Unreleased] entries for /compact (protoLabsAI#1527, protoLabsAI#1558) and rewrites the reverted
protoLabsAI#1544 brand-menu entry to its net shipped result — the always-available agent
switcher + Fleet-settings shortcut (protoLabsAI#1544, protoLabsAI#1556). (The /close command was
reverted, so it's intentionally not documented.)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l on bug form (protoLabsAI#1562)

Removes the "Expected vs. actual" field from the bug report form and makes
the "Acceptance" field optional. The bug form's required fields now map
cleanly to the issue gate, which for `bug`-labeled issues only requires a
Problem section plus Steps-to-reproduce/evidence — it never required
Acceptance for bugs. `hasRepro` is still satisfied by "Steps to reproduce /
evidence," so dropping expected-vs-actual won't trip the gate.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ce, not prod :7870 (protoLabsAI#1564)

apps/web/vite.config.ts proxied `npm run dev`/`preview` backend calls (/api, /a2a,
events, /agents, /plugins, /_ds) to http://127.0.0.1:7870 BY DEFAULT — the
default/prod instance the desktop app runs on ~/.protoagent. So browser-based
console dev silently read AND wrote the real desktop-app data (crossing dev↔prod
streams). Yesterday's ADR-0065 instance isolation separated the backends but never
covered this frontend-proxy layer.

Default is now http://127.0.0.1:7871 (the isolated `scripts/dev.sh` instance) —
fail-safe (a clean can't-connect if no dev backend is up, never a silent prod
hit) — plus a loud red startup guard whenever PROTOAGENT_API_BASE points at :7870.
The correct dev loop is `scripts/dev.sh` (backend :7871) + `npm run dev`
(frontend), both isolated. Docs: PROTO.md § Run it + CHANGELOG.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o-end (protoLabsAI#1527) (protoLabsAI#1566)

The shipped suite proved the checkpoint rewrite against a real SQLite
checkpointer but faked the knowledge store — so "the full transcript is archived
and searchable" was asserted only at the add_document call, not that it's
retrievable. Add an integration test using a REAL KnowledgeStore (FTS5, no
gateway): seed a distinctive token ("pumpernickel") in the head that compaction
REMOVES, compact, then assert (1) it actually compacted (archived_chunks>=1,
removed=4, kept=2, summary returned), (2) the live checkpoint collapsed to
[summary, tail] with the head gone, and (3) store.search finds the removed head
in the conversation domain — proving the raw history is preserved + searchable.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (ADR 0042) (protoLabsAI#1565)

Archetypes were a hardcoded list in `_archetypes()`, and the built-in
"Project Manager" pointed at `protoLabsAI/pm-stack` — a repo since split
into product-stack / leadEngineer / portfolio-manager-stack, so the card
no longer installed what it promised.

- Move built-in archetypes to `config/archetype-catalog.json` (served by
  GET /api/archetypes), so they can be added/removed with no code change;
  live config dir overrides the bundled seed like plugin/mcp catalogs. A
  hardcoded Basic+Custom fallback keeps the picker working if it's missing.
- Ship Basic + Custom; installed bundles that declare an `archetype:` block
  still self-register on top, now DEDUPED by id + normalized bundle URL so a
  card can't appear twice (fixes a latent duplicate React key).
- Fleet "New agent" now applies the archetype's persona: POST /api/fleet
  gained `soul`, threaded to manager.create(), which writes it to the new
  workspace's config/SOUL.md — so a bundle agent arrives WITH its persona.
- Refresh fleet guide + e2e fixture (dead pm-stack → product-stack) + tests
  (catalog fallback, dedupe, persona-write). CHANGELOG under [Unreleased].

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…box root (protoLabsAI#1552) (protoLabsAI#1567)

The boot "another instance can clobber your chat/knowledge/stores" warning
(infra/paths.colocation_warning) fired whenever ANY other protoAgent process was
live on the machine — because heartbeats live at the box tier and the check keyed
on the shared box_root. So a `dev` instance (~/.protoagent/dev, wholly separate
data) tripped a data-loss warning against the desktop/default instance, and the
suggested remedy ("give each its own PROTOAGENT_INSTANCE id") was a no-op since
they already had distinct ids.

Now each heartbeat records its instance_root, and colocation_warning warns only
when another live process shares THIS instance's instance_root (a genuine
clobber — e.g. the same instance run twice). Box-only co-residents with distinct
instance ids are detected but not flagged. A co-resident whose heartbeat predates
the field is treated as distinct (favours no false alarm).

Tests: same-instance_root → warns; box-only (distinct instance_root) → silent.

Closes protoLabsAI#1552.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k persona soul (ADR 0042) (protoLabsAI#1568)

Two audit follow-ups on the archetype flow:

- The wizard's post-finish bundle-install outcome ("tools ready" /
  "couldn't auto-enable — turn them on in Settings ▸ Plugins" / install
  failed) was set as an in-wizard Callout, but onFinished() flips
  setup_complete and unmounts the wizard immediately — so the message,
  and the actionable enable/failure guidance, was never actually read.
  Surface it as a toast, which lives in the top-right stack and survives
  the unmount. The in-progress "Setting up…" hint stays inline (the wizard
  is still open during the install await).

- Picking a bundle archetype whose manifest declares no inline `soul:`
  blanked the persona editor (soul === ""). Fall back to the base SOUL
  (the "basic" archetype's persona) via a new pure `personaSoul()` helper,
  used by both pickArchetype and the initial seed. Unit-tested.

Verified: web unit suite 245 passed (incl. 4 new persona tests); tsc clean
for the touched files (the 2 pre-existing DS-skew errors in App/ChatSurface
are unrelated and clear under CI's npm ci).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stall (protoLabsAI#1521, protoLabsAI#1522) (protoLabsAI#1569)

The Plugins settings panel already had version/update/uninstall; this adds the
RAIL context menu path and DRYs the shared logic. Right-click a plugin rail icon →
a disabled "Version vX.Y.Z" header, an "Update available" action when the
freshness poll reports it's behind, and a destructive "Uninstall…" (confirm:
"Uninstall <name>? This cannot be undone."). Update + Uninstall are gated so
built-in / bundled-in-tree plugins never offer them (server independently
refuses too). Shared usePluginManage() hook (update+uninstall mutations, DS
toast, query refresh) consumed by both the panel and the new PluginRailManage
root host; rail actions route through ephemeral uiStore triggers so the
context-menu registration stays a pure function (mirrors configurePlugin).

Closes protoLabsAI#1521, closes protoLabsAI#1522.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…AI#1532) (protoLabsAI#1570)

Mirrors the changelog pipeline exactly: ROADMAP.md (## Planned / In progress /
Shipped, bulleted) → scripts/roadmap.py (build → sites/marketing/data/roadmap.json;
check = drift guard) → sites/marketing/src/pages/roadmap.astro (same BaseLayout +
DS tokens as changelog.astro, grouped by status, issue/release-tag ref chips) +
a /roadmap nav+footer link before Changelog. Seeded with real open-issue items
(protoLabsAI#1520/protoLabsAI#1515/protoLabsAI#1514/protoLabsAI#1504/protoLabsAI#1535/protoLabsAI#1522/protoLabsAI#1521/protoLabsAI#1537) + v0.78.0 shipped highlights.

Closes protoLabsAI#1532.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e the checkpoint (protoLabsAI#1535) (protoLabsAI#1572)

A destructive "Rewind to here" action on assistant messages: discards everything
after the chosen message and, critically, rewrites the a2a:<session_id> LangGraph
checkpoint (the agent's real context) so a client-only trim can't leave the agent
still remembering discarded turns. Mirrors the /compact plumbing:
graph/rewind_op.py (host-free) → server.chat.rewind_session (under _thread_lock)
→ POST /api/chat/sessions/{id}/rewind; confirm dialog + client mirror on found.
Boundary integrity: _safe_cut_end never orphans a tool_call from its ToolMessage.

Review fix (was: silent divergence on duplicate assistant content — the server
resolved content to the LAST match while the client truncated at the clicked
bubble). The client now sends WHICH occurrence of that text it clicked, and the
server picks the same occurrence from the start (falls back to last-match only
when unaligned). Tested.

Closes protoLabsAI#1535.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ule changes (protoLabsAI#1537) (protoLabsAI#1571)

The Overview roll-up read goals/tasks/schedule via useSuspenseQuery but never
subscribed to the server→client event bus — so while the Overview tab is showing
(the individual panels unmounted), nothing refreshed its counts. Subscribe in
WorkOverview via the existing onServerEvent SSE client, invalidating the matching
query keys on the ADR-0039 topics: goal.changed + goal.iteration, task.changed,
scheduler.fired. Push-based, background invalidate (no re-suspend flicker).

The scheduler bus has no push on agent schedule add/cancel (mutates the store
directly), so the Overview's schedule query gets a gentle per-use refetchInterval
(15s) — NOT on the shared factory, to avoid regressing SchedulePanel.

Closes protoLabsAI#1537.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mabry1985 and others added 24 commits July 1, 2026 17:52
… 0068) (protoLabsAI#1606)

/compact (protoLabsAI#1558) is still pre-release, so it becomes the FIRST real entry in
the runtime/flags.py registry: tier "dev", remove_by 2026-09-01 (the staleness
guard now tracks it). Prod-channel users no longer see the command; the dev
channel — or PROTOAGENT_FLAG_CHAT_COMPACT=1 / ?flag:chat.compact=on / the
Settings > Developer toggle — keeps it on.

Both ends gate:
- Backend: POST /api/chat/sessions/{id}/compact 403s when the flag is off,
  before compact_session is reached — a disabled gate can't touch the
  checkpoint.
- Console: the client slash-command seam (ADR 0061) gains an optional `flag:`
  tag; ChatSurface lists + dispatches a tagged command only while its flag
  resolves ON (new useFlagPredicate(), the list-friendly sibling of useFlag —
  same ?flag: query > device override > server-state precedence). A flag-off
  command behaves as if never registered, so forks can flag-gate their own
  commands through the same seam.

Tests: route pass-through now forces the flag on via the env override + a new
flag-off 403 test proving compact_session is never called; unit test pins the
flag tag on /compact; e2e adds a ?flag:chat.compact=off run showing the
command vanish from the slash menu (mock server serves the real flag enabled
so the existing CLIENT_SLASH listing still covers it).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…pt with fade, Open CTA, by-id fetch (ADR 0070 D4) (protoLabsAI#1605)

* feat(console): background report card — raised surface, clamped excerpt with fade, Open CTA, by-id fetch (ADR 0070 D4)

The chat report card becomes a real card instead of the DS system-message
pill (near-black bg-inset fill, 100px radius, ghost link):

- Raised surface: --pl-color-bg-raised, 1px border, --pl-radius corners,
  left-aligned block, drop shadow var(--pl-shadow-popover) with the
  .pl-convo__jump literal fallback. Selectors use STACKED specificity
  (.pl-message--system.chat-report …, anchored under .chat-session-slot)
  so neither the DS default nor our own base system-message rule can win
  by stylesheet load order.
- Header row: report title + "Background report" subtitle (FileText glyph).
- Excerpt is a teaser: clamped to ~7 lines (max-height + overflow hidden)
  with a bottom fade-out (mask-image linear-gradient, -webkit- prefixed) so
  the preview visibly trails off. BackgroundWatch now injects just the
  result preview for finished jobs (the card header owns the lede;
  failures keep the explicit failed-lede).
- Clear CTA: an outline DS Button "Open report" into the document viewer;
  the whole card is click-to-open (selection-guarded so copying excerpt
  text never opens the viewer).
- By-id fetch: new api.backgroundJob(id) → GET /api/background/{id}
  (the ADR 0070 route carrying the FULL result). loadBackgroundReport
  keeps the legacy list-and-filter ONLY as a 404 fallback (pre-0070
  servers / deleted rows) and propagates real errors to the viewer.

Tests: unit (by-id fetch, 404-only fallback, placeholder, error
propagation — 6 new cases) + e2e report-card.spec.ts (card renders
clamped with the fade mask, CTA opens the docviewer with the full
report, and the by-id route is the fetch path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(console): fade the report-card excerpt only when it actually clamps (ADR 0070 D4 review)

The bottom fade-out mask on .chat-report-excerpt was unconditional, and a CSS
mask scales to the element's own box — so a SHORT report that fits entirely had
its last line (the conclusion) ghosted toward invisible and misread as
truncation. Reproduced: a 4-paragraph result rendered its final line at ~30%
opacity with nothing clamped.

Gate the mask behind a measured .chat-report-excerpt--clamped modifier
(scrollHeight > clientHeight, re-measured via ResizeObserver so rewrapping on
width changes moves the overflow point correctly). The e2e now covers both
directions: the long report clamps + fades, a short report renders its final
line unmasked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…otoLabsAI#1607)

Both are only reachable on a non-loopback (tailnet/LAN) hub; a loopback
desktop hub was never exposed.

1. WS proxy bypassed hub auth. The default-deny gate is a Starlette
   BaseHTTPMiddleware, which skips non-HTTP scopes, so /agents/<slug>/*
   WebSocket ran unauthenticated. For a REMOTE member the hub would attach
   that member's stored bearer and proxy any caller into its authed sockets
   (e.g. a terminal plugin PTY). forward_ws now refuses WS to a remote member
   (close 1008); host/local-peer sockets (no hub-stored credential) are
   unchanged. Remote live views should use delegate_to / a direct connection.

2. SSE cross-key break. The /api/sse-token was fetched slug-routed
   (member-signed), but the proxied /api/events 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 token; the hub
   validates it and forwards with the member's own credential attached.

Tests: WS proxy refuses a remote (1008) before resolving a target, still
serves a live local peer; apiUrl keeps the SSE token on the hub in a member
window. Full pytest 2764 passed; console unit 30 passed; tsc clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…tus (protoLabsAI#1608)

Phase 2 of the remote-fleet audit (console operability), stacked on the
Phase 1 security fix.

- Add a remote by URL: the ONLY way to register a token-gated remote from the
  console (discovery carries no credential) or one on a subnet the scan can't
  reach. Name + URL + optional token (SecretInput), gated by canAddRemote().
- Reachability feedback: both the manual add and the discovery add now read the
  server's register-time `reachable` and toast "added, not reachable yet" vs a
  success toast, instead of silently dropping a dead row in.
- Honest remote status: an offline remote reads "unreachable" with a warning dot
  (its `running` IS its reachability probe) instead of a neutral "stopped" — it
  isn't stopped, and can't be started.

Deferred to a follow-up (needs a backend endpoint + an AuthGate refactor): edit
a remote's token/URL in place, and route a proxied-remote 401 to a per-remote
"auth failed — update token" state instead of the global hub AuthGate; plus the
dead-remote 502 return-to-host boot recovery in App.tsx.

Console: tsc clean, 262 unit tests pass, production build clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ened ones (protoLabsAI#1609)

* feat(fleet): edit remote members in place + real recovery for down/mis-tokened ones

Remote-fleet audit follow-up (round 2), stacked on protoLabsAI#1608.

Backend:
- supervisor.update_remote(ident, name?/url?/token?) edits a registered remote in
  place under the remotes lock — omitted fields kept, token="" clears — re-running
  the same reserved-name / SSRF-egress / collision checks as add (factored into a
  shared _normalize_remote_url) and invalidating the probe cache. New
  PATCH /api/fleet/remotes/{ident} re-probes and returns fresh {reachable,version}.

Console:
- The add-a-remote form doubles as an EDIT form (Pencil on remote rows), pre-filled;
  blank token = keep. This is how you fix a rotated/wrong token or changed URL
  without remove+re-add (which would mint a new id/slug and break open windows).
- A proxied MEMBER's 401 no longer trips the hub's global AuthGate (which prompts
  for, and would overwrite, the HUB token). request()/requestForm()/the a2a stream
  now suppress it for slug-routed member requests (isMemberScoped).
- Boot gate gains targeted recovery for the focused member: a 502 (unreachable
  remote — it never 409s) folds into agentDown with Return-to-host/Try-again; a 401
  shows "can't authenticate — update its token in Settings ▸ Agents".

Tests: update_remote unit + PATCH route + isAgentUnreachable predicate. Full pytest
2767 passed; console tsc clean, 269 unit tests, production build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(fleet): cover the boot-gate decision + member-401 suppression

The high-blast-radius bits of the remote-recovery work were only covered at the
predicate level. Add the wiring tests:

- Extract bootGatePhase() (pure precedence: memberAuth > unreachable/notRunning >
  failed > stuck > loading) out of App.tsx's nested boot-gate ternaries and unit-test
  the full decision table. App now keys title/detail/action off the phase.
- Behavioral test that a MEMBER-scoped 401 does NOT trip the global hub AuthGate
  (notifyAuthRequired stays quiet) while a HOST-scoped 401 does — the load-bearing
  api.ts change. Plus isMemberScoped() predicate coverage (exported for it).

Console: tsc clean, 279 unit tests pass, build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…protoLabsAI#1607/protoLabsAI#1608/protoLabsAI#1609 (protoLabsAI#1611)

The v0.35.0 WS amendment said the slug proxy forwards WebSocket upgrades for
plugin views "carrying the bearer" — protoLabsAI#1607 deliberately narrowed that: WS
proxying to a REMOTE member is now refused (the hub's HTTP-only default-deny
auth would otherwise lend the member's stored token to an unauthenticated
caller). Add the amendment, note the hub-signed SSE token fix, and document the
console add-by-URL/edit-in-place management + the down/mis-tokened recovery UX.

Docs-only; `npm run docs:build` clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…mmand settings (protoLabsAI#1612)

Two halves of one operator ask — "completely disable run_command for a given
agent":

1) The denylist actually works now. tools.disabled was applied only inside
   get_all_tools(), while the filesystem tools (incl. run_command), plugin/MCP
   extra_tools, delegation and late-seam tools were appended after it in
   create_agent_graph — so tools.disabled: [run_command] silently did nothing.
   New drop_disabled_tools() (tools/lg_tools.py) is the single filter, applied:
   - after the extra_tools merge (before the subagent tool_map snapshot, so a
     disabled tool can't ride into a subagent via an allowlist),
   - after the fs/late-tools appends, before the deferred search_tools build
     (a dropped name is never advertised),
   - in run_manual_subagent (the out-of-graph console fan-out path).
   create_agent_graph also syncs the denylist from ITS config, so eval sweeps /
   scripts that build graphs directly stop inheriting a stale process global.

2) The knobs are in Settings, per agent. New schema sections (Capabilities):
   Filesystem — filesystem.enabled / allow_run (the kill switch: the tool is
   never built when off) / run_requires_approval / bypass_allowed — and Tools —
   the tools.disabled list. Capabilities renders bespoke panels, so the fields
   surface as QuickSetting chips on the Tools panel (the ADR 0048 §2.2 pattern
   Skills/MCP already use). All hot-reload: a save rebuilds the graph.
   filesystem.projects now round-trips through config_to_dict §B like the
   other registries.

Tests: test_tools_disabled.py pins each seam (fs / extra / delegation) + the
config-driven sync; settings-schema test pins the new fields, their cascade of
depends_on gates, and the Capabilities routing; roundtrip golden extends
automatically (FIELDS-driven) + filesystem_projects joins the legacy emitted
set; two e2e specs drive both chips end-to-end (mock fixture gains the fields).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…rds + quick-add (no tabs) (protoLabsAI#1610)

* feat(console): Work surface — card-first overview navigation, live cards + quick-add (no tabs)

Kill the Work hub's tab strip: the landing is always the Overview, now four
live cards (Goals · Watches — previously missing — · Tasks · Schedule) in a
responsive auto-fill grid. The whole card is the navigation (role=button,
Enter/Space, selection-guarded, raised-card hover per the chat report card);
nested views render the unchanged panels under a slim "← Overview" back bar,
with Escape backing out when no overlay is open.

Card anatomy: icon + name + 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" — pure helpers in workOverview.ts, unit-tested);
a StatusDot micro-list; and a corner "+" quick-add that stops propagation.
Empty cards render the DS Empty with the quick-add as the CTA — except
Watches, which has no quick-add at all (agent-created; the empty copy says so).

Quick-add reuses the panels' creators, one form two hosts: the Goals inline
<details> form is extracted into an exported GoalCreateDialog (DS Dialog) used
by both the Goals panel's new "New goal" header action and the overview;
TaskCreateDialog and ScheduleModal are exported and reused. Payloads and
endpoints unchanged.

Liveness: one surface-level SSE subscription set in WorkPanel (goal.changed/
goal.iteration, watch.changed/met/expired/stalled, task.changed,
scheduler.fired) so every card stays fresh whichever view is mounted; the
panels keep their own subscriptions (double-invalidate is a no-op). Schedule
has no push for agent-side add/cancel, so the overview's read keeps a gentle
per-use 60s refetchInterval (not on the shared schedulesQuery factory).

e2e: tasks/schedule/navigation specs now navigate via overview cards (the
.pl-tabs__select strip is gone); new work-overview.spec.ts covers counts +
pulses, click-through + back + Escape, tasks quick-add updating the card via
a stateful page.route, the Watches no-quick-add rule, and both empty-state
paths; mock server gains a seeded GET /api/watches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): watches met-today keys off finished_at + Goals-panel host e2e

Review fixes for the card-first Work overview:

- watchesPulse counted "met today" from last_checked, but the watch
  controller's met path (_finish) sets finished_at and returns BEFORE the
  last_checked=now write — a watch met on its first check has no
  last_checked at all, and one met later carries the PREVIOUS check's
  time (possibly yesterday). Key off finished_at with a last_checked
  fallback for older payloads; add finished_at to the console WatchState
  (it's in the backend to_dict), mirror the real write order in the e2e
  WATCHES fixture, and pin the first-check-met / stale-pre-met-check /
  legacy-fallback cases in unit tests.

- New e2e: the Goals PANEL's "New goal" header action (the other host of
  the lifted GoalCreateDialog) — Tasks/Schedule panel create flows had
  specs, the reworked Goals one didn't. Asserts the dialog gates on a
  condition, POSTs the same {session_id, condition, verifier} payload to
  /api/goals as the old inline form, closes + toasts on success, and
  never navigates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…Discord (protoLabsAI#1516) (protoLabsAI#1615)

The in-app updater card and the Discord release embed diverged because they had
two different writers: Discord = LLM-themed notes from the commit range (external
release-tools action), in-app = the raw human-curated CHANGELOG.md section baked
into latest.json. protoLabsAI#1516 wants one source, one voice.

release-tools already writes its generated notes to a file (`out-file`) in the
SAME run that posts the Discord embed — so this is pure wiring, no external change:

- release.yml: the "Generate + post release notes" step now writes `release-notes.md`
  and uploads it as a release asset (guarded/best-effort — a fork with no gateway
  key writes no file).
- desktop-build.yml: the latest.json fan-in prefers that `release-notes.md` asset
  for the updater notes, falling back to `changelog.py notes` (curated CHANGELOG),
  then the release body, then a placeholder.

So the in-app "what's new" now renders the identical themed prose as Discord.
Takes effect from the next release. Workflow-only; YAML validated.

Closes protoLabsAI#1516

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…e icons (protoLabsAI#1614)

Two console polish items from local testing:

- The Memory surface renders its DS Tabs as a direct child of the
  .stage-panel grid, so the strip landed in the default template's
  minmax(0, 1fr) row and stretched to fill the whole panel (tabs floating
  mid-panel, body squeezed to the bottom). Give the surface a .memory-panel
  row template — auto header, auto tab strip, 1fr scrolling body — the same
  shape .settings-panel uses for its sub-nav.
- Work overview cards: drop the lucide icon next to each card title; the
  header is now just the name + count badge.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…oLabsAI#1616)

The DS (@protolabsai/ui .pl-markdown) styles GFM tables with a fixed 0.85em /
5px·10px and horizontal-scroll-on-overflow — fine in the wide doc viewer, cramped
in a narrow chat column where a multi-column table just scrolls.

Make tables mobile-first and respond to their CONTAINER (the markdown column
width) instead of the viewport, via a container query on the streamdown
table-wrapper: compact type/padding at the narrow base (DS overflow-x scroll kept
so nothing clips), roomier at 30rem/48rem container breakpoints. The same table is
now compact in a narrow chat panel and comfortable in the doc viewer, automatically,
wherever markdown renders.

Console-side override (DS 0.52 owns the base rules; the `.markdown` alias rides the
same element and theme.css loads after the DS styles, so it wins by cascade). The
local DS repo is stale (0.47, no markdown.css yet), so this is the interim override
— fold back into @protolabsai/ui markdown.css on the next DS sync.

Console build clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…-transport race (protoLabsAI#1600) (protoLabsAI#1619)

test_install_deps_rejects_non_pep508_requires_pip[git+...] failed
intermittently on CI runners in its SETUP clone of the local fixture repo:

  fatal: failed to copy file to '.../objects/pack/tmp_rev_XXXXXX':
  No such file or directory

Root cause (reproduced locally): `git clone <local path>` uses git's
"local transport" — a readdir+copy of the source's .git/objects (which
also silently ignores --depth). `git commit` spawns a DETACHED
`git maintenance run --auto --quiet --detach` in the just-created
fixture repo; when that background maintenance renames a tmp_rev_* pack
temp file between the clone's readdir and copy, the copy dies with
ENOENT. Churning tmp_rev_* files in objects/pack while cloning
reproduces the exact CI fatal 2/60 runs via a plain path and 0/60 via
file://.

Fix (no retries):
- installer._clone(): rewrite a plain local path to a file:// URL, so
  git uses the regular pack transport (upload-pack streams a fresh
  pack; nothing enumerates the source's raw object files) — exactly
  what git's own warning recommends. Deterministic for production
  local-path installs too, and --depth 1 is now honored.
- test fixtures: _git() passes -c maintenance.auto=false -c gc.auto=0
  so fixture repos never spawn the detached background writer at all.

Stability: affected param test 20/20 green, both installer test files
20/20 green, full suite 2795 passed.

Closes protoLabsAI#1600

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… flex-col fallback) (protoLabsAI#1618)

* fix(chat): code blocks stack vertically — language header on top, body scrolls

streamdown lays the code block out as `flex flex-col` (language HEADER above the
code body), but the console build only generates the `flex` Tailwind utility, not
`flex-col`. So the block fell back to flex-ROW: the language label rendered as a
cramped vertical column on the LEFT of the code, and the row grew past the message
width (the overflow protoLabsAI#1593 tried to tame).

Force the intended vertical stack on `[data-streamdown="code-block"]`
(flex-direction: column) + `min-width:0; max-width:100%` on the block and body, so
the language sits on TOP as a header and a long line scrolls INSIDE the block
instead of stretching the panel. Applies wherever markdown renders (chat, reports,
memory, doc viewer), not just .chat-session-slot.

Verified headless (playwright against the preview build): code-block flex-direction
flips row→column, language moves to a top header, no page-level horizontal overflow
at a narrow width. DS-owned — the `[data-streamdown]` selectors should set this;
folding back into @protolabsai/ui is queued on the DS side.

Console build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chat): zero the min-width floor so code blocks let the panel go narrow

The real bite behind "code blocks pin the panel to ~half width": a wide code line's
min-content propagated up the message chain and floored the whole convo — so the
resizable chat panel couldn't slide narrow, and past a point the block just overflowed.

Walking the ancestor chain at a 480px width showed the code line (1633px) forcing every
level from .pl-convo-wrap down to be 1633px wide, because .pl-convo-wrap / .pl-convo-scroll
/ .pl-message are flex/grid items defaulting to min-width:auto (min-content). The DS zeroes
.pl-convo and .pl-message__body but misses those three, so the floor leaked through them.

Zero their min-width so the constraint chain reaches .chat-session-slot (the grid correctly
sized to the panel) and the overflow lands in the code body's overflow-x:auto. Verified
headless: at a 480px window the code body is now 444px (was 1633) with content scrolling
internally, and nothing exceeds the window — the panel slides to mobile width cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chat): markdown tables stack too (same streamdown flex-col fallback)

The table-wrapper hits the identical bug as code blocks: streamdown emits it as
`flex flex-col` (a copy-action row above the table), but the console build only
generates `flex`, not `flex-col`. So it fell back to flex-ROW — the copy cluster
sat BESIDE the table, and in a narrow panel the crushed table wrapped cell text one
character per line ("M/a/r/k/d"). This also corrects the protoLabsAI#1616 container-query table
change, which added the responsive padding but not the stack.

Force flex-direction: column on [data-streamdown="table-wrapper"] (+ min-width:0 /
max-width:100% on it and its children) so the copy cluster sits on top, the table is
full-width, and a wide table scrolls inside its own overflow-x:auto. Verified headless:
at a 460px panel the wrapper is flex-column, fits the panel, no page overflow, table
renders as a normal 3-col grid instead of per-char wrapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chat): GFM task-list checkboxes + readable table copy-format menu

Two more markdown rendering bugs found while testing:

Task lists: the item showed a disc bullet AND the checkbox with the label on the
next line. Root cause wasn't streamdown — it's the console's global
`input,textarea,select { width: 100% }` blowing the markdown checkbox to the full
row width, shoving the label down. Pin the task-list checkbox to width:auto and drop
the disc marker; checkbox + label now sit inline. Scoped to .task-list-item so plain
/ ordered lists (which render fine) are untouched.

Table copy menu: streamdown's "copy as" dropdown (Markdown/CSV/TSV) is styled with
inert Tailwind (min-w-[120px], px-3, w-full), so the menu collapsed to ~22px and the
labels wrapped one character per line (unreadable). Give it a readable surface +
real menu items (min-width, padding, nowrap, hover). Verified headless: menu is now
148px, task-list checkbox back to intrinsic size with the label inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chat): anchor the table copy-format menu on-screen (keep all formats)

Now that the menu is a readable 150px, streamdown's inline left offset (its
right-0/top-full Tailwind is inert) shoved it off the wrapper's right edge and
out of view, and the wrapper's overflow:hidden would clip it anyway. Anchor the
menu explicitly (top:100%; right:0; left:auto !important — beats the inline style)
and set the wrapper overflow:visible so the dropdown can escape (the inner table
scroller keeps its own overflow-x:auto + radius, and the p-2 padding means the
wrapper clip wasn't needed for corners). Menu now sits under the button, fully
on-screen, with Markdown/CSV/TSV all visible. Verified headless at 760px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…+ settle bubbles (protoLabsAI#1617) (protoLabsAI#1620)

The composer Stop only worked when the ChatSurface slot had started the
turn itself: it cancelled `taskId` state (set by this instance's send
loop) and aborted its own fetch controller. A slot rendering a turn it
did not start — after a reload, a remount, a turn launched from the ⌘K
palette / Work-surface quick-add, or on desktop where the Tauri relay
pumps frames into the shared store and ignores the abort signal — had
neither, so Stop was a silent no-op: no CancelTask on the wire, nothing
aborted, tokens kept streaming.

Server-side cancellation is healthy (verified live against an isolated
instance: CancelTask lands in ~10ms mid-reasoning and the stream closes),
so the fix is entirely client-side:

- stop() aborts any locally-owned stream FIRST (the cancel RPC must never
  gate UI release), resolves the task to cancel from the slot's live
  taskId OR the streaming message's durable taskId (the same field the
  self-heal reconciler uses; every send path stamps it), settles every
  bubble left `streaming`, then sends CancelTask.
- stopTurn.ts: resolveStopTarget + finalizeStoppedMessages extracted pure
  + unit-tested; chat-stop.spec.ts proves the full wire (Stop click →
  CancelTask RPC with the turn's task id → thread settled).

Closes protoLabsAI#1617

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…bsAI#1622)

While a request_user_input / ask_human / approval interrupt is pending, a
fresh operator message used to invoke the parked LangGraph thread — which
abandons the interrupt (dangling tool_call, form unresolvable) and lets the
model see the message BEFORE the form answer (protoLabsAI#1560).

The hold lives at turn entry (server/chat.py::_hold_if_hitl_pending, called
inside the per-thread lock by both the streaming and non-streaming drivers),
not at the SteeringMiddleware fold seam: a parked graph makes no model calls,
so the fold seam never runs while a form is pending — the interleave happened
at turn entry. Unmarked messages are parked in the existing steering queue
and the caller re-parks on the same form payload; the operator's actual
answer (new `hitl_resume` message metadata from the console, or an A2A
message/send on the parked taskId) now resumes the interrupt properly via
Command(resume=…). Held messages fold in at the first model call after the
form resolves — immediately AFTER the form response, in arrival order.

Console: sends while a form is open queue as steers (same pending-bubble +
cancel affordances), turn-end reconcile keeps unconsumed steers queued while
a form is pending instead of re-sending them as a fresh turn, and form
submit/dismiss are marked hitl_resume (streaming metadata + /api/chat
fallback body).

Edge cases: dismissal is also a marked resume, so held messages release on
cancel (no deadlock, nothing dropped); the pending-form state is re-read from
the durable checkpoint every turn, so a restart can't strand the hold; with
no pending interrupt every path is unchanged; autonomous turns stay exempt
(they must never park).

Closes protoLabsAI#1560

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The Docker hardening profile lived at the repo root but is only consumed
host-side by docker-compose's security_opt — it belongs with the other
deployment assets in deploy/. Updates the compose path, the .dockerignore
exclusion, and the stale compose header comment (the Dockerfile never
shipped the profile; Docker reads it from the host at compose time).

Verified with `docker compose config` — resolves cleanly.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…kdown overrides (protoLabsAI#1626)

0.53.1 folded the markdown-chrome fixes into the DS (protoContent#382): the
code-block + table vertical stack (flex-direction: column, owned in the DS now,
not relying on the consumer generating `flex-col`) and the container-responsive
tables. So the console overrides for those are gone (-87 lines in theme.css).

What STAYS — three console-specific gaps the DS package can't see:
- Task-list checkbox `width: auto` + `list-style: none` — the console's global
  `input,textarea,select { width: 100% }` blows the markdown checkbox to full
  width (the DS has no such rule), and the DS leaves the disc marker on.
- Table copy-format dropdown (Markdown/CSV/TSV) — streamdown's inert Tailwind
  collapses it + an inline offset drifts it off-screen; needs a readable surface,
  explicit anchor, and wrapper `overflow: visible` to escape the DS's clip.
- The `.pl-convo-wrap`/`.pl-convo-scroll`/`.pl-message` min-width:0 chain in
  chat.css — the DS ai.css still only zeroes `.pl-message__body`, so a wide code
  line would re-floor the panel. (chat.css unchanged.)

Verified headless against DS 0.53.1: code blocks stack + scroll internally + panel
narrows to mobile; tables render full-width + responsive + copy menu readable
on-screen; task lists clean. tsc clean, 299 unit tests pass, build clean.

DS bump via surgical package-lock edit + npm ci (npm install won't upgrade a
resolved DS — see the recurring gotcha).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…xists there (protoLabsAI#1628)

v0.81.0's release run warned "Unexpected input(s) 'out-file'": release.yml
passes out-file (protoLabsAI#1615, the release-notes single-source) but the action pin
was still v1.1.0, which predates that input — so the notes asset silently
never materialized (the guarded upload step just skipped). v2.4.0's action
accepts out-file; the underlying CLI was already fetched @latest, so the
composite input schema was the only stale layer. v0.81.0's asset was
backfilled by generating the notes locally with the same CLI.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…e height (protoLabsAI#1629)

Two issues in the contextual QuickSetting dialogs (e.g. Settings ▸ Tools ▸ "Shell &
filesystem tools"):

1. Field descriptions crammed into a ~210px sliver — the .setting-row base is a grid
   reserving 220-320px for the control (right for a wide-page text input), which starves
   the meta column in the 460px dialog, so a long description wrapped one-word-per-line and
   towered. The dialog override MEANT to stack (per its comment) but `flex-direction: column`
   is inert on a grid. Make it a real flex column → label + FULL-WIDTH description, then the
   control. Descriptions go from ~15 lines to ~3.

2. The dialog stretched to nearly full viewport height (the DS `.pl-dialog` max-height is
   ~100vh), towering over the settings surface it popped from. Cap it at min(72vh, 620px);
   the DS body already scrolls (overflow-y:auto), so overflow scrolls inside the cap and the
   footer stays fixed. Two-class selector `.pl-dialog.quick-setting-dialog` to beat the DS
   rule regardless of stylesheet load order. Scoped to QuickSetting — the main settings
   overlay keeps its height.

Verified headless (Settings ▸ Tools ▸ fs dialog): desc width 210→450px, dialog height
913→618px with the body scrolling + footer pinned. Console build clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…or chip removed) (protoLabsAI#1630)

* feat(tools): per-row on/off toggles in the Tools panel — the denylist editor chip is gone

Every row in Settings ▸ Capabilities ▸ Tools now carries a switch that edits
the tools.disabled denylist (the same protoLabsAI#1612-hardened config the YAML route
writes), so turning a tool off IS disabling it — the separate "Disabled tools"
textarea chip was redundant and is removed. The Shell & filesystem chip stays:
it owns the run_command execution policy (approval, /bypass) and the coarse
kill switches, while per-tool wiring lives on the rows (run_command et al are
ordinary Filesystem rows with switches).

Backend: a toggled-off tool must stay visible or it could never be re-enabled —
create_agent_graph now stamps graph.disabled_tools (everything the denylist
dropped, collected via drop_disabled_tools' new collector param across every
assembly seam) next to bound_tools, and /api/tools lists those rows with
enabled:false plus the RAW config denylist (so a row toggle edits one name
without clobbering stale entries, e.g. a disabled tool from an uninstalled
plugin). count stays the WIRED count.

Two real bugs surfaced and fixed along the way:
- search_tools was appended AFTER the final denylist pass, so
  tools.disabled: [search_tools] silently re-bound it — now filtered too.
- the hot-reload rebuild omitted tasks_store, so ANY settings save dropped
  task_create/task_list/task_update/task_close until restart (every row
  toggle would have made those 4 rows vanish). Now threaded like the
  checkpointer/background_mgr, with a kwargs-capture regression test.

Also deflaked nesting.spec.ts: it expanded the task card mid-stream, and the
live→history regroup on settle (protoLabsAI#1272-76) remounts the toolcard and drops the
expansion — the spec now settles the turn first (was ~40% flaky under a
parallel suite).

Verified live on an isolated instance: toggle off → row stays (dimmed, "1 off"
group badge, kicker "56 wired · 1 off"), rebuild keeps all 57 rows, toggle
back on restores; e2e pins add-preserves-denylist + off-row-toggles-back-on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(tools): pin that denylisted plugin/MCP tools stay cataloged toggled-off

The row toggles cover every seam — plugin/MCP tools enter as extra_tools,
whose denylist drop feeds the same disabled-catalog collector. Pin the
contract: dropped from bound_tools, listed enabled:false, still grouped
under the owning plugin / MCP server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tools): roll back the optimistic toggle flip on save failure

The onMutate snapshot was captured but never restored — the settled refetch
usually recovers, but it can fail for the same reason the save did, stranding
the never-persisted flip in the cache (and onToggle builds the next payload
from that cache, so the stale state would compound). Both failure paths
(transport error + ok:false) now restore ctx.prev before the invalidation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The v0.81.0 sync brings upstream's golden (identity_name=protoagent,
plugins_enabled=[]); roxy's example config sets identity.name=roxy and
plugins.enabled=[delegates, portfolio].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@protoquinn

protoquinn Bot commented Jul 2, 2026

Copy link
Copy Markdown

👀 Quinn is reviewing — verdict (PASS / WARN / FAIL) + findings to follow.

@protoquinn protoquinn Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Audit — PR #3 | chore: sync fork to upstream protoAgent v0.81.0 (80 commits)

VERDICT: WARN (non-blocking — CI still running)


CI Status

  • build: 🔄 in_progress
  • Python tests: 🔄 in_progress
  • gitleaks (tree): 🔄 in_progress
  • Verify workspace config: ⏳ queued
  • A2A live smoke (lean tier): ⏳ queued
  • Web E2E smoke: 🔄 in_progress
  • Fleet integration (multi-instance): 🔄 in_progress
  • Lint (ruff + import contracts): 🔄 in_progress

Diff Review

  • 80-commit upstream sync (protoAgent v0.77.0 → v0.81.0); diff exceeds GitHub API limit (406, >20k lines). Cannot perform direct diff-based review.
  • PR description is thorough: catalogs feature categories (work surface, fleet remotes, memory hardening, background results, developer flags), documents fork-delta handling, and reports pre-merge test results (26/26 config roundtrip, 66/66 representative tests, ruff clean).
  • No unresolved CodeRabbit threads — review surface is clean.

Observations

  • GAP: Diff not accessible — GitHub API 406 (exceeds 20k-line limit). Cannot verify individual file changes against PR claims. The 80-commit upstream sync is inherently large; this Gap is expected for a fork-sync PR.
  • GAP: clawpatch structural review not available — protoLabsAI/roxy is not in the project registry. Cross-file correctness, security, and contract integrity could not be structurally verified.
  • GAP: CI non-terminal — 8 checks still running. Formal PASS/FAIL deferred until checks settle. The merge-loop will re-dispatch on CI completion.
  • LOW: Merge strategy note — PR requests merge-commit (not squash) to preserve upstream lineage. This is correct for a fork sync and aligns with best practice.

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 2, 2026

Copy link
Copy Markdown

Submitted COMMENT review on #3.

@protoquinn protoquinn Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI terminal-green, no blockers on prior review — auto-approving on green (protoLabsAI#748).

@mabry1985 mabry1985 merged commit 50b9915 into main Jul 2, 2026
9 checks passed
@mabry1985 mabry1985 deleted the chore/sync-upstream-v0.81.0 branch July 6, 2026 07:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant