Skip to content

chore: sync fork to upstream protoAgent v0.77.0 (189 commits)#3

Merged
mabry1985 merged 191 commits into
mainfrom
chore/sync-upstream-v0.77.0
Jul 1, 2026
Merged

chore: sync fork to upstream protoAgent v0.77.0 (189 commits)#3
mabry1985 merged 191 commits into
mainfrom
chore/sync-upstream-v0.77.0

Conversation

@mabry1985

Copy link
Copy Markdown

What

Syncs the leadEngineer fork up to upstream protoAgent v0.77.0 — 189 commits behind (fork point was v0.66.0). Brings in ~11 minor versions of runtime fixes and features, including the two SQLite thread-safety fixes this fork was carrying unfixed copies of:

How clean

The fork's customization is 4 commits, all additive config/scaffoldconfig/SOUL.md (Lead Engineer persona), protoagent.bundle.yaml, scripts/team-up.sh / team-down.sh, config/langgraph-config.example.yaml. The only merge conflict was uv.lock (resolved to upstream's — the fork adds no Python deps). All customizations verified present post-merge; version → 0.77.0.

Verify

  • No conflict markers anywhere.
  • tests/test_docs_plugin.py + tests/test_skill_index.py40 passed (confirms the race fixes landed).
  • Full CI runs on this PR.

⚠ Merge note

Merge with a merge commit, not squash — squashing flattens the upstream lineage and breaks the merge-base for future syncs.

🤖 Generated with Claude Code

mabry1985 and others added 30 commits June 22, 2026 07:56
…bsAI#1294)

`_theme_path()` derived the theme file straight from `_live_config_dir()`,
skipping the `_config_scope` that config YAML, secrets, and the setup marker
all pass through. So co-located instances that differ only by
`PROTOAGENT_INSTANCE` (the default instance + the `scripts/dev.sh` sandbox)
shared a single `config/theme.json` and clobbered each other's theme —
breaking the ADR 0004 isolation contract for this one store.

Add `THEME_JSON_PATH = _config_scope(_LIVE_CONFIG_DIR / "theme.json")` next to
the sibling path constants and route `_theme_path()` through it. `_config_scope`
preserves the explicit-`PROTOAGENT_CONFIG_DIR` carve-out (desktop sidecar /
fleet member: the dir is already the isolated leaf, so don't double-scope), so
the only behavior change is that `PROTOAGENT_INSTANCE`-scoped instances now get
`config/<instance>/theme.json`. Unscoped default is unchanged.

Closes protoLabsAI#1293

Co-authored-by: Josh Mabry <artificialcitizens@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rotoLabsAI#1297)

* Sync the tab favicon + theme-color with the active per-agent theme

The per-agent theme (/api/theme, ADR 0042) repaints the whole console on an
agent switch, but the browser tab didn't follow: the favicon SVG and
`<meta name="theme-color">` were both frozen at the brand default (#9b87f2),
so every agent looked identical in the tab strip and PWA/mobile chrome stayed
lavender regardless of theme.

Add `syncBrowserChrome()` in agentTheme.ts: it reads the resolved
`--pl-color-accent` and rebuilds the favicon as a recolored data-URI SVG and
updates the theme-color meta. Wire it into the existing `watchThemeChanges()`
MutationObserver — the single broadcast point that already catches agent
switches, saves/resets, and the ThemePanel's live picker edits — plus one
initial sync. A static favicon SVG can't read page CSS vars (browsers render it
in an isolated context where `currentColor` resolves to black), hence the
runtime data-URI.

Closes protoLabsAI#1295

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

* Address review: gate favicon sync to active themes + validate the accent

Fixes the Web E2E failure and CodeRabbit's MAJOR finding on protoLabsAI#1297.

- E2E (`e2e/assets.spec.ts` "favicon link resolves to the icon asset") fetched
  the `<link rel=icon>` href and broke on `Protocol "data:" not supported`: the
  unconditional initial sync swapped the static favicon to a data-URI even on a
  default, un-themed load. Now `syncBrowserChrome()` only recolors when a theme
  is actually active (`data-theme` / inline `--pl-*` overrides present), and
  restores the shipped static favicon when a theme is cleared. Un-themed loads
  keep index.html's real, fetchable asset — and the base-path regression guard
  that test exists for. 124/125 passed before; this was the one.

- Harden the color before embedding it into SVG/markup (CodeRabbit): the accent
  comes from the opaque, agent-supplied theme blob, so `safeColor()` validates
  via `CSS.supports("color", …)` + a probe element and bails on anything that
  isn't a real color, instead of interpolating it raw into `stroke="…"`.

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

---------

Co-authored-by: Josh Mabry <artificialcitizens@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rd-stop on cancel (protoLabsAI#1298)

delegate_to and the delegate health prober spawn CLI coding agents (codex-acp,
claude-agent-acp, …) over ACP. Two lifecycle bugs leaked their subprocesses until
hundreds of ppid-1 orphans piled up holding ~20 GB:

1. Teardown signalled only the DIRECT child. The backend each ACP adapter spawns
   reparented to init and survived — close() did proc.terminate() on the node
   adapter only, never its child tree.
2. dispatch() awaited a POOLED, long-lived client and never reaped it on cancel:
   stopping the turn only sent a soft session/cancel and re-raised, leaving the
   agent running ("I stopped the main thread and the delegate didn't stop").
3. The prober's probe spawns + handshakes with a 45s wait_for; a timeout cancelled
   _start mid-initialize after the subprocess was already spawned, orphaning it.

Fixes:
- Spawn ACP agents with start_new_session=True (own process group), and TERM→KILL
  the whole group in close() via os.killpg. Grandchildren die with the agent.
- Add AcpClient.kill_now() — a synchronous group SIGKILL safe to call from a
  CancelledError handler (no awaits). dispatch() now drops the pooled client and
  kill_now()s its tree on cancel before re-raising.
- _start() self-reaps (close()) if the handshake raises or is cancelled after the
  subprocess exists.
- Add coding_agent.close_all() + _drop_client(); the delegate-health surface's
  stop() drains the whole client pool on server shutdown.

Regression tests (tests/test_acp_reaping.py) spawn a real grandchild and prove it
dies with the group, that kill_now is synchronous, that a cancelled dispatch
hard-reaps + drops, and that close_all drains the pool.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…und-trip logging (protoLabsAI#1301)

Closes protoLabsAI#1300, protoLabsAI#1296.

protoLabsAI#1300 — the delegate health prober ran a real ACP session (session/new or
session/load) every 120s per acp delegate, despite the probe documenting itself
as "no session, side-effect-free". Add AcpClient.handshake() that runs ONLY the
ACP `initialize` round-trip (refactor _start(open_session=)), and point the probe
at it. A status probe is now genuinely cheap + inert — it no longer spawns a full
backend or touches persisted session state on a timer.

protoLabsAI#1296 (mode 1, respawn loop) — the ACP launch env inherited CLAUDECODE /
CLAUDE_CODE_* from a protoAgent server itself started inside a Claude Code
session, so the spawned claude-agent-acp backend hit the "inside another Claude
Code session" guard and respawned every ~2 min with no surfaced error. Strip the
whole marker family in _launch_env() (an explicit delegate-env value still wins) —
no more partial-strip footgun.

protoLabsAI#1296 (mode 2, idle freeze) — add round-trip logging (initialize OK →
session/prompt send → request_permission outcome) so a stalled prompt is
diagnosable from the server log instead of a silent hang.

Tests: handshake() opens no session (wire-level), launch env strips inherited
markers but keeps explicit ones (wire-level); existing probe tests repointed
_ensure_started → handshake. Docs: coding-agents + delegates guides, ADR 0024.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…egate env PATH (protoLabsAI#1302)

Closes protoLabsAI#1299.

Primary (Tauri): a macOS app launched from Finder/Dock/launchd inherits only
launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), not the user's login-shell
PATH — so Homebrew (/opt/homebrew/bin), nvm, Volta, and asdf bin dirs (where npx,
node, and ACP coding-agent adapters live) are invisible to the bundled server, and a
`command: npx` delegate fails with "binary not on PATH". spawn_sidecar now augments
the sidecar's PATH (login-shell PATH via `$SHELL -ilc`, plus Homebrew/local fallbacks
and the already-inherited PATH) before .spawn(). macOS-only; web/terminal launches are
unaffected.

Secondary (probe consistency): AcpAdapter.probe resolved `shutil.which(d.command)`
against os.environ only, while the real ACP spawn merges the delegate's env — so a
delegate that supplies its own PATH spawned fine yet the Test button still red-X'd it.
The probe now resolves against the merged PATH (`shutil.which(..., path=…)`), so probe
and spawn agree.

Tests: probe resolves the command against the delegate env PATH (captures the path=
kwarg). Docs: coding-agents guide (macOS PATH warning + probe-vs-spawn note),
react-tauri-ui guide (sidecar PATH augmentation). cargo check ✓.

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

The mid-turn steering ✕-cancel was shipped in protoLabsAI#1104 (backend dequeue + DELETE
/steer/{msgId}, api.cancelSteer, the wired onCancel with optimistic-drop +
turn-end reconcile) and is covered by unit tests (tests/test_steering.py,
tests/test_chat_routes.py). The one outstanding protoLabsAI#1103 acceptance item was an e2e
that actually clicks the ✕ — this adds it.

- mock-server: a "hold the turn open" prompt streams the opening frames then holds
  the SSE open (no terminal frame) so the surface stays "streaming" — the steering
  state — deterministically, instead of racing the ~40ms-gapped frames; plus a
  DELETE /steer/{msgId} handler returning {removed:true} (cancel-before-drain path).
- spec: start a held turn → queue a steer (dimmed `.pl-message--queued` bubble) →
  click "Cancel queued message" → assert the DELETE fires and the bubble is gone.

Verified locally against a fresh build: the new spec + the chat e2e batch + web
unit tests all pass.

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

Several user-facing PRs merged since v0.66.0 never added a CHANGELOG bullet, so
the Unreleased section was incomplete ahead of cutting a release. Backfill them
so the rolled changelog + marketing /changelog are accurate:

- Added: per-agent ACP launch overrides (protoLabsAI#1289)
- Fixed: ACP probe initialize-only + nested-Claude env strip + logging (protoLabsAI#1301),
  macOS desktop sidecar PATH (protoLabsAI#1302), workspace _pick_port skips OS-occupied
  ports (protoLabsAI#1290), per-agent theme instance-scoped (protoLabsAI#1294), tab favicon/theme-color
  follow the active theme (protoLabsAI#1297)
- Docs: codex needs the codex-acp adapter (protoLabsAI#1287)

Test-only PRs (protoLabsAI#1291, protoLabsAI#1303) are intentionally omitted.

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>
…ixes protoLabsAI#1167) (protoLabsAI#1306)

The Web E2E smoke job ran `playwright install --with-deps chromium` on every run
with no cache, no timeout, and no retry — so a stalled CDN download for the ~100MB
chromium binary hung the step ~10min until the job-level orphan-kill fired, a
recurring false-red that cost a manual `gh run rerun --failed` each hit.

- Cache `~/.cache/ms-playwright` keyed on the resolved Playwright version; on a hit
  skip the binary download and run only the fast apt system-deps (`install-deps`).
- Hard-cap each attempt with `timeout 240` and retry up to 3× so a stalled download
  fails fast and self-heals instead of hanging to the orphan-kill; step capped at
  `timeout-minutes: 8`.

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

Problem 1 — the Docker image never shipped the React console. apps/web/dist
is .gitignored, the Dockerfile had no Node stage, and .dockerignore dropped
the npm workspace manifests, so COPY shipped no built console and
mount_react_app silently returned False — `-e PROTOAGENT_UI=console` 404'd at
/app. Added a node:20-slim web-builder stage (npm ci + npm run build --workspace
@protoagent/web) and COPY --from into the runtime image; unblocked the package
manifests in .dockerignore (node_modules still excluded, so they never reach the
final image); and made the server warn LOUDLY when the console tier is requested
but the build is absent (was a single quiet log line).

Problem 2 — requirements-core.txt (what the image installs) drifted from the
pyproject [project.dependencies] source of truth: it had silently lost pypdf,
youtube-transcript-api, and markdown-it-py, so the prod image lacked the
ingestion deps while CI (which installs via pyproject) stayed green. Restored
the three deps and added tests/test_requirements_core_sync.py, which fails CI if
requirements-core.txt misses any core pyproject dependency.

Verified with a real `docker build` (web-builder + full image) on Docker
28.5.1: the running container serves /app (200 + HTML), /app/assets/*.js (200),
and /_ds/plugin-kit.css (200); the ingestion deps import; no node_modules leak
into the final image.

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

* perf: off-loop console handlers + inbox/activity retention (protoLabsAI#875)

Address the SAFE subset of the perf-audit tail (protoLabsAI#875):

- Item 8: `_operator_runtime_status` now awaits `asyncio.to_thread` for the
  per-poll co-location (`ps` shell-out) and fleet version-skew probes, matching
  the startup-path co-location check. The `/api/runtime/status` route awaits a
  coroutine accessor and still accepts a sync dict (test doubles / forks).
- Item 7: the inbox/activity console handlers (`_operator_activity_list`,
  `_operator_inbox_add`, `_operator_inbox_list`, `_operator_inbox_deliver`)
  offload their sync SQLite store calls via `asyncio.to_thread`, mirroring the
  scheduler/goals neighbors. Return shapes unchanged.

Item 2 (inbox/activity retention) was already shipped on main (protoLabsAI#1059); items 5
and 9 are deferred; items 1/3/4 were already done.

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

* chore(changelog): trim protoLabsAI#875 bullet — retention pruning already shipped (v0.42.0/protoLabsAI#1059)

---------

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

The utility-bar left/right panel-toggle buttons now disable (greyed out,
non-interactive, with a "No panels in the <side> rail" title) when their rail
holds no views — matching the bottom-dock toggle's existing gate, instead of
appearing active but doing nothing when clicked.

- App.tsx: gate the left/right toggles on `leftMembers.length === 0` /
  `rightMembers.length === 0` (the rail's resolved view set, already computed).
- theme.css: add a `.util-btn:disabled` rule (opacity + default cursor) and
  guard `:hover` against disabled buttons, so a disabled toggle is visibly
  greyed and shows no hover feedback (also tidies the bottom toggle).
- layout.spec.ts: assert the populated left/right toggles stay enabled and
  interactive (the gate doesn't over-disable).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…O(N²) rescan (protoLabsAI#1310) (protoLabsAI#1311)

`_chat_langgraph_stream` recomputed the visible `<output>` and the live reasoning
view by re-running regexes over the ENTIRE accumulated text on every token chunk, so
a turn streaming N chars over ~N chunks cost O(N²) (`stream_visible_output` /
`stream_visible_reasoning` over `accumulated_raw` each step).

Add `StreamingOutputView` / `StreamingReasoningView` (graph/output_format.py): same
"full visible-so-far" contract, but they scan only the newly-appended tail —
- a cheap tail-only scan for the `<output>` / `<scratch_pad>` opener (the scratch_pad
  is never re-scanned), returning "" meanwhile;
- a fast-append in the steady answer-body stream (open, unclosed, no `<` in the region);
- a fall back to the authoritative pure function on any `<` (close / `<think>` /
  `<confidence>` / partial tag) or the closed state.

The pure functions stay as the oracle: a parametrized equivalence test (curated cases
× chunk sizes 1..50) plus a 300-doc random fuzz test assert the views return EXACTLY
what the pure functions return at every growing prefix — so downstream streaming
behavior is byte-identical, just ~O(N).

Addresses the O(N²) item of protoLabsAI#1310; the goals/beads polling→event-bus item remains
(it needs backend task-change events first).

ruff + lint-imports clean; full suite (2503) + live smoke green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he 5s poll (closes protoLabsAI#1310) (protoLabsAI#1312)

The Goals and Tasks right-sidebar panels each held a `refetchInterval: 5_000`, so
the console polled goals + the tasks board every 5s per mounted panel forever. The
inbox panel already does this the right way — invalidate off an `/api/events` push.

Backend (the single store seam, so it fires for BOTH agent-tool and console-API writes):
- graph/goals/store.py: `set()` / `clear()` publish `goal.changed` (covers create,
  each goal-loop iteration, finish, and clear — the controller routes them all through
  the store) via the best-effort `HOST.publish` seam.
- tasks/store.py: `create` / `update` / `close` / `delete` publish `task.changed`
  (delete only when a row was actually removed).
Both are no-ops when no publisher is wired (unit tests / standalone) and never break a
write on a bus hiccup.

Frontend:
- GoalsPanel / TasksPanel subscribe to `goal.changed` / `task.changed` and invalidate
  their query (mirrors InboxPanel).
- queries.ts: drop the 5s `refetchInterval` from goalsQuery + tasksQuery.

Live updates are now immediate (agent files a task → it appears at once) and steady-
state polling is gone.

Tests: goal/task store publish on each mutation + no-op on a no-op clear/delete; the
goal-hooks bus test now finds goal.achieved among the new goal.changed pushes rather
than assuming it's first. ruff + lint-imports clean; full suite (2507) + live smoke +
web vitest (115) + the goals/tasks e2e batch (20) all green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nstance (closes protoLabsAI#1159) (protoLabsAI#1313)

A first-class factory-reset for the default ("prod") instance — wipe its data +
local config back to a clean slate so the next boot runs the setup wizard, for
testing the fresh-user flow. CLI-only by design; there is intentionally NO in-app
self-wipe (a running server deleting its own WAL-locked data then relaunching is
too risky — descoped per the issue discussion).

SAFE on a multi-instance machine (validated against a real ~/.protoagent with 5
sibling instance roots + dozens of scoped team leaves): it preserves EVERY other
instance — any `~/.protoagent/<name>` carrying an `.instance-uid`/checkpoints.db
(the dev sandbox, fleet members, forks) is left untouched, and inside shared store
dirs every `<store>/<instance>` leaf (a subdirectory) is preserved. Only prod's own
unscoped top-level DBs + the direct files in those store dirs are removed. Tracked
`config/` files are `git checkout`-restored to pristine; only gitignored local
config (langgraph-config.yaml, secrets.yaml, .setup-complete, plugins/) is deleted.

Flags: `--dry-run` (print the exact plan, delete nothing — ALWAYS run first),
`--yes`, `--backup` (timestamped tar.gz), `--keep-secrets` (preserve secrets.yaml +
langgraph-config.yaml so no gateway re-auth), `--include-dev` (also wipe the dev
sandbox), `--force` (SIGTERM a server still bound to :7870 — a live process holds
WAL handles). Refuses to wipe while a server is bound unless --force.

Tests: tests/test_reset_script.py drives `--dry-run` against a synthetic tree and
asserts the plan targets prod-only, preserves siblings + leaves, and deletes
NOTHING (plus --keep-secrets / --include-dev plan variants). Verified the real
deletion path end-to-end in an isolated tmp HOME. Docs: PROTO.md run-commands.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(issues): issue forms + silent validation gate

Add a GitHub issue standard and enforce it without blocking authors.

- .github/ISSUE_TEMPLATE/{bug,enhancement}.yml — issue forms whose required
  fields render as the exact headings the gate checks; auto-label bug/enhancement.
- .github/ISSUE_TEMPLATE/config.yml — keep blank issues enabled (maintainer
  escape) + contact links (CONTRIBUTING, security advisory).
- .github/workflows/issue-gate.yml — runs on issues opened/edited/reopened;
  flags non-conforming issues with `needs-info` and NOTHING else (silent, never
  comments, never closes). Removes the label on edit once the issue conforms.
  Skips issues labeled `gate-exempt`. Ensures both labels exist (idempotent).
- CONTRIBUTING.md — the issue standard, surfaced by GitHub on the New-issue page
  (the discoverable home for the silent gate's rules).
- PROTO.md — short "Filing issues" note pointing at the gate + CONTRIBUTING.

Gate contract verified against protoLabsAI#1159/protoLabsAI#1300 (pass) and a thin issue (flag);
form-filed issues always pass by construction.

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

* ci(issue-gate): annotate runs-on for the workspace-config verifier

The Verify-workspace-config check requires every workflow's runs-on to carry the
'# workspace-config: allow-hosted-runner …' annotation; issue-gate.yml was missing
it (the 1 error blocking the PR). Mirrors the other workflows in checks.yml.

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#1315)

* feat(chat): /issue — user-only GitHub issue-creation control command

Add a server-handled `/issue` chat control command (like `/goal`): the user
types it, the dispatcher short-circuits the turn, and an issue is filed via the
`gh` CLI. It is deliberately NOT a LangChain tool — the read-only GitHub tools
(plugins/github) stay agent-facing; creating an issue is a write kept in the
user's hands, so the agent can't open issues autonomously.

- tools/gh_issue.py — parse + scaffold + gate-conformance check + `gh issue
  create`. The body is validated against the SAME rules as the CI issue gate
  (>=80-char body + Problem section; bugs need repro/expected; features need
  proposed-direction/acceptance), so anything filed here clears the gate. Repo
  resolution: --repo > github.default_repo > GITHUB_DEFAULT_REPO/GH_REPO env >
  ask (no silent misrouting). --dry-run previews without calling gh.
- server/chat.py — dispatch in both the streaming + non-streaming paths.
- graph/slash_commands.py — reserve the `issue` token (can't be shadowed).
- operator_api/console_handlers.py — surface /issue in the palette.
- graph/config.py + settings_schema.py — github.default_repo (UI-editable,
  Settings > GitHub) + the three roundtrip goldens.
- tests/test_gh_issue.py — 14 unit tests (parse, conformance, dry-run, create).
- docs/guides/file-github-issues.md (+ sidebar) + README feature-map row.

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

* feat(console): New-issue form dialog for /issue + share file_issue

The console UX for the user-only /issue command. Picking /issue from the chat
composer's slash menu opens a form dialog (DS Dialog, like the add-task dialog);
inline `/issue <title> …` still works.

- tools/gh_issue.py — refactor: extract file_issue() (validate + gh create,
  returns a structured result) + labels_for()/resolve_repo() helpers, shared by
  the chat command and the new route so they can't diverge.
- operator_api/github_routes.py (new) — GET /api/github/config (default repo +
  gh availability) and POST /api/github/issue (→ file_issue). Operator-surface
  only, not an agent tool. Wired in server/__init__.py.
- apps/web NewIssueDialog.tsx + issueBody.ts — the form; buildBody() assembles
  the exact `##` headings the gate checks, so a dialog-filed issue always
  conforms. ChatSurface opens it from runClientSlash("issue"); on success a
  "✓ Filed: <url>" note is posted to the thread.
- api.ts — githubConfig() + createIssue().
- tests: test_github_routes.py (6, HTTP contract + validation), issueBody.test.ts
  (6, body assembly + completeness).
- plugins/docs/nav.json — regenerate for the file-github-issues guide added with
  the command (fixes the docs-nav-sync test).

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

* docs: fix dead link in file-github-issues guide (github plugin has no docs page)

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

---------

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

Builds on the /issue command + dialog (protoLabsAI#1315):

- Multi-repo picker. New `github.repos` config (list) drives a quick-toggle
  dropdown in the New-issue dialog; `github.default_repo` is the preselected one
  (and the command's default), falling back to the first repo when blank. A
  "Custom…" option swaps in a free-text field (inline × to return to the list)
  for one-off repos. Pairs with the portfolio manager's many-repo setup.
  `effective_default_repo()` keeps the command + dialog agreeing on the default.
- Util-bar bug action. A 🐛 button next to Settings opens the same dialog,
  store-driven (`uiStore.newIssueOpen`) so the button and the chat `/issue`
  command share one mount; on success it notes "✓ Filed … <url>" in the chat.
- Tooltip popovers. Settings, the new bug action, and the three panel-toggle
  buttons now use the DS Tooltip (like the Inbox/Activity widgets) instead of a
  native title.
- Settings fix. The GitHub section was orphaned (unmapped sections default to the
  "Plugins" category, which only renders installed-plugin config). Route it to
  System so Settings ▸ System ▸ GitHub renders the repos list + default editor.

Config roundtrip goldens + tests updated; docs (guide + README) refreshed.

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

The last util button still on a native `title` — give it the same hover popover
as the Inbox/Activity/Settings/issue actions, with a stateful label (N running /
N finished / idle).

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

Adopts the DS fix (protoContent#290 → ui 0.46.0): Tooltip is now Radix-backed and
collision-aware, so the utility-bar tooltips (Settings, /issue, background-agents,
inbox, activity, panel toggles) auto-flip/shift to stay within the viewport instead
of clipping at the bottom corners — no per-button `side` workaround needed.

- apps/web/package.json: ^0.45.0 → ^0.46.0 (pulls @radix-ui/react-tooltip transitively).
- e2e: the DS Tooltip is now portaled + shown on hover, so the plugin-view utility
  widget test hovers the pill and asserts the page-level `role=tooltip` (was a static
  child of `.pl-tip-wrap`).

Verified: tsc clean · web unit 121 · web e2e 127 · build OK.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ls into a chip (protoLabsAI#1319)

* fix(graph): failed/cancelled task delegations close as error cards

A subagent crash returned an 'Error: …' string and a user-cancelled delegation
returned a '[cancelled…]' string — both rode the green 'done' card because the
tool-call frame's error flag is read from the ToolMessage status, which a plain
string never sets. Now _run_subagent raises SubagentError on a hard failure and
the task tool converts failure + cancellation into ToolMessage(status='error'),
so the card shows the X (the lead still gets a readable 'continue without it'
result). task_batch reports a failed sub-delegation inline and continues.

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

* feat(web): fold settled tool cards into a summary chip + label subagents

Spotlight active work: running top-level tool cards render full; finished ones
fold into one expandable 'N tools' chip (aggregate status → 'N failed' when any
errored), so a fan-out turn no longer buries the answer under a wall of cards.
Once the turn ends nothing is running, so the whole run collapses into the chip.
A 'task' card now shows which subagent ran ('task → researcher', read from the
call args) instead of a bare 'task'. ToolCardSummary is a thin local mirror of
the proposed DS primitive (protoContent#292) — swaps to @protolabsai/ui on release.

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

* test(graph): document subagent tool frames don't nest live (audit #4)

A subagent's own tool calls DO propagate into the parent turn stream, but the
task tool's on_tool_end is emitted before them (the delegation is detached via
ensure_future for Tier-2 cancellation), so the console's 'last open task wins'
nesting can't attach them — subagent tools render as top-level cards, not nested.
Pins the ordering so a future parent-id linkage fix trips this test.

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

* fix(web): only fold at ≥2 settled tools; keep single tools inline

E2E caught two things: (1) a lone finished tool folding into a '1 tool' chip hid a
useful card and broke the single-tool specs — fold now engages only at ≥2 settled
cards; (2) splitting running-vs-settled into separate lists remounted a card when it
settled (losing its expanded state) — so the no-fan-out path now renders every card
inline in emission order, identical to before. Adds a FANOUT mock scenario + e2e
asserting the ≥2 fold collapses to a '2 tools' chip that expands to the cards.

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

* fix(web): summary chip shows a resting border + a running total of the block

The fold chip now reads as a card at rest (solid border + raised fill, not just on
hover), and its count is the running total of ALL tool calls in the block (running +
settled) so the tally ticks up live as tools fire rather than only counting what's
folded. DS chip border mirrored in protoContent#292.

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

* feat(web): pinned-spotlight tool block — stable height, no bounce

The tool block bounced as cards popped in/out mid-turn (each running tool mounted a
row, then unmounted into the chip; the chip popped in at the 2nd tool) and the chat
auto-scroll reflowed on every change. Now, while the turn streams, the active card(s)
sit in a fixed-height '.tool-spotlight' slot that reserves one row and never collapses
between tools, and everything finished folds into the running-total chip below — so the
block holds a stable height for the whole turn and a finishing tool just crossfades from
the slot into the chip (reduced-motion respected). Settled state is unchanged: a lone
tool inline, a fan-out folded. Threads message streaming-status into ToolCalls.

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

---------

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

@protolabsai/ui 0.46.0 → 0.47.1 (now ships ToolCardSummary). ToolCalls imports it
from @protolabsai/ui/tool-card; the local mirror (ToolCardSummary.tsx) and its
duplicate .pl-toolcard-summary* CSS are deleted — the DS styles bundle supplies the
chip frame (incl. the resting border). Host-only .tool-spotlight/.tool-subagent stay.
Completes the contribute-back loop; rendering is unchanged (e2e green).

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

The console nested a subagent's own tool cards under the `task` card via a timing
heuristic ('last open task wins'), which breaks when the delegation's on_tool_end races
ahead of the subagent's child frames (the delegation runs detached via ensure_future) and
mis-attributes concurrent task_batch delegations. Now the subagent's run is tagged with
the delegating tool-call id (`_run_subagent` sets it as run metadata → propagates to every
child event); server/chat.py emits it as `parentId`, the A2A executor rides it on the
tool-call DataPart as `parentToolCallId`, and the console nests by that id (heuristic kept
as a fallback for older servers). task_batch gains an InjectedToolCallId so its sub-
delegations nest too.

Tests: nesting integration test now asserts the wire linkage; new e2e proves the child
nests even when its frames arrive after the task closes; task_batch tests invoke via the
tool-call envelope.

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

Comments out the show_component tool (inline component rendering, ADR 0051) and removes
it from get_all_tools(), so the agent can't call it and it's gone from the console Tools
tab. The component-v1 pipeline (graph/components.py codec, the server/chat.py sentinel
extraction, the console component registry) is left intact — re-enabling is just
uncommenting the tool. Tracked by protoLabsAI#1323. TestShowComponentTool skipped (codec tests stay).

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

* fix(web): collapse a delegation's nested tools (stable height, no bounce)

The subagent-nesting fix put the subagent's tools in the DS always-on `nested` rail, so
the task card GREW as each child streamed in and then COLLAPSED when it folded into the
chip — the up/down bounce the user reported. Now the nested tools live in the card's
COLLAPSIBLE body (revealed on expand) and the header shows a running count
('task → researcher · 3 tools'), so the card holds a stable one-row height while the
subagent works. Nesting is unchanged (still by explicit parent id); only its default
visibility moved from always-on to on-expand.

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

* fix(web): keep the most-recent tool in the slot until replaced (no blank gap)

The spotlight reserved an empty row whenever no tool was running — a blank gap between
tools and during the answer-streaming tail. Now the slot holds the LAST tool that ran
(running or just-finished) until a newer one replaces it, so it's never empty; the
previous tool drops into the running-total chip as the new one crossfades in.

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

---------

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

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>
…protoLabsAI#1328)

* feat: native reasoning — drop the scratch_pad/output protocol

Stream the gateway's native reasoning instead of a prompted <scratch_pad>:
- _ReasoningChatOpenAI (graph/llm.py) lifts the gateway's reasoning_content delta into
  the message's additional_kwargs (langchain-openai drops it by design — 'use a
  provider-specific subclass'). The model reasons natively; nothing is forced.
- server/chat.py streams that reasoning_content on the reasoning channel and the model's
  content directly as the answer — the <scratch_pad>/<output> parsing + StreamingViews
  are gone. extract_output stays as a terminal fallback (still strips any stray tag).
- graph/prompts.py drops the protocol from the lead + subagent prompts.

The model now behaves natively (reasons, answers, calls tools) with no text protocol.
extract_output's pass-through fallback keeps the non-streaming path backward-compatible.

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

* feat(web): render reasoning as a collapsed tool-style card

The native reasoning blocks rendered as big always-expanded boxes (the DS Reasoning
component auto-opens and locks open while streaming) — cramped and distracting between
the tool cards. ReasoningCard now renders each reasoning run as a DS ToolCard: same
chrome, COLLAPSED by default, a spinner while the model is still thinking (done-glyph
hidden — reasoning isn't pass/fail), stacking evenly with the tool cards. Expand to read.

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

* feat(web): fold an agentic turn's reason→tool timeline into one WorkBlock

With native reasoning interleaved between every tool step, a turn exploded into a
forever-stack (reasoning → tool → reasoning → tool …) burying the answer, with bogus
per-fragment 'N tools' chips. WorkBlock now folds the whole intermediate timeline behind
ONE collapsed disclosure ('Working… / Worked · N tools') and renders the trailing answer
prominently below it — expand to replay the timeline (collapsed reasoning + flat tool
cards). Only engages when a turn interleaves reasoning WITH tools; tool-only /
reasoning-only / plain turns keep their inline rendering (so the e2e mocks are unchanged).

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

* feat(web): keep the latest tool exposed below the WorkBlock while streaming

The WorkBlock folded the WHOLE timeline even mid-turn, so you couldn't see what the agent
was doing. Now while streaming it spotlights the most-recent tool below the 'Working… · N
tools' summary (kept until a newer tool replaces it) — the block isn't fully collapsed
while it works. On completion it all folds into the one block, answer below.

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

* feat(web): render chat markdown with streamdown (streaming-hardened)

Swap the assistant-message markdown renderer from react-markdown + remark-gfm +
rehype-highlight to **streamdown** — a renderer built for streaming AI output. It
HARDENS incomplete markdown (a half-written code block / table / link no longer flashes
broken mid-stream) and memoizes blocks instead of re-parsing the whole answer on every
streamed token. XSS-safe (rehype-sanitize/harden); Shiki highlighting + a code-block
copy button; mermaid/katex support.

- Markdown.tsx: render via <Streamdown>; LazyMarkdown still code-splits it out of the
  initial chunk (streamdown pulls Shiki/mermaid/katex — heavy).
- tailwind.config.cjs: scan node_modules/streamdown/dist so streamdown's utility classes
  (code-block chrome, emphasis) generate; the existing shadcn→token bridge themes them
  on-brand (ADR 0037 already maps --muted/--border/--primary/etc. to --pl-* tokens).
- main.tsx: import streamdown/styles.css (its stream-in animations) before the app theme.
- theme.css: drop the dead rehype-highlight `.hljs-*` token rules — Shiki emits inline
  colors. The `.markdown` typography stays, so common text is visually unchanged.
- chat.spec.ts: streamdown renders inline emphasis as `[data-streamdown="strong"]`
  (styled span) rather than <strong>; align the selector. Block tags (h2/li/pre/code)
  stay semantic and assert unchanged.

DEPENDENCY RESOLUTION (.npmrc + root @types/react pin):
A transitive dep (@docsearch/react, VitePress docs site) pins react@18 while the console
runs react@19. npm's strict peer resolver ERESOLVE-rejects that valid nested split when
re-resolving the tree to add streamdown, so add `.npmrc legacy-peer-deps=true` (so
`npm install` and CI's `npm ci` agree). Legacy mode would otherwise drop the root
@types/react@18 the console's tsc resolves for the global `JSX` namespace, so pin it
explicitly in the root package.json devDependencies. streamdown deduped onto react@19;
react/react-dom/vite/ui versions unchanged (lockfile churn is legacy-mode reserialization).

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

* fix(web): stop the WorkBlock spotlight strobing on rapid tool fan-outs

The spotlight slot rendered its current tool via `group(call)`, which keys by tool id —
so every new tool (e.g. each of task_batch's 100+ children) REMOUNTED the card, replaying
its mount animation and flashing the previous tool's output for a frame before the next
took over. On a big fan-out this strobed.

Add a `spotlight` mode that renders only the most-recent tool under a STABLE key
(`__spotlight__`), so React updates one card in place — name/status/output swap smoothly,
the entrance animation fires once. WorkBlock's spotlight uses it; the inline streaming
spotlight is switched to the same stable key.

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

* perf(tools): offload blocking web_search/fetch_url work off the event loop

A fan-out research turn (task_batch → N parallel researchers) pegged the server at ~99%
CPU and made it UNRESPONSIVE — even the cancel/tasks API couldn't answer. Root cause: two
tools did synchronous work directly on the asyncio event loop, so under concurrency they
serialised on the loop and starved everything else:

- web_search: `ddgs.text()` is a blocking network call — wrap it in `asyncio.to_thread`.
- fetch_url: `_extract_text_from_html` (BeautifulSoup over up to 2MB) is CPU-heavy and
  synchronous — offload it too. The httpx GET was already async.

Now the loop stays free to service other subagents' IO, SSE streaming, and cancellation
while a search/parse runs in a worker thread. No change to research depth or output — the
detailed reports are unaffected; the server just stops wedging under load.

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

* feat(web): tally reasoning + tool + skill work in the WorkBlock header

The "Working…/Worked" card counted only tools ("· N tools"). Replace that with an
icon-and-count tally of the turn's actual work, in the DS ToolCard name slot:

- 🧠 reasoning steps (reasoning parts in the timeline)
- 🔧 tool calls (timeline tool ids, minus skill loads)
- 📚 skill loads (`load_skill` calls, broken out; the skill name rides its JSON input)

Each kind shows only when present. Hovering the header opens a DS Tooltip breakdown —
"2 reasoning steps", "5 tool calls · web_search ×3, fetch_url ×2", "1 skill load ·
deep-research" — so the collapsed card still tells you what happened without expanding.

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

---------

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

Renders a MARKDOWN_SMOKE fixture exercising every markdown construct (headings, inline
emphasis, nested lists, task lists, blockquotes, Shiki code, mermaid, KaTeX math, tables,
images, footnotes, hr) through the real streamdown pipeline. Hard-asserts the structure +
the interactive chrome the DS must theme (code-block copy button, table wrapper), and
annotates the harder constructs' current render state instead of flaking — plus saves a
screenshot of the rendered message for the brand/style audit.

Findings it pins (feeding protoContent#297/protoLabsAI#298):
- table chrome: 3 off-brand action buttons render (protoLabsAI#297)
- task-list checkboxes: render OK
- KaTeX math: 0 nodes — math is NOT wired in our streamdown setup (protoLabsAI#298)
- mermaid: 0 SVG — diagrams fall back to a code block, NOT wired (protoLabsAI#298)

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

The DS now ships a brand-styled streaming-markdown renderer (`@protolabsai/ui/markdown`,
0.48.1), the DS-owned version of what the console hand-rolled with streamdown (protoLabsAI#1328). It
themes BOTH prose and streamdown's interactive chrome (code/table action buttons) to
`--pl-*` via the stable `[data-streamdown]` hooks, wires KaTeX math, and renders mermaid as
a themed code block. Closes the contribute-back from protoContent#297/protoLabsAI#298.

Adoption:
- Markdown.tsx → `<Markdown className="markdown">` from `@protolabsai/ui/markdown`; the
  `.markdown` class rides the DS `.pl-markdown` element so existing selectors still match.
- theme.css: delete the hand-rolled `.markdown` element CSS — the DS owns prose + chrome.
- tailwind.config.cjs: drop the streamdown dist `@source` scan — the DS markdown.css themes
  the chrome via `[data-streamdown]` in plain CSS, no Tailwind reliance.
- main.tsx: import `katex/dist/katex.min.css` (math glyphs); `streamdown/styles.css` stays
  for the streaming token-fade. `@protolabsai/ui/styles.css` now carries `.pl-markdown`.
- Chrome defaults to copy-only (download/fullscreen off for a chat bubble — protoLabsAI#297); mermaid
  stays a themed code block (DS `renderMermaid` is opt-in — it's heavy).
- markdown-smoke.spec.ts: now asserts KaTeX math renders + the DS chrome; the prior gaps
  (math unwired, 3 off-brand buttons) are resolved.

Dependency plumbing (the awkward part):
- 0.48.1 lists `@types/react@^19` as a dependency and ships .tsx SOURCE our tsc compiles,
  so the tree must resolve a SINGLE @types/react or the DS source and our code disagree on
  `ReactNode`. Align the app to @types/react@19 (the DS is React-19-native): bump apps/web
  back to ^19, drop the root @18 pin, fix the 5 spots that relied on @18 (`JSX` global →
  `import type { JSX }` in ChatComponent/PluginsSurface; type the react-error-boundary
  `reset` param in ErrorBoundary/GoalsPanel/TasksPanel).
- Drop `.npmrc legacy-peer-deps=true` (it silently disables npm `overrides`) and instead
  resolve the @docsearch/react react@18-vs-19 ERESOLVE with a targeted override pointing it
  at react@19. `npm ci` validates cleanly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mabry1985 and others added 20 commits July 1, 2026 02:31
…LabsAI#1499)

Build the previously-unbuilt ADR 0030 D5 slice so a monitor-mode goal is
no longer an unbounded forever-tick:

- deadline (ISO-8601 or epoch): a not-met monitor goal past its deadline
  finishes with terminal status `expired`, firing on_failed + the
  goal.failed bus event like exhausted/unachievable.
- stall_after (N): after N consecutive checks with unchanged verifier
  evidence, fire a new on_stalled hook WITHOUT ending the goal — once per
  stall episode, re-armed when evidence changes — plus a best-effort
  goal.stalled bus event. A signal the external engine stopped moving.

Both are plain data fields on the goal spec (not verifiers), so the
Phase 1 chat trust-gate is unaffected. register_goal_hook (real +
testkit) gains on_stalled; parse_control/set_goal_safe accept the two
new fields; "expired" joins TERMINAL_STATUSES and the finish glyph map.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on concurrent search) (protoLabsAI#1500)

`docs_search` dispatches `DocsIndex.search` onto thread-pool workers via
`asyncio.to_thread`, but `DocsIndex` holds ONE `sqlite3.connect(":memory:",
check_same_thread=False)` connection shared across all of them. A single sqlite
connection is not safe for concurrent use across threads (`check_same_thread=False`
only silences the guard — it adds no locking). When the agent fires two docs_search
calls back-to-back, they race on the connection and corrupt cursor state, so the
computed `bm25()` column reads back as NULL → `float(None)` → the observed
`TypeError: float() argument must be a string or a real number, not 'NoneType'`
(plus intermittent InterfaceError/IndexError), which escaped the narrow
`except sqlite3.OperationalError` and surfaced to the agent as a failed tool call
with "nothing came back".

Fix + hardening:
- Serialize every connection touch (seed + search) on a `threading.Lock`. A `:memory:`
  DB can't be shared via per-thread connections (each opens its own empty DB), so a
  lock is the right tool; the corpus is tiny and in-memory, so contention is nil.
- Defense in depth: `float(score or 0.0)` guards a stray NULL, and search's `except`
  now catches any error (degrade to no-results, never raise into the tool).
- `docs_search` wraps the call and returns a graceful "Docs search failed …" message
  so the agent is told it errored instead of getting a raw traceback / empty result.
- Regression test hammers 12 threads × 200 concurrent searches: must never raise.
  Verified it fails on the pre-fix code (float/InterfaceError) and passes after.

Note: graph/skills/index.py mirrors this shared-connection pattern and is queried
every turn — same latent race, tracked separately.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion race as docs) (protoLabsAI#1501)

SkillsIndex holds ONE `sqlite3.connect(..., check_same_thread=False)` connection and
reuses it across threads. It's read on the per-turn hot path (`skill_summaries` /
`discoverable_count` via KnowledgeMiddleware, always-on since ADR 0060) while the
curator writes to it (`update_confidence`/`delete_skill`/`rebuild_index`). A single
sqlite connection is not safe for concurrent use — `check_same_thread=False` only
silences the guard, it adds no locking — so concurrent turns racing on the connection
corrupt cursor state: `cur.fetchone()` returns None mid-flight → `['n']`/`int(None)`
subscripting, plus `InterfaceError`/`IndexError`. Same class as the docs-index bug
(protoLabsAI#1500); a 14-thread reader+writer stress harness reproduces 246 errors on the old code.

Fix: a small `@_locked` decorator serializes every connection touch on a per-instance
`threading.RLock` (reentrant so `replace_disk_skills`/`rebuild_index` → `add_skill`
don't self-deadlock). Read-path `except` clauses broadened from `sqlite3.OperationalError`
to `Exception` so a stray error degrades to empty/None/0 instead of raising into the
turn. The connection is tiny + queries are fast, so serialization cost is negligible.

Regression test hammers 10 readers + 4 writers × 150 iters: must never raise (verified
246 errors → 0). Full skills suite (index/curator/persistence/slash/layered/loader/
crud/structured) green: 125 passed.

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

* feat(security): federation token + /api operator ceiling; operator goal channel (ADR 0066)

Goal trust-gate Phase 2, Option B (dedicated operator channel). Phase 1 (protoLabsAI#1492) refused
command/test/ci/data-expr goal verifiers from a /goal CHAT message for everyone — including
the operator, who lost a legitimate feature. Phase 2 restores it via a dedicated
operator-tier channel, and adds the path ceiling that makes a federation token meaningful.

- auth: optional `auth.federation_token` (env A2A_FEDERATION_TOKEN). The middleware
  classifies each request by which secret matched (constant-time compare) → operator |
  federation, and DENIES a federation credential the whole /api operator surface (403) —
  plugin install/enable, config/SOUL rewrite, subagent runs, the operator goal set-path.
  /a2a + /v1 stay open to either tier. The ceiling lives entirely in the middleware, so no
  per-request trust threading is needed (Option B's simplification over A).
- goals: GoalController.set_goal_operator accepts ANY verifier type; POST /api/goals now
  routes to it (was plugin-only). Safe because /api is operator-tier by the ceiling.
  set_goal_safe (agent/SDK/plugin) stays plugin-only.
- Backward-compatible + opt-in: no federation_token ⇒ single-token mode, byte-for-byte
  unchanged (every bearer holder is the operator; in that mode a bearer holder already has
  RCE via /api/plugins/install, so the loosened goal endpoint adds no capability — the
  ceiling is the control, per ADR D4).

Tests: federation denied /api (+ fleet-proxied variants) / allowed /a2a+/v1, operator full
access, single-token + open-mode unchanged, _requires_operator classification;
set_goal_operator accepts dangerous verifiers; config-roundtrip golden + REST-handler
contract updated. 379 passed, ruff clean.

Follow-ups (ADR 0066 slices): CLI `goal set`; console Goals set-form.

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

* docs(nav): regenerate nav.json for ADR 0066

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

* docs(adr): link ADR 0066 to federation-token follow-up protoLabsAI#1504

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

---------

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

* feat(watches): standalone watch primitive — many concurrent supervised watches (ADR 0067)

Resolves the ADR 0030 fork-in-the-road ("a separate watch primitive … open to the inverse
call"): a supervisor agent needs to babysit MANY external processes at once, which the
monitor-goal disposition can't (a goal is one-per-session). Adds a first-class `watch`.

- graph/watches/: Watch type + WatchStore (keyed by watch id, MANY per instance,
  PROTOAGENT_INSTANCE-scoped) + WatchController (create/list/clear/evaluate/tick_all),
  reusing graph/goals/verifiers verbatim. Out-of-band _watch_loop tick (server), no agent turn.
- Reaction on met (D3): enqueue an optional follow-up prompt as a one-shot agent turn via
  sdk.run_in_session (protoLabsAI#1494) + fire on_met hooks. deadline → expired (on_expired); stall_after
  unchanged checks → on_stalled once per episode (watch stays active).
- Agent tools create_watch/list_watches/clear_watch — plugin-verifier only (like set_goal);
  shell/test/data watches are operator-only (the trusted=True path, next slice's /api/watches).

watch + run_in_session compose into the parallel-supervision engine: N watches, each with its
own trip-action.

Slice 1 of ADR 0067. Follow-ups: operator /api/watches + sdk.create_watch/register_watch_hook
(PR2); console Watches panel (PR3); migrate start_goal_loop monitor path onto watches +
deprecate goal `monitor` mode.

Tests: trust gate, MANY concurrent watches, met/not-met/expired/stall/tick, the run_in_session
reaction. 15 pass (incl. nav sync); ruff + tool-inventory + import layering clean.

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

* fix(watches): use the current infra.paths API (instance_paths().store) in WatchStore

_resolve_base imported `scope_leaf`, removed by the instance-paths redesign (ADR 0065) —
so WatchStore() blew up at server boot with ImportError. Unit tests missed it because they
inject an explicit base_dir (skipping _resolve_base); live-smoke caught it (its whole point).
Mirror the current GoalStore: `instance_paths().store("watches")`, WATCH_PATH override. Add a
test that exercises _resolve_base so the boot path is covered.

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#1498)

Subscribe the console Goals panel to the `goal.iteration` bus event (in
addition to `goal.changed`) so a drive goal's `iter N/max` + last-reason row
updates live on each continuation.

The backend already publishes `goal.iteration` on the plugin bus for every
drive-goal continuation (graph/goals/controller.py — the HOST.publish block in
`evaluate`, ADR 0051 Slice 3), and the SSE bridge (operator_api/routes.py
`_sse_frames`) streams *every* bus event with no server-side topic allow-list;
the client filters by topic in apps/web/src/lib/events.ts. So the event already
reaches the browser — the panel just wasn't consuming it. This mirrors the
existing `goal.changed` subscription and invalidates queryKeys.goals so the
already-rendered iteration count + last reason refresh while the loop runs.

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

Slice 2 of the watch primitive — the operator + plugin surfaces on top of the protoLabsAI#1505 engine.

- Operator REST: GET/POST/DELETE /api/watches → _operator_watches_{list,set,clear}. POST
  accepts ANY verifier type (command/test/ci/data) via WatchController.create(trusted=True),
  safe because /api is operator-tier by the ADR 0066 path ceiling. Mirrors /api/goals.
- SDK: sdk.create_watch(*, condition, verifier, run_prompt=…, …) — plugin-verifier-only watch
  registration for plugins (hold many at once).
- Plugin seam: registry.register_watch_hook(on_met/on_expired/on_stalled) → loader collects
  PluginLoadResult.watch_hooks → agent_init set_watch_hooks (mirrors the goal-hook seam), so a
  plugin reacts in-process when a watch trips.

Tests: operator set accepts a command verifier / list / clear / disabled-when-off; create_watch
plugin path + unavailable; registry+loader hook seam. 160 pass; ruff + boot-path imports clean.
plugins guide SDK list updated.

Follow-up: console Watches panel (PR3).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a right-rail Watches panel mirroring GoalsPanel: lists passive
verifier-only watches (condition, status pill, id · verifier.type ·
last_reason meta, Clear button), live-refreshed off the watch.* bus,
consuming GET /api/watches + DELETE /api/watches/{id} (PR protoLabsAI#1507).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lenames (CodeRabbit protoLabsAI#1505) (protoLabsAI#1509)

Two findings from the protoLabsAI#1505 review:
- Race (Major): WatchController.evaluate did an unlocked read-mutate-write, so the cadence
  tick_all and an event-driven evaluate_now on the SAME watch could interleave (lost stall
  increment, or a just-finished watch re-activated / its reaction double-fired). Serialize per
  watch id with an asyncio.Lock (evaluate → _evaluate_unlocked); distinct ids never block.
- Filename collision (Minor): _safe_name mapped every unsafe char (incl. / and \) to _, so
  distinct ids like "a/b" and "a_b" shared one JSON file. Append a short hash of the raw id
  when sanitization changed it; already-safe ids stay human-readable.

Tests: concurrent evaluate finishes once (fails without the lock); no filename collision across
sanitized ids. 25 pass, ruff clean. (GoalController/GoalStore share the same latent pattern —
out of scope here.)

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

Add a compact, collapsed-by-default "New goal" form at the top of the
Goals panel. It POSTs to the operator `/api/goals` endpoint (ADR 0066),
which accepts any verifier type behind the /api operator ceiling.

- api.setGoal({session_id, condition, verifier}) → POST /api/goals,
  mirroring the existing clearGoal/POST request() patterns.
- <details> disclosure with session_id (default "operator"), a required
  condition, and a small verifier JSON textarea (default {"type":"llm"})
  parsed on submit — invalid JSON shows an inline error and blocks submit.
- Result surfaced via the shared DS useToast (success/error), goals query
  invalidated to refresh the list, condition cleared on success.

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

Watches (ADR 0067) replace the goal `monitor` disposition, so remove it entirely: a goal now
does one thing — the agent drives a bounded loop toward a verifier — and `GoalState` sheds its
union fields.

Removed:
- GoalState monitor fields (mode, deadline, stall_after, stall_streak, stalled_notified,
  last_checked) + the `expired` terminal status.
- GoalController: the monitor branch in evaluate, evaluate_now, tick_monitor_goals,
  _parse_deadline/_parse_stall_after; mode/deadline/stall_after params on parse_control /
  set_goal_safe / set_goal_operator.
- graph/goals/hooks.fire_stall_hook + on_stalled from register_goal_hook (registry/testkit).
- server _monitor_goals_loop + its task wiring; config goal_monitor_interval.
- sdk.start_goal_loop / stop_goal_loop / _to_cron (the OODA-loop helper) — create_watch is
  the replacement.
- /api/goals + /goal chat set-paths drop mode/deadline/stall_after.

Moved: the deadline/stall parsers now live on WatchController (watches use them; the
/api/watches handler updated).

BREAKING for forks that used start_goal_loop or a monitor goal → move to sdk.create_watch +
register_watch_hook. ADR 0030 superseded by 0067; goal-mode + plugins guides updated;
test_goal_monitor / test_goal_evaluate_now deleted, test_goal_loop trimmed to run_in_session.

Full suite: 2547 passed. ruff + nav-sync clean.

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

The rework (13 PRs, protoLabsAI#1491protoLabsAI#1511) shipped without CHANGELOG entries and left a few stale docs.
True them up:
- CHANGELOG [Unreleased]: watch primitive (ADR 0067), run_in_session, federation token + /api
  ceiling (ADR 0066), goal continuation tools, RCE-via-chat fix, and the BREAKING monitor-mode
  removal + CodeRabbit watch fix — grouped Added/Changed/Security/Fixed/Docs.
- New docs/guides/watches.md (+ vitepress sidebar + guides index) — the watch subsystem had no
  guide.
- README + starter-tools reference: the starter tool list now names the 3 goal + 3 watch tools
  (was just `set_goal`).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `.beads/issues.jsonl` tracker held 67 stale issues (111 across history) —
long-closed work, superseded epics, and a few desktop/CI/portfolio tasks that no
longer reflect active work. Cleared them all: `br delete --force --hard` on every id,
then `br sync --flush-only --force` to flush the now-empty DB out to the tracked JSONL
(overriding beads' empty-DB export guard). `br ready` / `br list --all` → 0.

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

Mermaid and SVG artifacts now render into a shared transform-driven
viewport instead of a static centered graphic:

- scroll-wheel / pinch to zoom, cursor-anchored (the point under the
  pointer stays put)
- click-drag to pan
- a Reset control that re-fits the diagram to the panel
- mermaid re-fits automatically after its async render (window.__artFit)

The issue framed this as "copy the SVG renderer's existing zoom/pan" —
but SVG had none either (it only did place-items:center). Both graphic
kinds render an <svg> into the sandboxed frame, so one `viewport(...)`
wrapper covers both. Self-contained JS/CSS styled off the base() token
carry — no DS stylesheet needed in the graphic frames.

Bumps the artifact plugin to v0.14.0. Adds a regression test for the
viewport wiring; the existing 2-</script> guard confirms the injected
scripts escape their close correctly.

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

The Knowledge panel's Upload (source ingest) and Add (typed entry)
buttons expanded a form inline inside the narrow sidebar panel —
cramped for file pickers / URL + metadata fields, and it pushed the
knowledge list out of view.

Wrap both in a centered DS Dialog (width min(680px,94vw)) so the forms
have room and the list stays visible behind the overlay. The form
bodies are unchanged (issue non-goal), and per-row EDIT stays inline —
it belongs next to the chunk it edits. UI-only; no API change.

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

* feat(web): expose registerKeybinding on the fork extension seam (protoLabsAI#1457)

The keybinding registry describes itself as "the fork/plugin seam,
mirroring the other src/ext/ registries" — but it was the one register*
not re-exported from src/ext/index.ts, so a fork following the ext
README couldn't `import { registerKeybinding } from "./index"` like it
does for surfaces / slash / composer / palette.

Re-export registerKeybinding + registeredKeybindings + the Keybinding
type from the seam index, and document the pattern in the ext README.

The rest of the keybinding feature already shipped in protoLabsAI#1364: registered
binds auto-appear in Settings ▸ Keyboard, are user-rebindable, fire
through the global host, and the rebind UI already blocks conflicting
combos in overlapping scopes. This completes the discoverable public
surface for the fork path.

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

* docs(adr-0061): list registerKeybinding among the fork-seam registries (protoLabsAI#1457)

The keybinding registry is a peer of registerSlashCommand /
registerComposerAction / registerPaletteCommand on the same src/ext seam
(exposed on src/ext/index.ts in this change). Enumerate it in ADR 0061
so the registry list stays complete, cross-referencing ADR 0063.

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

---------

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

↑ recalls previously-submitted messages into the composer (newest
first), ↓ walks back toward the live draft — readline/shell style.
Recalled messages are editable before resending; on submit the (edited)
text becomes the newest history entry.

- History only triggers at the edges: ↑ recalls only when the caret is
  on the first line, ↓ only on the last line — so multi-line editing
  keeps normal caret movement.
- The last 100 submitted messages persist in localStorage (one ring
  shared across chat slots, consecutive-deduped), survive reloads.
- Recording covers both send() and the mid-turn queueSteer() path.

New `chat/inputHistory.ts` (cached, crash-safe store) + unit tests, two
e2e specs (recall/walk + edit-and-resend), and a composer placeholder
hint. Full web unit suite 215 green; e2e verified locally against the
built app.

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

* docs(adr): 0068 — developer flags & the Developer panel (protoLabsAI#1506)

A design for a local/static feature-flag system (off/dev/beta/on) to
gate pre-release functionality, plus a Developer panel to view/toggle
flags. Grounds every decision in existing machinery to avoid a parallel
system: one backend registry shaped like the config FIELDS list, a
runtime channel the tier is measured against, resolution precedence
mirroring the plugin-config env>UI>default ladder, a device-local
CONSOLE_SECTIONS panel, and a remove_by cleanup contract. Articulates
why a flag is neither a plugin (load-time capability) nor a setting
(permanent user config). Status: Proposed.

Also backfills the ADR index, which had drifted (missing 0065/0066/0067).

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

* docs: regenerate nav.json for ADR 0068 (gen_docs_nav.py)

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

---------

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

The portfolio guide covered only the federated (register standing remotes) model and
omitted team spawning entirely — portfolio_spinup_team wasn't even mentioned. Add a
'Spinning up teams on demand' section (spinup/dispatch/autodispose/teardown/teams) and
document v0.14 gateway inheritance: a spawned team inherits the PM's resolved gateway +
OPENAI_API_KEY, so it runs real turns with no team_template / creds prep; spinup preflights
and fails loudly on an unreachable model.

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

protoquinn Bot commented Jul 1, 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.77.0 (189 commits)

VERDICT: WARN


CI Status

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

Diff Review

  • Diff too large for GitHub API (300+ files) — expected for a 189-commit upstream sync; cannot perform line-level review.
  • PR description is specific: claims only merge conflict was uv.lock (resolved to upstream), no conflict markers, fork customizations verified present, 40 tests pass.

Observations

  • Gap — unverified: fork customizations (config/SOUL.md, scripts/team-up.sh, etc.) could not be verified present post-merge; path_exists not available via pr_inspector. Author's claim accepted at face value pending CI.
  • Gap — structural review skipped: clawpatch unavailable for protoLabsAI/leadEngineer (not in project registry). No cross-file structural analysis performed on this 189-commit sync.
  • Gap — non-terminal CI: all 8 checks still running. Formal PASS/FAIL deferred until checks complete. If any check fails, this becomes FAIL.
  • LOW: uv.lock merge conflict resolution to upstream is the correct choice (fork adds no Python deps), but the resolution cannot be verified without the diff.

Merge note: PR description correctly recommends merge-commit over squash — squashing would flatten upstream lineage.

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 1, 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.

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

VERDICT: FAIL


CI Status

  • Python tests: failure
  • build: success
  • A2A live smoke (lean tier): success
  • gitleaks (tree): success
  • Web E2E smoke: success
  • Verify workspace config: success
  • Lint (ruff + import contracts): success
  • Fleet integration (multi-instance): success
  • deploy: skipped

Diff Review

  • 189-commit upstream sync from protoAgent v0.66.0 → v0.77.0; diff too large for automated summary (GitHub 406).
  • PR claims 4 additive fork commits preserved; only merge conflict was uv.lock (resolved to upstream).
  • Includes SQLite thread-safety fixes (protoAgent protoLabsAI#1500, protoLabsAI#1501) the fork previously carried unfixed copies of.

Observations

  • HIGH: Python tests CI check is failing. PR description claims 40 tests pass locally (test_docs_plugin.py, test_skill_index.py) but CI disagrees. Blocking — need a passing CI run before merge.
  • LOW: Clawpatch structural review unavailable (leadEngineer not in project registry) — cannot perform cross-file analysis on the 189-commit sync.
  • LOW: Cannot verify fork customizations (config/SOUL.md, protoagent.bundle.yaml, scripts/team-up.sh, scripts/team-down.sh, config/langgraph-config.example.yaml) survived the merge — diff too large for path-level inspection (GitHub 406 on diff endpoint).

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 1, 2026

Copy link
Copy Markdown

Submitted CHANGES_REQUESTED review on protoLabsAI/leadEngineer#3.

…nfig

The upstream sync pulled in the stricter config-roundtrip golden, which pins the EXAMPLE
config's parsed fields. leadEngineer's fork customizes config/langgraph-config.example.yaml
(the Lead Engineer setup), so the golden must track it: agent_runtime=acp:claude, identity=
engineering-team, plugins.enabled=[delegates,project_board,agent_browser], acp_agents.claude,
filesystem.allow_run=false + a REPLACE_ME project. 26 config-roundtrip tests pass.

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

@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.77.0 (189 commits)

VERDICT: WARN (non-terminal CI — formal PASS/FAIL deferred)


CI Status

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

⏳ All 8 checks still running. Formal verdict deferred until CI is terminal.

Diff Review

  • 300+ files changed (189 commits). GitHub API rejected diff fetch — expected for a sync of this scale.
  • PR is a clean upstream sync: fork customizations are 4 additive config/scaffold commits. Only merge conflict was uv.lock, resolved to upstream.
  • No unresolved CodeRabbit threads.

Observations

  • GAP: CI non-terminal — cannot verify test suite, lint, build, or smoke tests. Deferring PASS/FAIL.
  • GAP: Diff not reviewable via API — 300+ file limit exceeded. Relies on PR author attestation that no conflict markers remain and fork customizations are intact.
  • GAP: Clawpatch structural review unavailable — repo not in project registry.
  • LOW: Merge strategy note in PR body is correct — squash would flatten upstream lineage and break future sync merge-bases.

Bottom line: Looks like a clean, honest sync PR. Once CI goes terminal-green, this should be a straightforward PASS. Re-review will confirm.

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 1, 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 46f2d9a into main Jul 1, 2026
9 checks passed
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