chore: sync fork to upstream protoAgent v0.104.5 (379 commits)#10
Conversation
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>
…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#2063–protoLabsAI#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
|
👀 Quinn is reviewing — verdict (PASS / WARN / FAIL) + findings to follow. |
There was a problem hiding this comment.
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
protoAgentv0.104.5. - The author has explicitly handled conflict resolutions for
.github/workflows/docker-publish.ymlandrelease.ymlby moving to repo variables, which is the correct pattern for forks to avoid divergence. config/langgraph-config.example.yamlcorrectly 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_summaryfailed (406 Too Large) due to the volume of changes (300+ files). I cannot manually verify every line of the 379 commits. - GAP:
clawpatch_reviewfailed 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
|
Submitted COMMENT review on #10. |
|
✅ CI went terminal-green with no blockers on the prior review — promoting it to APPROVED per the approve-on-green policy (#748). |
There was a problem hiding this comment.
CI terminal-green, no blockers on prior review — auto-approving on green (protoLabsAI#748).
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=trueandIMAGE_NAME=protolabsai/leadengineerare 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 newfleet.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) — cleanlint-imports(import-linter 2.11) — 3 contracts kept, 0 brokenscripts/gen_attribution.py --check— in syncpython -m pytest tests/ -q— 3842 passed, 5 skippedMerging
🤖 Generated with Claude Code
https://claude.ai/code/session_01L4LLynB9pTvpJzkqUjC98V