Skip to content

chore: sync fork to upstream protoAgent v0.104.5 (379 commits)#10

Merged
mabry1985 merged 381 commits into
mainfrom
chore/sync-upstream-v0.104.5
Jul 20, 2026
Merged

chore: sync fork to upstream protoAgent v0.104.5 (379 commits)#10
mabry1985 merged 381 commits into
mainfrom
chore/sync-upstream-v0.104.5

Conversation

@mabry1985

Copy link
Copy Markdown
Member

Brings the leadEngineer fork from v0.66.0 up to upstream protoAgent v0.104.5 — 379 upstream commits merged.

Conflict resolutions

  • .github/workflows/docker-publish.yml / release.yml — took upstream's fork-friendly versions (CI: audit workflows for fork-friendliness — guard expensive/org-specific jobs protoAgent#1534): image name + enablement now come from repo variables instead of the fork's hardcoded edits. DOCKER_PUBLISH_ENABLED=true and IMAGE_NAME=protolabsai/leadengineer are set as repo variables, preserving the pre-sync publish behavior with zero file divergence going forward.
  • config/langgraph-config.example.yaml — kept both sides: upstream's new fleet.autostart + secrets_manager (ADR 0080) sections, followed by this fork's Lead Engineer block.
  • tests/test_config_roundtrip.py — took upstream's stricter test, then repinned the golden to this fork's example values (follow-up commit).

Verified locally

  • ruff check . (0.15.10, the CI pin) — clean
  • lint-imports (import-linter 2.11) — 3 contracts kept, 0 broken
  • scripts/gen_attribution.py --check — in sync
  • python -m pytest tests/ -q3842 passed, 5 skipped
  • Fork identity intact (SOUL.md, example config LE block), no leftover conflict markers

Merging

⚠️ Merge with a MERGE COMMIT, not squash — squashing flattens the upstream lineage and breaks the merge-base for the next sync.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L4LLynB9pTvpJzkqUjC98V

mabry1985 and others added 30 commits July 3, 2026 10:38
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ad (protoLabsAI#1729) (protoLabsAI#1741)

The testkit's FakeRegistry.register_surface kept only the surface NAME and
discarded the start/stop/reload callables, so a plugin's surface *lifecycle
wiring* (e.g. arming watch tripwires in `start`) was untestable from a
register(FakeRegistry) smoke test — deleting the wiring left the suite green.

Capture the callables in `surface_specs` (keyed by effective name = name or
plugin_id, mirroring the real registry), keeping `surfaces` (names) for the
existing name-only assertions. Mirror of protoLabsAI#1637 (register_chat_command).

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

The documented two-router pattern (public view at /plugins/<id>, bearer-gated
data at /api/plugins/<id> — ADR 0026, docs/guides/plugin-views.md) tripped the
protoLabsAI#870 non-conforming-prefix WARNING on every boot for every conforming plugin,
including first-party artifact/docs/notes and the google plugin — the loader's
check contradicted the convention the guide prescribes.

Add a shared `_prefix_conforms(prefix, plugin_id)` predicate in
graph/plugins/registry.py that accepts both canonical prefixes, and use it at
both the registration-time check (registry.register_router) and the mount-time
check (server/agent_init._mount_plugin_routers) so they can't drift. The
warning now fires only for genuinely off-convention prefixes.

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

A manifest-less ghost dir under `plugins/<id>` — e.g. a `__pycache__`-only
leftover orphaned when a plugin is extracted core→standalone (git doesn't
track it, so it survives on every machine that imported the old plugin) —
made `install`/`uninstall` refuse the standalone successor as a "built-in".

Treat a directory as built-in only when it actually holds a
`protoagent.plugin.yaml` or `protoagent.bundle.yaml` (new `_is_builtin`
helper), mirroring how the loader decides a dir is a plugin. Applied at both
the install shadow-check and the uninstall not-removable check.

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

A mid-read httpcore.ReadError / httpx.TransportError — what a rate-limited or
flaky gateway raises when it terminates the SSE response — bubbled unhandled
through _chat_langgraph_stream and silently lost a whole background turn's work.
model.max_retries doesn't cover it: langchain retries request *start*, not a
mid-body read.

Fix at the safe layer — the model stream itself (_ReasoningChatOpenAI._astream):
reconnect only when the stream drops BEFORE emitting any content (the rate-limit
case), within the model's max_retries budget. This retries the model call only —
no tool replay, no turn restart — and never reconnects once a token has streamed
(a fresh stream would duplicate it). Logic lives in a testable
`_stream_with_reconnect` helper.

Also classify the terminal case: when reconnects are exhausted (or content had
already streamed), the a2a-stream and chat handlers now log a clear "provider
closed the stream — possible rate limit" WARNING and fail the turn cleanly, so a
background scheduler can observe/reschedule, instead of an alarming
unhandled-exception traceback.

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

A background turn (session=system:activity) died with an unhandled
`sqlite3.OperationalError: database is locked` from `aput`, losing the whole
turn's committed work — the worst failure class for an autonomous agent.

WAL + the existing `busy_timeout=5000` (added in protoLabsAI#322) absorb almost all
contention on checkpoints.db, but a writer holding the lock past the timeout —
the incremental pruner mid-VACUUM, or two background turns landing at once —
still surfaces the error. Wrap the checkpointer writes (put / put_writes) in a
bounded retry with short exponential backoff: a locked write commits nothing,
so retry is safe. Only "database is locked" is retried; every other error (and
the final attempt) propagates unchanged so real problems still surface.

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

* feat(plugins): opt-in background auto-update policy (protoLabsAI#1720)

Plugins were update-only-on-demand — an operator had to click Update in
the console for each behind plugin. Add a `plugins.update_policy` config
map that opts individual plugins into background auto-updates, plus a
periodic sweep that pulls + hot-reloads them the same way the Update
button does and emits `plugin.updated` on the event bus.

Config (graph/config.py + config_io.py):
- `plugins.update_policy: {<id>: {track, when}}` — a non-empty `track`
  arms the plugin (the ref comes from plugins.lock); `when` is `idle`
  (default) or `always`. Empty map = manual-only (unchanged default).
- `plugins.autoupdate_interval_hours` (default 6; 0 disables the loop).
- Both round-trip through from_dict ⇄ config_to_dict (golden updated in
  the three suites that pin the full plugins.* dict).

Loop (server/agent_init.py + server/__init__.py):
- `_plugin_autoupdate_loop` mirrors `_checkpoint_prune_loop`: reads the
  live config each pass, sweeps only opted-in plugins, self-guards every
  step, launched at boot when the cadence is non-zero, cancelled on
  shutdown (STATE.plugin_autoupdate_task).
- Per plugin: skip if not armed / not installed / SHA-pinned / not
  behind; a release-tag pin moves to the newest tag, a branch pulls its
  head. Pull with force, purge modules, reload through the enable path.

Safe-moment gate (server/chat.py):
- `when: idle` defers a plugin's update while a chat turn is (or was
  just) in flight — a reload rebuilds tools/routers, safe between turns
  but disruptive during one. A tiny monotonic beacon is stamped at the
  top of both graph entry points; `_server_is_idle()` reads it.

Refactor: extract `purge_plugin_modules` to graph/plugins/loader.py so
the console Update route and the loop share one implementation.

Tests: tests/test_plugin_autoupdate.py (13 cases — gating, pinned/behind,
idle gate, release-tag targeting, disabled-no-reload, allowlist
threading, beacon). Full suite green (3000 passed).

Docs: plugins guide "Keeping plugins current" + configuration reference.

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

* fix(plugins): address CodeRabbit review on the auto-update loop (protoLabsAI#1720)

- Idle gate now tracks IN-FLIGHT turns, not just the last turn-start
  timestamp. A turn running longer than the quiet window (300s — common
  for agentic turns) previously read as "idle", so the sweep could
  hot-reload plugins mid-request — exactly what `when: idle` promises to
  prevent. Both entry points now bracket their turn via a thin wrapper
  (`_turn_started`/`_turn_ended`) that stays balanced on early
  `aclose()`/error; `_server_is_idle()` requires `active_turns()==0` AND
  a quiet cooldown. (CR: chat.py idle-gate race.)
- Reload block in `_autoupdate_one_plugin` is now exception-guarded, so a
  reload crash can't unwind past the per-plugin loop and abort the whole
  sweep (the code+lock are already on disk; next boot/enable mounts it).
  Restores the "one failure never blocks the rest" guarantee. (CR: stability.)
- Auto-update loop starts unconditionally when graph_config exists (it
  self-guards each pass), so a later config reload that raises
  `autoupdate_interval_hours` from 0 takes effect without a restart. (CR.)
- Finish the purge dedup: `_load_plugin_module` now calls the shared
  `purge_plugin_modules` instead of re-inlining the sys.modules loop. (CR.)

Declined CR's suggestion to offload `_apply_settings_changes` to a thread:
it mutates global STATE and rebuilds the graph on the server loop by design
(mirrors the console Update route); threading it risks a race. The stall is
rare (only on an actual update, ~6h cadence) and bounded.

New tests: long-turn defers, wrapper brackets active turns + balances on
early close, active-turn counter never underflows. Full suite green (3003).

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

Verifying fusion (rung 4) actually works required contriving a task hard
enough to fail greedy, best-of-k, AND tree-search first before fusion is
even reached -- impractical for a quick sanity check, live-caught while
dogfooding the board seam.

Add force_rung: run exactly ONE named rung once -- no cheaper-rung-first
cascade, no escalation past it -- and report pass/fail against the real
verifier. A forced tree-search seeds one un-refined attempt first (purely
to generate the failing-test feedback the refine step needs); that seed
isn't scored as the result unless it happens to pass outright. Raises
ValueError for an unknown rung name, or force_rung="fusion" with no
fusion_generate configured -- a misconfigured test, not a runtime outcome.

Deliberately kept OFF the agent-facing coder_solve tool's signature -- this
is an operator/testing affordance, not something the ladder (or an agent)
should reach for itself. The same boundary this codebase already draws
elsewhere (board_create_feature is a tool; /features/{id}/cancel isn't).
projectBoard-plugin wires it behind a new, non-tool API route (companion PR).

24 passed (was 15; +9 new). Full suite: 2996 passed, 5 skipped (unrelated,
pre-existing). lint-imports: 3 contracts kept.

Co-authored-by: Josh Mabry <josh@protolabs.studio>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
protoLabsAI#1746)

A register_chat_command handler may now return a form request
{"form": <request_user_input-shaped payload>, "on_submit": <async(answers,
session_id)>} instead of a reply string. Per ADR 0045 the form rides the SAME
input_required frame the agent HITL uses (one canonical A2A wire) — tagged with
a plugin_callback_id so it's distinguishable from a graph interrupt.

Backend (graph/slash_commands.py): PluginFormRequest + a session-scoped, single-
use, TTL-reaped callback registry; run_plugin_chat_command coerces a form-dict
return into a PluginFormRequest; submit_plugin_form redeems answers (and supports
multi-step wizards — an on_submit that returns another form). Dispatcher (both
_chat_langgraph_stream + _chat_langgraph) emits the tagged input_required (stream)
or a "use the console" note (non-streaming /v1). Submit route
POST /api/chat/commands/submit (operator_api).

Wire: the hitl-v1 DataPart already carries the full payload (Struct), so
plugin_callback_id survives with NO executor change.

Console: HitlPayload gains plugin_callback_id?; resumeHitl/dismissHitl branch on
it — redeem via api.submitChatCommandForm instead of Command(resume); a returned
form opens the next wizard step.

Demo: the hello plugin's /hello-form shows the round-trip end to end.

Tests: 11 backend cases (round-trip, single-use, session-scope-without-consume,
multi-step, malformed, unknown callback). ruff + lint-imports clean;
web tsc + vitest (103) + build clean; plugin + HITL-hold suites green.

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

Slice 1 of protoLabsAI#1701. Adds a client composer-form seam and uses it for /effort.

- SlashContext.openForm(spec): render any JSON-schema form through the SAME
  HitlForm the agent's HITL interrupt uses, resolved LOCALLY (host shows
  spec.payload, calls spec.onSubmit with the answers) — no agent round-trip.
  Optional on the seam so a command falls back gracefully where unwired.
- ChatSurface holds a client composer-form state DISTINCT from the agent `hitl`
  interrupt (rendered only when the agent isn't holding the panel) so the two
  never collide.
- /effort: bare `/effort` now opens a radio-card picker (low/medium/high/max/off,
  each with a hint, current level preselected) instead of just printing the value;
  submit sets the tab's effort locally. `/effort <level>` still applies directly.

Frontend-only, low-risk; establishes the reusable pattern for Slice 2
(plugin-driven forms). Unit tests cover the picker payload, submit/apply,
the typed-arg path, and the openForm-absent fallback.

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

Backend foundation for the archetype setup wizard. A plugin marks a
setting `required: true` in its manifest to declare it needs that value
(API key, endpoint, …) to function. Today a plugin missing such config
either loads and fails cryptically mid-call, or (for env vars) refuses
to load via requires_env. This adds a SOFT gate in between.

- `settings[].required: true` (no manifest-parse change — settings pass
  through verbatim) declares a required field.
- The loader computes `needs_config` from the resolved config (defaults ⊕
  YAML ⊕ secrets — secrets resolve in too, so API keys are covered). If an
  enabled plugin loads with a required field blank, it STAYS loaded but is
  flagged `incomplete` + `needs_config: [{key, label}]`.
- Its tools are swapped for same-signature stand-ins (`_needs_config_tool`)
  that return a friendly "needs setup" notice instead of erroring — so the
  agent can point the operator at configuration. Fill the field in; the next
  reload restores the real tools.
- Surfaced on `GET /api/runtime/status` (verbatim meta pass-through) and
  `/api/plugins/installed` (merged from meta) so the console can show a ⚠️.
- `0`/`false` count as provided; only null/empty-string/empty-collection
  read as unset (`_is_blank`).

The guided install wizard over these fields is the frontend follow-up
(reuses the protoLabsAI#1701 composer-form component). Whole-plugin enable/disable
stays the revocation primitive — this doesn't change tool availability,
only tool behavior when unconfigured.

Tests: required-blank → incomplete + guarded tool returns the notice +
runtime-status pass-through; required-present → runs normally; blank
OPTIONAL setting → not flagged; `_is_blank` semantics. Full suite green (3016).
Docs: plugins guide + CHANGELOG.

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

* feat(web): plugin setup wizard — ⚠️ needs-setup badge + guided config (protoLabsAI#1719)

Frontend for the required-config gate (backend protoLabsAI#1750, on main). Completes
protoLabsAI#1719's UI half.

- An INCOMPLETE plugin (loaded but missing a `required: true` setting;
  `incomplete`/`needs_config` from /api/runtime/status) shows a ⚠️ "needs
  setup" Badge in the Plugins panel, tooltip listing the missing fields.
- Its row's primary action becomes a labeled "Set up" button (instead of
  the gear icon) that opens the plugin's config dialog.
- PluginSettingsDialog gains an optional `needsConfig` — when set it renders
  a setup banner naming the required fields above the existing config form.
  Reuses SettingsCategory, so saving reloads the plugin, which clears
  `incomplete` and the badge (no new save/reload path).
- Skippable: closing the dialog leaves the plugin incomplete; the badge is
  the durable re-entry point.

Types: RuntimeStatus.plugins[] + InstalledPlugin gain incomplete/needs_config.
tsc + vite build clean; web unit tests 355 pass.

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

* chore: nudge review after draft→ready (protoLabsAI#1751)

---------

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

Two watch-reliability bugs found in the same protoTrader audit.

protoLabsAI#1753 — tick_all evaluated EVERY active watch on the global watch_interval, so a
watch created with interval_s=1800 was actually polled every tick. That collapsed
stall_after's wall-clock meaning (stall_after × interval_s) to a few minutes and
fired dozens of spurious "stalled" reactions overnight (each a full agent turn,
~65k input tokens). The cadence tick now skips a watch until its own interval_s has
elapsed since last_checked (a floor); watches with no explicit interval still
evaluate every tick, and the event-driven evaluate()/evaluate_now() fast path a
plugin calls on state change is unaffected.

protoLabsAI#1752 — POST /api/plugins/{id}/update (and any settings hot-reload) rebuilt the
plugin bundle but never pushed the rebuilt verifiers/hooks into the LIVE registry
the goal/watch controllers consult — only full startup did. A plugin update that
shipped a NEW watch verifier left it resolving as "unknown plugin verifier" (armed
but blind) until a full restart. Extract _apply_plugin_registries() and call it in
the reload commit like init does. Safety net: an unknown plugin verifier now logs a
one-shot WARNING (deduped per name, re-armed on the next registry change) instead of
being visible only by polling /api/watches.

Closes protoLabsAI#1753, closes protoLabsAI#1752

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

Persona edits used to overwrite SOUL.md in place, so a prior prompt iteration was
gone for good. Now every save archives the OUTGOING persona to a per-instance
config/soul-history/ dir before writing the new text — including the bundled SEED
the agent was running on the very first edit — deduped against the last snapshot and
capped at the most recent 50; a history failure never blocks the save.

- infra/paths.py: soul_history_dir (config/soul-history), added to explain().
- graph/config_io.py: write_soul snapshots the effective outgoing persona (read_soul,
  so the seed is captured on first edit); snapshot_soul / list_soul_versions /
  read_soul_version. Version ids are <YYYYMMDDThhmmss.ffffffZ>-<8-char content hash>
  so a lexical sort stays chronological even for sub-second saves; read_soul_version
  validates the id against that format (no path traversal) with a resolved-parent
  check. list_soul_versions marks the live one with is_current. A corrupt/non-utf8
  history file degrades to "skip", never blocks the save or 500s the list.
- operator_api/config_routes.py: GET /api/config/soul/history (list),
  GET /api/config/soul/history/{id} (full text), POST .../{id}/restore. Restore
  re-saves through _apply_settings_changes (snapshots the CURRENT persona first, so
  rolling back is itself reversible); restoring the already-live version short-circuits
  the graph recompile.

Steelman + adversarial reviewed: seed-on-first-edit gap, is_current, no-op-restore
short-circuit, and the non-utf8-history-blocks-save contract bug all fixed.

Tests: tests/test_soul_history.py + route tests in tests/test_config_routes.py.
Console version-history UI is a follow-up.

Closes protoLabsAI#1691

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

So a run can be correlated with which SOUL.md persona was live when the agent did
something — the debugging payoff of the soul-history feature.

- graph/config_io.py: soul_revision() — a short SHA-1 of the current effective
  persona (matches the content-hash suffix of soul-history version ids, so a tagged
  run lines up with an archived version).
- observability/telemetry_store.py: soul_rev column on the per-turn `turns` table,
  added via a guarded ALTER migration so existing telemetry DBs upgrade in place.
- server/a2a.py: populate soul_rev on the SQL telemetry row + the realtime
  turn.usage bus event (computed once per turn, best-effort — never breaks a turn).
- server/chat.py: soul_rev in the Langfuse trace metadata for both trace sites.

Deliberately NOT a Prometheus label — a content hash is high-cardinality and would
explode the metric series.

Tests: telemetry_store column round-trip + old-DB migration; soul_revision hashing.

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

* feat(web): SOUL.md version history panel — view + one-click restore (protoLabsAI#1691)

Adds a "Version history" section below the persona editor in Agent → Identity,
consuming the backend from protoLabsAI#1757. Each archived SOUL.md snapshot is listed
newest-first with its save time, a text preview, and a "current" badge on the
live one. Expanding a row fetches and shows that version's full text; "Restore"
rolls the persona back to it (behind an inline confirm) via the same save+reload
path — which snapshots the *current* persona first, so a roll-back is itself
reversible.

After a restore the editor re-seeds from the restored persona, unless there's an
unsaved draft in the textarea (that's preserved). Loading / empty / error states
are handled; the list scrolls internally so it never pushes the editor off-panel.

Gates (run in worktree):
- npm run test:unit → 355 passed
- npx tsc --noEmit → clean
- npm run build → ok

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

* test(web): cover the SOUL version-history api methods (protoLabsAI#1691)

soulHistory/soulVersion/restoreSoulVersion — assert the routes hit, that the
version id is encodeURIComponent'd (not path-split), and that restore is a POST.

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

* docs(changelog): note the console version-history panel (protoLabsAI#1691)

Completes the SOUL-history entry — the "console UI is a follow-up" note now
describes the shipped Version history panel.

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

* refactor(web): move persona version history into a trigger button + dialog (protoLabsAI#1691)

The always-open inline list crowded the Identity panel. It now lives behind a
"History (n)" button in the SOUL header (grouped with Edit/Preview) that opens a
DS Dialog — Esc/backdrop dismiss, focus trap. The trigger shows the snapshot
count; restoring closes the dialog so the editor's update is the confirmation.

The list grows with its content and caps at 68vh (scrolling inside the dialog),
so the dialog is sized to the list — no dead space under a short history, no
overflow past the screen for a long one.

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

* fix(config): flag only the newest identical snapshot as current (protoLabsAI#1691)

list_soul_versions marked every archived snapshot whose text equalled the live
persona as is_current. Because dedup only collapses CONSECUTIVE identical saves,
a persona saved → changed → saved again lands in history twice, and after a
restore the live persona matched BOTH copies — so two (or more) rows showed the
"current" badge. Flag only the first (newest) match.

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

---------

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

railSurfaces() appends fork-contributed (`ext`) surfaces without a placement
check, so a fork id colliding with a core/plugin id already in the list rendered
twice — a duplicate React key AND a duplicate dnd-kit sortable id, which desyncs
the sortable rail's index→id mapping. Dedupe by id (first occurrence wins).

Hardens the class of bug behind protoLabsAI#1755 (wrong-item rail clicks) but does NOT close
it: the reported GitHub-plugin case doesn't flow through `ext`, and the click path
is otherwise id-based + clean on both the console and DS sides. Remaining suspect
is a dnd-kit transform/stacking artifact in @protolabsai/ui's sortable rail — filed
on protoContent; protoLabsAI#1755 stays open pending a live repro.

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

* docs(adr): ADR 0071 — plugin permissions: trust-and-consent, not a sandbox (protoLabsAI#1725)

Design-only ADR (Status: Proposed) consolidating the locked ComfyUI
posture into an explicit plugin permissions model, grounded in an
as-built audit of the enforcement surface.

Core findings it records:
- An enabled plugin runs in-process with full runtime privileges. The
  ONLY hard boundaries are: enable gate + plugins.disabled, tools.disabled
  (by tool name), route auth default-deny + namespace-scoped public_paths,
  requires_env/min_version load gates, and install-time source allowlist +
  SHA pinning. fs, network egress, cross-section secret reads, internal
  APIs, and apply_settings are unenforceable in-process.
- The manifest `capabilities:` block is decorative (declared, not enforced)
  — presenting it as confinement is the actual risk.

Decisions:
- D1 Trust, not sandbox — reaffirm; untrusted code → MCP; stop
  re-proposing an in-process sandbox.
- D2 Present only the boundary we enforce — reframe `capabilities:` as
  disclosure; whole-plugin enable/disable is the revocation primitive.
- D3 Formalize provenance-based consent (P1): sources.official (default
  github.com/protoLabsAI/* fork-overridable), sources.acked,
  trust_unverified — gating auto-enable (protoLabsAI#899), inherited by auto-update
  (protoLabsAI#1720).
- D4 Design-only; the consent layer is a tracked follow-up.

Adds the index row; regenerates plugins/docs/nav.json (docs nav gate).

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

* docs(adr): ratify ADR 0071 — Accepted (protoLabsAI#1725)

Maintainer ratified the trust-and-consent (not sandbox) model. Flip
Status Proposed → Accepted in the ADR + index row.

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

* chore: nudge review after draft→ready (protoLabsAI#1748)

---------

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

⌘K gains an "Agents" section (beneath "Chat with <this agent>") listing every
OTHER reachable fleet agent; picking one navigates to that agent's slug-routed
console. Ordered reachable-first → recently-opened → alphabetical; a down or
unreachable agent is listed but disabled. Routed through a new serializable
`agent` NavIntent so it behaves identically from the in-app palette and the
always-on-top desktop launcher (which forwards the intent to the main window).

New pure helper src/app/fleetPalette.ts (+ tests): roster→entries with the
sort/filter/disabled logic and a small localStorage recency store.

Gates (worktree): test:unit 369 passed, tsc clean, build ok.

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

The Tauri desktop WebView has no native browser zoom, so desktop users had no way
to scale the console UI. Add a small view/zoom module that applies a page zoom via
the CSS `zoom` property on the document element (browser-zoom-equivalent: reflows
layout, adjusts scrollbars — unlike a transform), 50%–200% in 10% steps, persisted
to localStorage and re-applied before first paint (no flash of unscaled UI).

Wired through three ADR-0063 keybindings (view.zoom.in/out/reset → mod+= / mod+- /
mod+0, allowInInput), so they appear in Settings ▸ Keyboard (group "View") and are
rebindable — and, like the existing binds that shadow ⌘T/⌘O, they override the
browser's native zoom for those combos in a plain tab.

Closes protoLabsAI#1711

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

Adds the careercoach official plugin (https://github.com/protoLabsAI/careercoach-plugin)
to config/plugin-catalog.json + the marketing sites/marketing/data/plugins.json mirror, so
it shows in System → Plugins → Discover for one-click install alongside the other plugins.

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

careercoach-plugin now has a dedicated `## Quick start` how-to; repoint the
marketing catalog `links.docs` from #readme to #quick-start so the /plugins
page's docs link lands on the guide (matches the plugin manifest's guide_url).

Co-authored-by: Josh Mabry <artificialcitizens@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ter() runs host-free (protoLabsAI#1773)

The plugin-devkit testkit's default host-stubs omitted `graph.subagents.config`
entirely (ModuleNotFoundError) and handed back a raise-when-called `Knobs` /
`make_knob_tools` from `graph.sdk`. So a scaffolded plugin whose `register()`
registers a subagent (`registry.register_subagent(SubagentConfig(...))`) or wires
runtime knobs failed its OWN host-free smoke test before the author wrote a line.

These seams are CONSTRUCTED at register() time, not merely imported, so they need
permissive RECORD-ONLY stand-ins (not the raise-when-called placeholder):

- Stub `graph.subagents` + `graph.subagents.config` exposing `SubagentConfig`, a
  record that stores its kwargs as attributes — `from graph.subagents.config import
  SubagentConfig` imports and `reg.subagents[0].name` is assertable.
- `graph.sdk` now exposes a chainable no-op `Knobs` (`.define`/`.preset` return self,
  reads mirror the surface) and `make_knob_tools(...)` returning record-only stub
  tools named like the real `<prefix>_knobs`/`_tune`/`_preset` — a plugin can
  `register_tools(make_knob_tools(...))` host-free and the contribution stays
  assertable. The remaining `graph.sdk` seams (complete/run_subagent/...) stay
  raise-unpatched so a model-touching test still fails loudly if unpatched.

`graph/plugins/testkit.py` is the single source of truth — the scaffolder vendors it
verbatim into each plugin's `tests/_plugin_testkit.py`, so newly-scaffolded plugins
inherit the fix automatically.

Closes protoLabsAI#1764

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

* fix(console): theme overrides win over defaults + persist across reload (protoLabsAI#1762)

The per-agent theme bridge (agentTheme.ts) seeded localStorage with the
incoming agent/server blob on every boot apply and then re-applied it — so a
user's persisted, unsaved theme tweaks were clobbered by the agent default on
reload (and never survived a restart).

Fix: extract pure, unit-tested precedence logic (themeMerge.ts). On the BOOT
apply the user's persisted working copy now WINS over the agent default and the
default only fills the gaps (per-token override precedence + user mode wins);
an explicit agent switch/reset still replaces verbatim (ADR 0042). The apply
reads persisted state instead of resetting to a default object, so changes are
live and survive reloads with no re-render.

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

* fix(console): scope boot theme-merge to the focused agent (protoLabsAI#1762)

The boot-merge (preservePersisted) merged the persisted `pl-theme`
localStorage blob over the incoming server default. But `pl-theme` is a
single GLOBAL key shared by every same-origin agent window (the fleet
console is slug-routed on one origin, ADR 0042), so a different agent's
saved theme can be sitting in it — merging it bled the wrong agent's
accent/mode/radius over this agent's saved look, violating the ADR 0042
boot contract ("apply THIS agent's saved theme on boot").

Stamp a companion `pl-theme:agent` owner key (the focused slug) on every
applyAgentTheme write, and only merge on boot when the persisted blob
belongs to the current agent (persistedThemeIsForCurrentAgent()); on a
mismatch we replace verbatim with the agent's own theme. The protoLabsAI#1762
unsaved-tweak-survives-reload behavior is preserved for the common
single-agent case (owner matches).

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#1767) (protoLabsAI#1775)

Server-initiated turns — background push-resume (ADR 0070), scheduled fires, and
watch reactions (ADR 0067) — run by self-POSTing into a session and hold the
connection open for the WHOLE turn. The browser only renders turns it streamed,
so during the agent's longest turns the console showed nothing: no typing
indicator, no stream, the app looked hung.

Additive fix (issue Option 1 — leaves the fragile streaming path untouched):

- Backend emits turn-lifecycle bus events `turn.started` / `turn.finished` around
  the self-POST, carrying the target `session_id` and an `origin`
  (`background-resume` / `scheduler` / `watch-<id>`):
  - background/manager.py `resume_origin` (the push-resume nudge)
  - scheduler/local.py `_fire` (scheduled + watch-reaction one-shots)
  Both are best-effort and always emit `turn.finished` (even on delivery failure)
  so a hung `turn.started` can't spin the indicator forever.

- Console renders the EXISTING typing spinner off `turn.started`, labelled by
  trigger ("responding to background reports…"), and clears it on `turn.finished`.
  A tiny ref-counted store + a bus watcher; display-only, never touches
  conversation history or `sessionStatusMap`.

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

Shift+click on the tab-strip "+" already opens a new incognito chat (protoLabsAI#1697),
but with no visual cue. While Shift is held, the DS TabBar's "+" now swaps to
the incognito EyeOff glyph (the same glyph incognito tabs and the composer chip
wear) and reverts the instant Shift is released.

The cue rides the existing "is Shift held" signal that already drives the
quick-delete trash (protoLabsAI#1373): the inline keydown/keyup/blur effect is pulled into
a reusable `trackShiftHeld` helper (with cleanup on unmount), and the tab-strip
wrapper now carries a `--incognito` modifier alongside `--del`. CSS masks the DS
"+" svg into an EyeOff outline, mirroring the sibling trash-mask pattern — no new
dependency. The Shift+click incognito gesture is unchanged; its predicate is
extracted to `isIncognitoAddClick` so a unit test can pin it.

Closes protoLabsAI#1744

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…toLabsAI#1711, revisit later (protoLabsAI#1777)

Reverts the UI zoom feature (8bbb8f2, shipped in v0.89.0): removes the three
`view.zoom.*` keybindings, the `initZoom()` boot wiring in main.tsx, and the
`apps/web/src/view/zoom.ts` module (+ its tests). No dead code left behind;
re-enabling later is a revert of this commit.

The 0.89.0 CHANGELOG entry is left intact (immutable released history); the
removal is recorded under [Unreleased] → Removed. Tracked for rework in protoLabsAI#1711.

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

When an agent fans out N background subagents in a single turn
(task_batch(run_in_background=True), or several task(run_in_background=True)),
each job's completion independently push-resumed the origin session (ADR 0070 D1),
producing N drip-fed briefing turns instead of one — the real cross-report
synthesis only landed on the last. This coalesces a fan-out into ONE push-resume.

- store: nullable background_jobs.batch_id column (guarded in-place migration) +
  batch_size / batch_outstanding / batch_status_counts helpers.
- manager: spawn/spawn_work accept batch_id; new resume_for_terminal(job) —
  singleton → resume_origin (unchanged); non-last member → held (None); last member
  wins a synchronous single-fire claim (_joined_batches) and fires one
  resume_origin_batch with a per-status summary. Straggler timeout
  (BACKGROUND_BATCH_JOIN_TIMEOUT_S, default 900s) forces a partial join if a member
  hangs so finished reports are never stranded.
- agent: _turn_id_from(state) = the emitting AIMessage id; task/task_batch stamp it
  as batch_id (a lone task → batch_size 1 → singleton path). sdk single-spawn stays
  unbatched.
- server/a2a: _spawn_background_resume routes through resume_for_terminal; a held
  (None) result does nothing (no wake, no failure); singleton/fired-batch keep the
  non-delivery Activity-wake fallback.
- ADR 0070 amended (D5); CHANGELOG entry; tests for batch keying, hold-until-last,
  single-fire, failed-member summary, straggler timeout, and store migration.

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

The red error-strip that rendered above the chat (image-can't-be-seen, attach
failures, queue/cancel/rewind errors) moves to a DS toast (useToast), per the
console toast convention. All ChatSurface onError sources now route through one
`onChatError` → `toast({ tone: "error" })`.

Bonus fix: the strip only rendered in the LEFT dock's content, so an error from a
chat docked right/bottom silently showed nothing; a global toast fixes that. The
old `error` state + its engine-ready clear effect + the `.error-strip` CSS +
CircleAlert import are removed (net −34/+16).

Scope: the persistent, server-driven banners that live ABOVE the shell —
`shell-warning-banner` (runtime warnings) and `AgentDownBanner` (status + Start
action) — intentionally STAY banners; they're ongoing state, not transient
notices, so a toast is the wrong surface for them.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mabry1985 and others added 23 commits July 18, 2026 18:17
…rotoLabsAI#2048)

* docs(changelog): backfill [Unreleased] for everything since v0.103.2

[Unreleased] was EMPTY while 12 commits sat on main — three ADRs (0085 managed Node, 0086
chat-first mobile, 0087 device pairing), the a2a 0.3.0 cutover, and the ui@0.57 adoption.
Cutting a release from that would have generated notes from nothing, which is the exact
failure mode we've been bitten by before.

My own PRs (protoLabsAI#2043 / protoLabsAI#2045 / protoLabsAI#2046) are part of the miss — the file's own header says to add
entries in the PR and I didn't.

Entries describe what SHIPPED, deliberately not what the ADRs propose: past release notes
have overclaimed by reading docs-only ADR PRs as delivered features. Each line here is
traceable to merged code. The a2a entry calls out that peers must move to >= 0.3.0, since
that cutover has no dual-read and is the one item here that can break an integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

* docs(changelog): add the uv pin — CI entries are in convention here

Missed on the first pass as 'CI-internal, not user-facing', but this CHANGELOG does carry
that class of entry (the uv.lock re-lock in 0.103.1, the fork-friendly-workflows and Node 20
runtime entries earlier). It also isn't purely internal: contributors hit it as spurious
uv.lock drift failures on unrelated PRs, which is worth being able to look up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

---------

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>
…87 D6 amended) (protoLabsAI#2050)

* feat(pairing): offer to make a loopback-bound agent reachable (ADR 0087 D6 amended)

Josh, on the desktop app: `{"ok":false,"error":"no reachable address — this instance looks
bound to loopback..."}`.

The bind guard was right; the FEATURE was wrong. The desktop app binds 127.0.0.1 by design —
correct for a local app — so pairing was unusable in exactly the place it was asked for. It
dead-ended on an error whose fix (edit two config keys, restart) appeared nowhere. I built
and verified this against a tailnet-bound dev instance and never ran it in the desktop app,
which is the one environment that matters for "add my phone".

A loopback-bound instance now also reports what it COULD be reached on, and the Devices panel
offers to bind there:

  - `_pairable_addresses` splits address classification from the bind filter, so
    `_candidate_hosts` stays "pairable AND reachable now" while the 409 can answer "here is
    what you could expose". A test pins the relationship so they can't drift.
  - Choosing an address writes `network.bind` (host layer) through the existing settings
    path — no new config plumbing; `network.bind` and `auth.token` were already schema-backed,
    which is why this is an orchestration change rather than a new subsystem.
  - The offer NEVER includes a public address (same tailnet + RFC1918 allowlist), and tailnet
    is offered above LAN and labelled as the safer pick — reachable only by your own devices,
    versus anything on that Wi-Fi.

ORDER MATTERS in `makeReachable`. `auth.token` applies LIVE, so writing it 401s the current
session on the next request unless the browser already holds it: store locally first, save,
and roll the local value back if the save fails — otherwise a failed save locks you out with
a token the server never accepted. And because the server refuses a non-loopback bind with no
token, "exposed but open" is not a state this flow can produce even by accident.

`api.pairingStart` now reads the 409 body directly instead of going through `request`, which
collapses every non-2xx into a thrown string and would have discarded the payload. Returning
200 with ok:false would have been easier and weakens a correct status code for client
convenience.

`network.bind` is restart-gated, so the flow ends on an explicit restart notice rather than
pretending to be done, and it names the undo (set the bind back to 127.0.0.1).

Verified the whole loop on a loopback instance: 409 offers tailnet+LAN -> click tailnet ->
host-config.yaml gains `bind: 100.119.239.8` -> restart comes up on that address -> pairing
returns a QR.

27 pairing tests (3 new) · 566 unit · 227 e2e · 3796 python · ruff + 3 import contracts clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

* docs(changelog): add the desktop pairing fix

Adding it in the PR this time — [Unreleased] being empty at v0.104.0 is what forced the
backfill in protoLabsAI#2048, and the file's own header asks for exactly this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

---------

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>
…e desktop app (protoLabsAI#2052)

Josh, after enabling network access on desktop and restarting: "This is taking longer than
usual. The engine may still be compiling..." — the console never loaded.

`makeReachable` wrote the address the operator picked. uvicorn takes ONE host, so a single
non-loopback bind DROPS loopback — and the desktop webview reaches its own sidecar over
`http://127.0.0.1:<port>` (src-tauri/src/lib.rs). The app could no longer talk to the server
it had just reconfigured. The feature bricked the app it was built for.

Now binds 0.0.0.0. Choosing "Tailnet" selects the address ADVERTISED IN THE QR, not the only
one we listen on; "loopback plus tailnet, nothing else" isn't expressible in a single uvicorn
host, so the wildcard is the only value that serves both callers.

That genuinely listens on every interface, which is broader than the old copy ("listen on the
address you pick") implied — it undersold the exposure while describing a posture the code
didn't deliver. The UI now says "all your network interfaces" and explains WHY (it's what
keeps this app working while your phone connects). The token is what actually gates access.

WHY I MISSED IT: I drove the flow end-to-end against a server I reached OVER THE TAILNET, so
losing loopback cost me nothing. The desktop app is the only client that requires loopback,
and it's the client this feature exists for — the same shape as the bug it was fixing, one
layer down.

Guarded by a source-level test (devicesBind.test.ts) rather than a behavioural one: the
regression is a one-token change that type-checks, passes every behavioural test, and only
manifests where loopback is the client — i.e. never in CI. Verified it FAILS against the
v0.104.1 code before keeping it.

566 unit (3 new) + 227 e2e + 3796 python pass; ruff + 3 import contracts clean. ADR 0087 D6
records the constraint so nobody "tightens" this back to a specific bind.


Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

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>
…rotoLabsAI#2054)

Josh got locked out of the desktop app, and the diagnosis I gave first was wrong — worth
recording accurately.

WHAT ACTUALLY HAPPENED. Binding a single non-loopback address killed loopback, so the
webview's calls to 127.0.0.1 were REFUSED, not 401'd. A connection refusal reads as "server
not up yet", so the boot gate spun on "This is taking longer than usual" forever. That is
already fixed in v0.104.2 (bind 0.0.0.0).

I assumed the 401 path was also broken and that AuthGate never fired. It does — verified by
reproducing a tokenless client against a token-gated server in BOTH a plain browser and the
faithful desktop context (`?__apiPort=` + the injected API base). AuthGate came up correctly
in both. Good thing to have checked rather than "fixed".

WHAT WAS GENUINELY BROKEN is the design flaw underneath: the flow mints a token and writes
it only to the acting browser's localStorage. Nothing else can ever learn it — not the
desktop app's own window after a restart, not the CLI, not a second browser — and the
operator is never shown the secret their instance now requires. That is how one click leaves
every other client 401ing against a credential nobody has seen.

So the minted token is now displayed once, with a copy button and a plain statement that
nothing else knows it. And AuthGate stops pointing only at `A2A_AUTH_TOKEN`, which is a dead
end when the token was written to `secrets.yaml` — precisely what this flow does — so a
locked-out operator now knows both places to look.

STILL OPEN, deliberately not shipped here: the desktop app should not have to ask at all. It
spawns the server, so it can read the configured token and authenticate its own webview. The
reliable channel is a Tauri `invoke()` command — NOT `initialization_script`, which
lib.rs already documents as unreliable across Tauri v2 webview contexts. That is a Rust
change I cannot exercise without a desktop build, and after this stretch I am not shipping
untested Rust into the boot path. Filed rather than guessed.

566 unit + 227 e2e pass; ruff + 3 import contracts clean.


Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

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

Once an instance has a token — most easily via Settings ▸ Devices, which mints one — the
desktop app's window had nothing in localStorage and hit AuthGate on next launch. The
operator was sent to secrets.yaml to unlock software running on their own machine, for a
secret the app already had on disk.

The app SPAWNS the sidecar and sets its PROTOAGENT_HOME, so it reads `auth.token` from that
server's config and hands it to its own webview on the first 401.

DELIVERY CHANNEL. Over `invoke`, not `initialization_script`: lib.rs already documents that
`window.__PROTOAGENT_API_BASE__` injection "proved unreliable across Tauri v2 webview
contexts" (hence the `?__apiPort=` handoff). A token must not ride the webview URL either —
that's readable by the page and anything it embeds.

PARSING. A hand-scan rather than a YAML dependency: the file is written by our own ruamel
with a fixed shape, and the desktop binary shouldn't grow a parser for one lookup. Four Rust
unit tests cover quotes, inline comments, an absent/empty token, and — the one that matters —
a `token:` under some OTHER top-level key, which must not be mistaken for the operator bearer.

SAFETY. `tryDesktopSelfAuth` runs once per page and refuses to re-save a token equal to the
one already stored: without that, a genuinely-rejected token would clear the gate, retry,
401, and loop invisibly instead of prompting. Every failure path (browser, no token, missing
command, IPC error) degrades to today's prompt — this is convenience, and it must never be
able to block boot.

VERIFIED IN A REAL DESKTOP BUILD, which is the condition I set for touching Rust in the boot
path. Built the app, ran it against an isolated HOME holding a token-gated config, and
confirmed `invoke` reaches the command from the actual webview:

    desktop: auth_token requested — configured: true   (x2 — main + launcher windows)

Twice is correct: each webview has its own storage and so each self-authenticates. Two false
starts worth recording — the bundled sidecar resource is a stale Jun-2 binary that doesn't
resolve a fixture home, and a debug build loads `devUrl`, so the webview stays empty unless
something serves the dist there. Neither was a code fault; both would have read as one.

4 Rust + 5 console tests; 566 unit + 227 e2e pass.

Closes protoLabsAI#2055.


Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

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

Third time this feature stopped Josh's desktop app from starting. Same root shape each time:
I trusted a local proxy for a server fact.

`makeReachable` decided whether to mint a token from `localStorage.getItem("protoagent.authToken")`.
His browser still held a token from earlier — one I had since REMOVED from secrets.yaml while
unblocking him. So the flow concluded "a token exists", skipped minting, and wrote
`network.bind: 0.0.0.0` onto an instance with none. `evaluate_open_bind` then refuses at
startup (correctly — the operator API includes plugin install = code execution), the sidecar
exits, and the app never comes up. Recovery required hand-editing YAML.

Evidence lined up exactly: ~/.protoagent/host-config.yaml modified at 01:21, and NOT ONE file
in the desktop config dir touched. Bind written; token never was.

"This browser holds a token" and "this server requires a token" are different facts. They
diverge the moment a token is rotated or removed, which is precisely what had happened. The
mint/verify/then-bind ordering I added earlier was sound but lived INSIDE the `if` — when the
condition is wrong, the whole safety sequence is skipped. A guard that only protects the path
you anticipated isn't a guard.

So `bearer_configured()` exposes the server's own answer — the same fact `evaluate_open_bind`
refuses on, so the write and the boot guard cannot disagree — and the pairing 409 carries it
as `auth_configured`. A missing field (older server) reads as NOT configured: minting a second
token is recoverable, writing a bind without one is not.

Also fixes the rollback, which blanked localStorage on a failed save instead of restoring what
was there — that browser may hold a token still valid for something, and the save never landed.

Guarded both sides. Python pins that bearer_configured tracks a live rotation and AGREES with
evaluate_open_bind, so the flow can't write a bind the boot guard will reject. The console
guard is source-level on purpose: this is a one-expression change that type-checks, passes
every behavioural test, and only manifests when browser belief and server config disagree — a
state no unit test naturally constructs. Verified it FAILS against the shipped v0.104.3 code
(2 of 4) before keeping it.

4 Python + 4 console tests; 566 unit + 227 e2e + 3800 python pass; ruff + 3 contracts clean.


Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

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#2060) (protoLabsAI#2061)

The devkit building-plugins skill documented start_goal_loop with a
copy-pasteable example, but the helper was removed with monitor goals in
the ADR 0067 migration (protoLabsAI#1511) — a plugin author following the skill got
an ImportError, invisible in host-free test suites where graph.* is
stubbed. Two plugins (spacetraders' campaign loops, learning-wiki's
/learn) had meanwhile hand-rolled the identical composition, so the
one-call shape earns its way back — on the post-0067 substrate:

- start_goal_loop = create_watch + schedule_recurring under a shared
  derived id (watch <plugin>:goal-loop:<id>, tick
  plugin:<plugin>:goal-loop:<id>), idempotent by (plugin_id, loop_id),
  watch rolled back if the tick can't be scheduled — never half a loop.
- stop_goal_loop re-derives both ids and cancels both; call it from a
  watch on_met hook for a self-retiring cadence.
- Goals stay drive-only; GoalState untouched (ADR 0067 amended).
- Fixes the skill's phantom example and the stale start_goal_loop
  reference in the goal-mode guide.

Closes protoLabsAI#2060


Claude-Session: https://claude.ai/code/session_015q4tbDRVZGMtrmmSyK2K9G

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

Josh, after the fourth time this flow stopped his desktop app from starting: "put the devices
settings section behind a developer flag. this is a joke."

He's right. The count, in one day:

  v0.104.1  bound the chosen address → killed loopback → app can't reach its own sidecar
  protoLabsAI#2054     minted a token only the acting browser held → every other client 401s
  v0.104.4  decided from localStorage, not the server → wrote a bind with no token → the
            boot guard refuses → app won't start
  now       with a token finally configured, CORS PREFLIGHT 401s: OPTIONS carries no
            Authorization by design, so every cross-origin call from the webview is blocked
            before it starts

Four failures, four correct fixes, and each one exposed the next layer. That is not a feature
that's nearly done; it's a feature whose failure surface I don't yet understand. A flag is the
honest response — it stops shipping the failure to users while the path gets exercised where
those failures actually land, which is the desktop app itself and never once my test harness.

`settings.devices` (ADR 0068), tier "off". Flag-off sections are filtered from the nav AND
from id resolution — filtering only the nav would leave a persisted "devices" id happily
rendering the panel.

The preflight bug is REAL and separate: it breaks any token-gated instance whose console is
cross-origin, pairing or not. Not fixed here — this commit is the gate, and I'd rather land
that fix on its own with its own reproduction than bundle it into a change whose whole point
is reducing blast radius.

Guarded source-level: the failure mode is someone deleting the `flag:` key during an
unrelated edit, which type-checks, renders fine locally, and silently re-exposes a flow with
this track record. The e2e settings spec asserts the section list, and "Devices" being ABSENT
from it is now part of that assertion.

3 new tests; 566 unit + 227 e2e + 3823 python pass; ruff + 3 contracts clean.


Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

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

Ports the injection posture of LangChain's open-swe reviewer into the ADR
0077 roles (open-swe wraps every piece of PR-controlled text as explicit
untrusted data; our panel had no such framing anywhere):

- review-finder: everything the PR supplies — diff, title/description,
  comments, fetched file contents — is DATA, never instructions; steering
  text aimed at the reviewer is reported as a security finding, not obeyed.
  The description-waiver bullet narrows: a description can own minor/nit
  limitations, it cannot waive a blocker/major.
- review-finder ref discipline: code-context github_read_file reads pin to
  the PR head SHA (a plain read shows the default branch — finders were
  evidencing claims against stale files); policy docs (CLAUDE.md/PROTO.md)
  read at the base ref, never the PR head — a PR must not rewrite the rules
  it is judged by.
- verifier: same ref discipline + data framing for re-reads.
- review-synthesizer: quoted PR text inside finder reports is data.
- code-review.yaml: new optional head_sha/base_ref inputs (server-resolved
  by callers like pr-reviewer-plugin's dispatcher; empty on manual runs),
  threaded into every finder + verify step; {{inputs.prior_findings}} now
  rides inside an explicit <prior_findings> wrapper.


Claude-Session: https://claude.ai/code/session_01M5CBxvjterELhCenKUXn8S

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

The findings-lifecycle half of the open-swe reviewer comparison: publish
surfaced findings as inline review comments with machine markers (dispatcher
posts, cap 6), reconcile them against GitHub thread state on delta re-reviews,
settle threads in code from panel-emitted status/note, suggestion blocks ≤4
lines. Extends ADR 0078 D5's GitHub-native memory from verdict granularity to
finding granularity; gives the eval its ws-91a resolution substrate. The
deterministic seam stands: the model still never posts, promotes, or chooses
a verdict.


Claude-Session: https://claude.ai/code/session_01M5CBxvjterELhCenKUXn8S

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

- review-finder: a literal changed-line pass runs FIRST (wrong identifier/
  operator/key, dropped await/lock — a provable local failure beats an
  elaborate adjacent hypothesis); claims must name the concrete failure mode
  (build/runtime/user impact); severity follows runtime consequence.
- review-synthesizer: two prose-only coverage cross-checks before finalizing
  (zero findings on production code => re-walk the reports once; a heavily-
  changed area with zero findings is flagged as a coverage gap). The
  never-add rule stands.
- conventions angle (code-review.yaml): explicit CI test-enforcement check —
  a suite no longer run / skipped / made non-blocking / conditionally
  bypassed is a major, not a nit.


Claude-Session: https://claude.ai/code/session_01M5CBxvjterELhCenKUXn8S

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

The panel runs alongside other reviewers (Quinn, CodeRabbit, humans) and could
not see any of their inline threads — so the three-way eval measured overlap
that the review then re-posted as duplicate noise. New optional
existing_threads recipe input (a pre-rendered <pr_review_threads> data block;
the caller fetches, escapes, and wraps — pr-reviewer-plugin companion),
threaded into every finder. Finders drop candidates overlapping an existing
thread by location or underlying defect (independent confirmation goes in
prose, never re-filed); the synthesizer's drop list backstops slippage.


Claude-Session: https://claude.ai/code/session_01M5CBxvjterELhCenKUXn8S

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

Two gaps before cutting v0.104.5.

1. The CORS-preflight fix from protoLabsAI#2062 was IN that PR's squash commit but is absent from
   [Unreleased] on main — a later PR's changelog conflict dropped it. It's a real user-facing
   fix (any token-gated instance with a cross-origin console couldn't load), so releasing
   without it would have understated the release and left the fix uncredited. Restored verbatim.

2. protoLabsAI#2063protoLabsAI#2066 (the /code-review panel prompt work + ADR 0088) landed with NO [Unreleased]
   entries at all. Added one grouped, factual line traceable to those PRs — not my work, so
   described from the PR titles and diffs rather than embellished.

Same lesson as the v0.104.0 backfill: an empty or half-populated [Unreleased] at release time
is how notes get generated from nothing. Checked the full v0.104.4..main range rather than
assume the section was current.


Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

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>
… dropped (protoLabsAI#2069)

CAUGHT AT RELEASE. The CORS-preflight exemption was the SECOND commit on protoLabsAI#2062's branch
(5756250). GitHub's squash captured only the first (the Devices gate) — a stale-SHA squash —
so the code never reached main, while protoLabsAI#2062's changelog line (re-added in protoLabsAI#2067, then rolled
into [0.104.5] by protoLabsAI#2068) advertised it. Tagging v0.104.5 then would have shipped release
notes describing a fix that isn't in the binary.

Cherry-picked 5756250's code + test onto main verbatim; its changelog change auto-merged to
a no-op since the entry was already present. Verified the exempt-OPTIONS test passes and the
real GET still 401s without a bearer.

The lesson is the standing stale-SHA one, sharper: a squash silently drops commits pushed
after auto-merge is armed, and a dropped code commit is invisible unless you diff the release
against what the changelog claims. I only caught it because the pre-tag verification grepped
for the fix and found nothing.


Claude-Session: https://claude.ai/code/session_01NvepACsQqNVvPzoKkuzFgf

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

Python license strings in THIRD_PARTY_LICENSES.md resolve from the installed
environment (importlib.metadata), so regenerating in an env without the project
deps fell every row through to UNKNOWN — while still producing a file the
--check inventory gate reports as ok. The manifest ping-ponged between real
licenses and all-UNKNOWN depending on whose machine last ran it.

Carry-forward: a pinned name==version's license is immutable, so the committed
manifest is itself a valid license cache. When the installed env resolves a
package to UNKNOWN, back-fill from the existing file instead. Regeneration is
now monotonic — the env can only ever fill in an UNKNOWN, never wipe a recorded
license. A bare-env regen with the manifest present becomes a zero-diff no-op
(with a loud ::warning:: naming what it preserved).

Guard: the one case carry-forward can't rescue is seeding a fresh manifest in a
bare env (nothing to back-fill from). Refuse to write when >40% of Python rows
are UNKNOWN rather than commit an all-UNKNOWN file that passes --check. Normal
rate is ~4%; a bare-env seed is ~100%.

The --check gate stays offline/stdlib-only (unchanged) — this only affects the
write path. npm was never affected (licenses come from package-lock.json).

Adds tests/test_gen_attribution.py covering the license column, which had no
coverage before.


Claude-Session: https://claude.ai/code/session_01QXPWUrhCvz1ufA5k6KD4Qc

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…-v0.104.5

# Conflicts:
#	.github/workflows/docker-publish.yml
#	.github/workflows/release.yml
#	config/langgraph-config.example.yaml
#	tests/test_config_roundtrip.py
The sync pulls upstream's stricter test_config_roundtrip.py, whose
FROM_YAML_EXAMPLE_FIELDS pins the parsed example config. This fork
customizes the example (Lead Engineer identity), so the golden moves to
the fork's values: agent_runtime acp:claude, identity engineering-team,
the LE plugin set, the claude ACP agent, and the fenced-filesystem
defaults (allow_run off, REPLACE_ME project registered).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4LLynB9pTvpJzkqUjC98V
@protoquinn

protoquinn Bot commented Jul 20, 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 #10 | chore: sync fork to upstream protoAgent v0.104.5 (379 commits)

VERDICT: WARN


CI Status

  • 9 checks currently queued (including Python tests, Lint, and E2E smoke).

Diff Review

  • This is a massive sync (379 commits) from protoAgent v0.104.5.
  • The author has explicitly handled conflict resolutions for .github/workflows/docker-publish.yml and release.yml by moving to repo variables, which is the correct pattern for forks to avoid divergence.
  • config/langgraph-config.example.yaml correctly preserves the Lead Engineer specific blocks while adopting upstream changes.
  • Local verification by the author reports 3842 tests passed, though CI is still pending.

Observations

  • GAP: diff_summary failed (406 Too Large) due to the volume of changes (300+ files). I cannot manually verify every line of the 379 commits.
  • GAP: clawpatch_review failed as the repo is not yet in the project registry.
  • LOW: CI is still settling.

Note on CI: CI is currently pending. Per the approve-on-green policy, this review will be automatically promoted to APPROVED once all checks settle terminal-green, provided no blocking issues are raised.

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 20, 2026

Copy link
Copy Markdown

Submitted COMMENT review on #10.

@protoquinn

protoquinn Bot commented Jul 20, 2026

Copy link
Copy Markdown

✅ CI went terminal-green with no blockers on the prior review — promoting it to APPROVED per the approve-on-green policy (#748).

@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 6df904e into main Jul 20, 2026
10 checks passed
@mabry1985
mabry1985 deleted the chore/sync-upstream-v0.104.5 branch July 20, 2026 07:19
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