Repository navigation
Conversation
…#3214) (#3231) * fix(cleanup): scope the #1581 orphan agent-volume sweep to this stack (#3214) Docker volumes are daemon-global while the sweep's ownership check reads one stack's DB, so a second stack on a shared daemon reclaimed every other stack's unattached agent volumes — force-removed, logged at INFO. - Every agent data volume is created through docker_utils. agent_volume_labels, which stamps trinity.instance=<installation_id> (instance_identity.get_instance_id — the durable id the alert label already abbreviates; one source). All six creation sites use it, pinned by a guard test. - The sweep lists candidates on the instance label's key AND value and skips the cycle when the id is unresolvable. - is_reclaimable_agent_volume refuses a volume labelled for another instance, closing the same hole on the retention-purge path (two stacks can both have an agent `alpha`). - Legacy (unlabelled) volumes fail closed: labels are immutable, so the sweep never reclaims them; unowned+unattached ones are named in one WARNING per change of that set for a human. Own agents' volumes are still removed at retention purge, which is ownership-row driven. - Reclaims and removals log at WARNING as unrecoverable, naming volumes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(#3214): classify the volume-stamp mint; stub the new lifecycle import - test_2669: get_instance_id() mints installation_id on purpose. A volume created before the id exists would carry no owner and never be reclaimed. It gets an _ALLOWED_USES entry with that reason, like the label tier's existing entry. - test_agent_readiness_probe: the hand-written services.docker_utils stub lacked agent_volume_labels, which lifecycle.py now imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… — never re-send on silence (Abilityai/trinity-enterprise#568) (#3240) The receipts already existed on every dispatch route (#914, #2661, #2670); what was missing was the caller knowing what they mean. In the 2026-09-08 cascade every duplicate dispatch was an agent re-sending after "could not confirm delivery". The delegation contract is now one text, taught wherever a caller reads it: - the platform prompt, §Agent Collaboration (DELEGATION_CONTRACT spliced into PLATFORM_INSTRUCTIONS): every agent, every turn, no image rebuild; - verbatim in the chat_with_agent and every dynamic chat_with_<agent> description (src/mcp-server/src/delegation_contract.ts); fan_out and send_message repeat its rule sentence and point at it; - the receipt itself: every execution_id receipt a chat_with_* caller gets says "Do not re-send: read the outcome with get_execution_result(...)". That covers the queued_timeout that chat() and task() write, the 409 in-flight replay, and the backend-authored accepted / queued / queued_timeout receipts, whose REST-only "Poll GET ..." line runAgentChat replaces. The leads claim nothing about liveness, because the backend replays an async receipt for 24 h (#3245). fan_out_timeout keeps its own #2670 wording. Claude Code shows a model only the first 2,048 characters of an MCP tool description (CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH). #2958 took the chat_with_agent description from 2,035 to 2,424 characters, so since 2026-09-24 Claude Code has cut its tail (the list_recent_executions advice and "prefer parallel=true, async=true"). It now publishes at 1,940 characters; per-mode detail moved into parameter descriptions, which are not cut. Other runtimes' MCP clients were not measured. The text names only what the platform produces today: no `replayed` (ent#566 has not shipped it), and agent_busy is checked against list_recent_executions before a re-send, because a usage-limit 429 is raised after the turn ran (#3244). The unverified "concurrent-duplicate guard will kill mid-execution" claim is gone; no such guard exists. Also drops chat_with_agent from PENDING in tool-description-budget.test.ts (#3234): the entry fits the cap now, and the test fails an entry that fits. Tests: tests/unit/test_ent568_delegation_contract.py (byte parity with the TS copy, every tool, argument, status and event it names proven real, budget) and src/mcp-server/src/delegation-contract.test.ts (the descriptions a real tools/list publishes, and the receipt messages on chat/task timeout, 409 replay, async accepted/queued and the pull-routed path). The CI boot smoke also imports dist/tools/dynamic-agents.js. Docs: requirements §37.5, architecture/mcp-server.md and execution.md, the feature flows that teach delegation, the agent guides, user docs. Follow-ups: #3232, #3233, #3244, #3245. Fixes Abilityai/trinity-enterprise#568 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
) (#3261) * fix(setup): deploy the system agent once /setup creates the admin (#3237) On a browser-claimed install (every one-click / marketplace path) the only system-agent deploy runs at boot, before /setup has created the admin, and fails with "Admin user 'admin' not found". Nothing retried it, so trinity-system stayed absent until a backend restart. /setup now schedules ensure_deployed() as a background task after the admin row is written, ahead of first-run seeding (the boot order; the seeder hosts its alerts on trinity-system). The task never raises, since Starlette runs background tasks in sequence, and skips without Docker. Fixes #3237 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(setup): show the system-agent deploy step in the first-time setup flow (#3237) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…prise#747, #748) (#3181) * feat(workspace): Discuss and Dismiss on asks (ent#747, ent#748) Dismiss (Abilityai/trinity-enterprise#748): one click ends a pending question or approval as disposition=dismissed (status stays cancelled) through the ask ending sink — audit, thin broadcast, and the filer's wake with its own framing. 5s client-side Undo window; nothing is sent until it lapses. Addressee only (person gate, uniform 404); a dismiss that loses a race, or lands past the deadline, is a no-op. Discuss (Abilityai/trinity-enterprise#747): opens one chat per ask with the asking agent (CAS link in a new platform-only context key), titled after the ask, with the ask as its tile; a turn-context provider puts the ask (id, kind, live status, options) in every turn there. The ask stays one pending row; a question can be answered from the composer with "Send as answer" (written as response). Also renders agent markdown in the ask context's "Your recent answers" list, the surface #3115 missed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): Discuss opens its new chat instead of "isn't available" The Inbox turned Discuss's open-thread into a bare /workspace/c/<id> push, and the #3140 guard read the just-created chat (not yet in the thread list) as not the viewer's. Route it through the shell's openThread, which adopts the id, and refresh the list. The chat tile now forwards open-thread too — its Discuss did nothing before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(workspace): an answered ask's result comes back to its chat (ent#747) An answer that woke an opted-in agent left the woken run's output in the execution history only — the person saw the agent start and never saw what it did. The resume run now carries the Workspace destination (the ask's discussion chat, else its attached chat; only when the answerer is the addressee), so the ent#457 completion report posts the result there as an agent message. The open chat watches for it for up to 5 minutes, since it has no history poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): address #3181 review — discussed-ask audience, link-write errors F1: an answered ask's resume run reports into the ask's DISCUSSION chat only. The attached-chat fallback (Main for a background ask) widened every answered ask's audience to the client — verbatim final reply, shared files, inherited by delegated children. The run is now told its reply goes to the person. Store watch narrowed to match. Tests pin turn_audience and delegation inheritance for the stamped run. F2: the discussion-link write tolerates only SQLite's busy snapshot (OperationalError). Any other failure, or a busy write with no racing link while the ask is still pending, is a retryable 503 instead of "409 This ask is already pending". F3: the discussion's system notice no longer quotes the agent-written title. F4: options are truncated before JSON encoding, keeping the closing quote. F5: Dismiss refuses alerts (422), like Discuss and the card. F7: real TestClient tests through get_portal_principal (agent key 403) and the live in-process limiter (429). Send as answer stands down while a reply chip or attachment is in the composer (#3168 collision). tables.py disposition comment lists dismissed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(workspace): correct #3181 review I1/I2 doc staleness operating-room.md: an answered ask's result returns to its discussion chat only; the attached chat is not a destination (F1), so an undiscussed answer's run stays owner-only. backend.md: turn_context now has one OSS provider, the discussed-ask line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…3227) * fix(pull): the pull sink applies post-turn delivery settings (#2329) A queued /task row carries delivery settings in backlog_metadata. The push drain applied them in run_async_task; the pull sink wrote the terminal and stopped. On a pull pilot a Chat-tab /task (save_to_session, trigger manual) succeeded and never reached the chat session, and the collaboration activity stayed started. - run_post_turn_delivery: the three post-turn steps (chat-session save and broadcast, collaboration activity, self-task finalisation), moved out of run_async_task. The push drain and the pull sink both call it. - request_from_metadata: the queued-request rebuild, shared by _spawn_drain and the pull sink. - Pull sink: on the CAS-won branch, spawns delivery when the metadata asks for it, then signals the sync waiter with the chat session id; signals at once when there is nothing to deliver. Delivery failure never blocks the signal. - A token-gated SUCCESS write refuses a row already SUCCESS or SKIPPED. The claim token survives the terminal write, so a worker's retried result POST won the CAS twice and would have saved the chat turn twice. A late SUCCESS still corrects a FAILED row. - close_execution_activity and the bulk close also close the collaboration / self-task activity a queued row names, so a terminal written outside run_async_task (pull sink, lease-reaper park, expire, watchdog) does not leave it started. - Sync /task keeps the chat_session_id the sink signals. Fixes #2329 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pull): keep a re-queued row's activities open; deliver only the applied terminal (#2329) - The lease reaper closes the dead attempt as CANCELLED on a row it has just re-queued. The queued-activity close now runs only for a row that is terminal, so the collaboration / self-task activity stays open for the re-delivered attempt (a CANCELLED close is never upgraded). - Sink delivery runs only when the row still holds the status its own CAS wrote, so FAILED then a late SUCCESS under one token saves the turn once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(pull): backlog expiry calls no activity closer — drop it from the #2329 close-owner docstrings merge-train: expire_stale_queued writes FAILED and calls no closer, so the docstrings and the test docstring overstated coverage. Mechanical, per the merge-train note on the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…n was approved for it (Abilityai/trinity-enterprise#752) (#3252) * test(isolation): restore the adapters.message_router binding after a patch.dict re-import test_channel_image_vision re-imports adapters.message_router inside a patch.dict(sys.modules, ...) that mocks adapters.base. patch.dict puts the original module back in sys.modules on exit, but not the `adapters` package attribute the re-import rebound, and `import adapters.message_router as mr` resolves through that attribute. A later test then received the module built on the mocked ChannelResponse: test_ent751_gate_callers:: test_a_channel_message_held_by_the_gate_is_answered_in_the_conversation failed after test_1533 + test_channel_image_vision, reproduced on the base commit 09f9d08 (pre-existing, order-dependent; surfaced by an -n auto run). An autouse fixture in the polluting file puts the binding back after every test. The three-file reproduction goes from 1 failed to 88 passed. Learnings fragment added (the 2026-08-10 package-attribute / sys.modules class). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(skills): a gated skill is refused inside the agent unless its run was approved for it (Abilityai/trinity-enterprise#752) The dispatch-time gate (#751) reads what a requester typed, so a request that names a gated skill only in prose ("please pay the invoice") reached the executor, and the agent's own Skill call loaded the skill. On Claude Code agents a PreToolUse hook now asks the platform before a skill loads into a run, and refuses it unless that run was cleared for it. - Hook: docker/base-image/hooks/skill-gate.py (bootstrap) + _skill_gate.py, registered by its own managed-settings drop-in (/etc/claude-code/managed-settings.d/50-skill-gate.json, root 0444, exec form under `env -i` + `-I -S`, LD_* pinned empty) on Skill and on Agent/Task subagent `skills:` preloads. Identity from /proc (the claude process's launch env, the container's), never its own env. Every outcome is exit 0 or 2 under a 10 s deadline — for this CLI exit 1, a crash or a hook timeout lets the tool run. No verdict → a root-owned marker decides: an agent with gates fails closed, every other agent keeps its skills. - Check: POST /api/skill-gate/check (routers/skill_gate.py), the agent from its key (get_self_agent), every verdict a 200; refusals hand the request back ("…with a message that includes /pay-invoice…"), name no person or role, and are audited skill_gate_refused (throttled). - Clearance is execution + skill (D3), not an input hash: #751's approved-run record, or a new `self_approved` record that record_self_approval writes at /task, /chat (moved from admission to the row's setup — the agent receives the row's id, not the capacity slot's) and the execute_task backstop. No migration: a new value in skill_gate_requests.state, outside the lattice. - A self-approved /chat turn runs in its own session (isolated_session), so the next caller never resumes a context with the skill loaded. - Marker synced at start/recreate (gated agents only), self-healed from the check (rate-limited) and serialised per agent; /health reports skill_gate_hook. - Review hardening: a skill or subagent definition whose name the hook cannot read (or an unterminated front matter) is "could not tell", never "not gated"; block-scalar names are read; no subagent_type means general-purpose (a definition may override it); CLAUDE_CONFIG_DIR from the claude env adds its dirs; refusal audits are capped per agent (the run id is the caller's); a logger that cannot start never changes the verdict. Every wired call site and each review fix was mutation-checked. Inert until ent#753 supplies the gate map. Stated limits in requirements/security.md §26.13 (Claude Code only; not a boundary against an adversarial executor — credential confinement is, R23 / ent#558). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): the hook allows a Skill call inside a cleared run; a leading slash never reaches it (Abilityai/trinity-enterprise#752) The localhost eyeball showed that Claude Code expands an approved or self-approved request that starts with /pay-invoice without any Skill call, so the hook is never asked about it; the dispatch-time check decides those. The hook's allow path is a Skill call inside the cleared run (a slash mid-sentence, or the model choosing the skill). - skill-gate.md Testing step 7 now uses the mid-sentence form for the approved and self-approved cases, adds the isolated /chat probe and the no-run case, replaces "stop the backend" (the chat itself goes through it) with a check route that answers 503, and saves the running base image before the build so the restore never downgrades the agents. - §26.13 and the flow's limits say what a clearance lets through. - "the session tab" is the Chat tab. - Status records the 2026-10-05 eyeball. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(skills): the real-/proc reader test says it is Linux only, so test-runner classifies its skip (Abilityai/trinity-enterprise#752) The skip matched no rule in test-runner's skip-patterns.txt and showed up as an unclassified warning in /verify-local's unit stage. Its skipif is exactly 'not Linux', so the reason now says so and the existing GATED:Linux only rule classifies it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…ine (Abilityai/trinity-enterprise#798) (#3257) Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… carry the ADR-0011 line (Abilityai/trinity-enterprise#797) (#3258) * docs(positioning): README carries the ADR-0011 headline (Abilityai/trinity-enterprise#797) Hero alt text, H2, lede, "The solution" line and footer move onto the ADR-0011 line; the enterprise-modules sentence drops the bare "SSO" (T3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(positioning): installer, image listings and CLI carry the one-line form (Abilityai/trinity-enterprise#797) install.sh banner (width kept at 60), AWS/DO listing titles, AMI ami_description, CFN Description first line, DO/Vultr MOTD taglines, and the CLI docstring, pyproject description, Homebrew desc, src/cli/README and docs/CLI.md now read "the operating system for the AI-native company". Description strings only; no other value changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(positioning): first-run screen carries the Trinity one-liner (Abilityai/trinity-enterprise#797) The /setup lede now opens with the ADR-0011 line; the CTA and the Governed/Auditable/Your-infrastructure chips are unchanged. Text only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…tyai/trinity-enterprise#801) (#3259) * feat(positioning): runtime prompts carry the Trinity one-liner (Abilityai/trinity-enterprise#801) * test(positioning): case-insensitive absence guard for the retired taglines (Abilityai/trinity-enterprise#801) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…yai/trinity-enterprise#800) (#3260) * docs(positioning): engineer context and template card carry the ADR-0011 line (Abilityai/trinity-enterprise#800) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(positioning): memory, onboarding and diagram docs carry the ADR-0011 line (Abilityai/trinity-enterprise#800) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(positioning): name all three shipped runtimes in the supporting-facts line (Abilityai/trinity-enterprise#800) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…here is no room above (#3264) (#3267) On the Workspace agent band the activity chart is 23px tall, sits just under the page header and inside ScanlineReveal's clip-path. The tooltip was an `absolute bottom-full` child of the bar, so it always opened upward and its top rows (the date, the top buckets) were covered. StackedBarChart now teleports one tooltip to <body>, position: fixed, placed from the hovered bar's rect: above when it fits, below when it does not, clamped 8px inside the viewport on every side. It stays hidden until measured and follows the bar through scroll and resize. Applies to every caller (Workspace band, agent Overview, canvas charts). Fixes #3264 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… resource.url, A2A settled-reply metadata (#3215) (#3217) * fix(nevermined): resolve the plan's x402 scheme for 402/verify/settle (#3215) `_build_payment_required` passed no `scheme=` to the SDK helper, so every 402 — and every verify and settle built from the same helper — advertised the SDK default `nvm:erc4337`. The facilitator is POSTed that requirements document verbatim, so `accepts[0].scheme` is the only channel the scheme travels through: a fiat (card) plan's token was always checked against crypto requirements and rejected. Card plans were unpayable. - `PlanScheme(scheme, network)` + `default_plan_scheme(env)`; the erc4337 environment map stays an explicit frozen literal (a payments-py bump must not move a live agent's network, #3216). - `_build_payment_required(..., plan_scheme=None)` — `None` is the pre-#3215 document, so crypto bytes are unchanged per environment (golden test). - `scheme_from_token` reads `accepted.{scheme,network,planId}` off the presented token, allow-listed against the SDK's scheme/network vocabularies and checked against this agent's plan id. This is what verify and settle use: it removes every network call from the money path and makes a settle re-driven hours later byte-stable with its own verify. - `resolve_plan_scheme` (402 and `/info` only) parses `plans.get_plan` with the SDK's own keys — parity-tested against `resolve_scheme`/`resolve_network` — behind a per-(env, plan_id) cache, single-flight futures, its own 2-slot gate (never a facilitator slot, so an anonymous 402 flood cannot starve paying traffic), stale-while-revalidate and a WARN once per negative window. The SDK swallows these failures at DEBUG, which is how a card plan silently stayed crypto with nothing in Trinity's logs. 61 new tests; the 196-test ent#679/#3185 baseline stays green unchanged. Mutations proven red: dropping `scheme=` (5 red), swapping settle's token-derived scheme for a plan lookup (2 red). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(nevermined): one public origin for the card and both 402 doors (#3215) AC5. The x402 402's `resource.url` is what a buyer's token is minted and verified against, and the facilitator compares origin+path — so one wrong scheme character mints a token for a URL the client never calls. Both payment doors built it from `request.base_url`, which is http on a standard Trinity deployment for two independent reasons: - `docker-compose.prod.yml` / `docker-compose.hosted.yml` override the image `command:` and drop the Dockerfile CMD's `--proxy-headers --forwarded-allow-ips=*`, so uvicorn does not trust `X-Forwarded-*` at all and `request.url.scheme` stays http behind ANY proxy. Restoring those flags is a trust change (it re-prices `request.client.host` for every per-IP limiter, and agents on the agent network reach `backend:8000` directly), so it stays deferred — which is why `utils/public_url.py` reads the RAW header and never `request.url.scheme`. - the frontend nginx forwarded `X-Forwarded-Proto $scheme`, and `$scheme` is http on that hop (TLS terminates upstream), clobbering the real https before the backend saw it. Now a `$fwd_proto` map preserves an upstream https and otherwise falls back to this hop's scheme, on all three proxied locations (`/api/`, `/a2a/`, `/mcp`) — a door that lied about the scheme on one of them would mint tokens for the wrong origin just the same. `public_base_url(request, configured=, frontend_url=)` owns the precedence: the operator's declared origin (Settings `public_chat_url` → `PUBLIC_CHAT_URL` → `FRONTEND_URL`) when it is the host the caller actually used, else the request host with an https upgrade — never a downgrade — from the raw header. The same-host restriction keeps a caller that reached a private host from being sent to a public one whose narrow tunnel may not route that path. One helper now serves the agent card, the paid door and the A2A door, so a single request yields a single origin; `_base_url_from_request` also starts honouring the Settings row, as the Telegram/WhatsApp webhook URLs already do. `GET /api/paid/{name}/info` — the card's `paymentInfoUrl`, from which a payment-aware client pays on its first request — now loads the config WITH the key so it can resolve the plan's real scheme, and emits an absolute `resource.url`. The key is used server-side only; a test pins that it never reaches the body. 34 new tests; 291 green across the #3215 files and the ent#679/#3185 baseline. Mutation proven red: removing the header upgrade → 9 red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(nevermined): mirror the A2A payment receipt onto Task.metadata, plus docs (#3215) Operator scope-add (2026-10-04T11:48:59Z): a settled `message/send` returned a Task with `metadata: null` while `a2a-protocol.md` promised "a normal A2A Task whose metadata carries the payment status and a receipt". The metadata WAS built and placed — on `status.message.metadata`, which is the spec location (the provider SDK's own `X402A2AUtils` reads exactly there) — but top-level `Task.metadata` was never set, so a typed `a2a-sdk` client rendered a charged turn as `metadata: null`. `_task_object` now mirrors it from the same assignment, the same dict object, so the two locations cannot drift. The docs name `status.message.metadata` as the primary location and the mirror as the compatibility half. The free path still emits no `metadata` key at all, so an unpaid Task's bytes and every idempotency snapshot built from them are unchanged. Docs: - `user-docs/integrations/a2a-protocol.md` — names both locations precisely. - `user-docs/integrations/nevermined-payments.md` — crypto vs card plans and the scheme/network each advertises; why the 402's `resource.url` origin matters and which knob fixes it. - `user-docs/guides/deploying/public-access.md` — the A2A door was published by #3213 and the narrow tunnel table had no row for it. Added with ANCHORED rules (`^/a2a/[^/]+$`, `^/a2a/[^/]+/\.well-known/agent-card\.json$`): an unanchored `/a2a/*` would also match `/api/agents/{name}/a2a/call`, Trinity's authenticated OUTBOUND caller, which has no business on a public hostname. - `memory/requirements/public-access.md` §23.2–23.3, `memory/requirements/mcp.md` FR-4 + new FR-4a, `memory/feature-flows/nevermined-payments.md` (two new sections + the two new knobs), `memory/architecture/backend.md` one-liners for `nevermined_payment_service.py`, `paid.py` and `a2a.py`. 11 new tests; 382 green across the #3215 files and every payments/A2A neighbour. Mutation proven red: removing the mirror → 6 red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(a2a): card keeps the configured public origin; doors stay same-host (#3215 review I3) #3215 made ONE origin rule serve the agent card and both payment 402s. The doors needed same-host — an x402 token is minted and facilitator-verified against `resource.url`, so a 402 quoting another origin is unpayable. But the card is the opposite kind of document, and same-host regressed it: the `get_agent_a2a_card` MCP tool proxies the card route from `backend:8000`, so a card fetched there advertised `http://backend:8000/a2a/{name}` to every external buyer. That is dev's behaviour broken on an existing surface, and the plan's own T5 text named configured-first as "keep the card as is". Still one module and one upgrade rule, now one keyword apart: * `public_base_url(..., configured_wins=False)` — default unchanged, so every door's bytes are identical. `True` returns the declared origin whatever host the caller used, and falls through to the SAME request-host + upgrade-only raw `X-Forwarded-Proto` logic when nothing is configured. * `routers/a2a._card_base_url` wraps it for the two card routes only (the authenticated per-agent card and the public well-known card). The A2A JSON-RPC door, the paid door and `/info` keep T5 exactly as built. Safe because the two only differ where it cannot matter: a buyer that follows the card arrives on the configured host, so the door's own same-host rule mints that 402 for that very origin — the 402 is byte-identical to the URL the card sent it to (pinned by a test). With nothing configured the two are the same function. Tests: 14 new in `test_3215_public_base_url.py`, 10 red before the fix — `TestConfiguredWins` at the helper, `TestCardAndDoorOverTheRealRoutes` over the real routes (both card surfaces and the anonymous 402 on one internal host, which is the only layer that can catch a call site passing the wrong precedence), and `TestOneOriginPerRequest` adjusted to the new rule rather than trimmed. The route-level fixture imports `dependencies` at collection time on purpose: `tests/unit/conftest.py` pops it from `sys.modules` between tests, so a fixture-local import makes `dependency_overrides` stop matching after the first test. 405 green in the #3215/#3185/ent#679 batch (391 before). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(nevermined): cancelled plan-lookup leader fails followers loudly (#3215 review I4) The single-flight leader's `finally` answered its followers either way, but with the WRONG answer when it had nothing: a cancelled leader (its caller disconnected, its request shut down) completed the shared future with `default_plan_scheme(...)`. Every follower then returned crypto for a card plan with no WARN, no negative window opened, and last-known-good never consulted — the exact silent mis-advertisement #3215 exists to stop, reintroduced at the one place the service is not looking. A leader that resolved nothing now sets `PlanLookupLeaderLost` on the future instead. Each follower already catches it, so it falls into its existing `_record_plan_lookup_failure` branch: the 402 still gets an answer (fail-open is the accepted design), but it is last-known-good when there is one, the negative window opens, and the WARN names the reason. The leader's own `CancelledError` still propagates unchanged — `finally` swallows nothing. The future's exception is marked retrieved, so a cancelled 402 with no followers does not log "Future exception was never retrieved" at GC. Tests: 4 new in `test_3215_plan_scheme.py::TestCancelledLeaderFailsFollowersLoudly` — the WARN and the negative window, last-known-good preferred over the default, no asyncio noise with zero followers, and the successful-leader path proved untouched. 2 go red with the fix reverted (verified against a scratch copy, then restored byte-identical); the other 2 are the no-regression arms. 409 green in the #3215/#3185/ent#679 batch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(nevermined): the cancelled-leader test uses a genuinely slow leader (#3215) `svc2._get_payments_client = svc._get_payments_client` handed the leader svc's delay-less `_Plans`, so its lookup finished in microseconds and whether `leader.cancel()` landed first was a thread-vs-event-loop race: green on Linux CI, red on every macOS run. The cache and the in-flight registry are module-global, so last-known-good is shared without sharing the client — drop the override and let the 0.2 s delay do its job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…n the message field (Abilityai/trinity-enterprise#784) (#3219) * feat(workspace): opening an agent resolves to a new chat (Abilityai/trinity-enterprise#784) Replaces `landingThread` with one pure rule, `agentLanding`, that every door which has to RESOLVE a landing calls. ent#523 landed you in the chat you were most recently active in; most visits to an agent start new work, so resuming cost two actions every time. The default is now a new, empty chat. Nothing is minted by the landing: the helper is pure and returns a null session, and the row is born on the first send (`newThread`, ent#451), so repeated visits accumulate no empty chats. An unused Main is already kept out of the chat list by `sidebarThreadsOf` — the AC 4 guard, still green. `agentLanding` takes `lastOpenSessionId` as the seam ent#621's agent-switch keys will pass, honoured only when it still names a live, unarchived chat of this agent in the principal's own thread list — so a stale or forged id falls back to the default rather than landing somewhere it should not (#3140 class). No Map is built here; ent#621 adds the state and the handler. `resolveAgentLanding` keeps its signature and delegates, so the `?agent=` deep link and the sidebar row cannot drift. `forceNew` stays accepted for existing `?new=1` links and is now redundant rather than wrong. Shell wiring (`landOnAgent`, the focus gate) follows in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(workspace): land on the new chat, and focus only when asked (Abilityai/trinity-enterprise#784) Wires the shell to the ent#784 rule and gates the composer focus. `landOnAgent` is now synchronous. The awaited `ensureMainListed` is gone from the landing path — the rule needs nothing from the network — and with it the overtake race that awaited round trip required. The pinned Main is still minted on the first visit; `watch(activeAgentName)` owns that promise (ent#523) and no longer blocks the landing. The landed chat KEEPS `/workspace/a/:name` rather than escaping to bare `/workspace`, so a reload, a bookmark or a copied link still names the agent; the first send replaces it with the thread's own URL as today. An idempotency guard makes the second watcher fire (the thread list arriving) a no-op, so it cannot remount an unsent chat and throw away what was being typed. `landOnAgent` now asks `guardLeaveCall` BEFORE touching `activeAgentName`, which feeds `convKey`. Back/forward and a typed `/workspace/a/:name` reach the landing without passing a click door, so they could end a live voice call without a word (the ent#551 class; the click doors were already guarded). Focus is gated on WHY the fresh composer mounted, not on the pointer alone. A gesture (New chat, ⌘J, the agent picker, switch-agent) passes `always` and focuses on any pointer, keeping #2579 AC 2 on Android. A landing passes `fine-pointer` and focuses only where that cannot summon an on-screen keyboard — the "unprompted" the issue names. `shouldFocusOnRestore` is renamed `shouldAutoFocusComposer`: two reasons now share the one rule. Tests: the focus gate is proven by MOUNTING PortalConversation under a stubbed `matchMedia` (#2918), asserting `document.activeElement`, with focus shown to be elsewhere first. Reverting either the rule or the gate turns 10 cases red on behaviour — session ids and activeElement, not source text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(workspace): document the new-chat landing rule (Abilityai/trinity-enterprise#784) Tiered docs for a feature change: the owning area file, the requirement, and the feature flow. - `architecture/workspace.md` — `agentLanding` is the one landing rule and what it now answers; the synchronous `landOnAgent`, the kept URL, the guard order, and the `focusOnMount` mode. - `requirements/core-agent.md` — the ent#523 "One page" rule amended rather than rewritten: what reversed, and that landing mints no row. - `feature-flows/workspace-agents-at-the-centre.md` — the rule table, the "a tab is not a landing" note (the strip is now the only way back), the two doors that RESOLVE vs the gesture doors that ASSERT, and a `## Changed by ent#784` section rather than edited history. Names the follow-up: backend adoption of an empty Main on `new_thread=True` is not done here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): [I1] a thread-list re-fire must not re-mint over an adopted chat (Abilityai/trinity-enterprise#784) ent#784's landing stays on `/workspace/a/:name`, so the watcher that resolves that route can now re-fire with the route unchanged — once per thread-list refresh — for as long as the person stays there. `landOnAgent`'s idempotency guard keys on `startingNewChat`, which the first send clears, so a refresh settling in the window between `onSessionAdopted` and the asynchronous `router.replace` nulled `pendingSession` and bumped `convGen`, remounting an empty composer over the thread whose first reply was streaming (and handing the `/c/:id` watcher a #3140 "chat isn't available" for the chat just created). Guarded in the watcher instead: a list-only re-fire is a no-op, keyed on "the route did not change on this fire" AND on having already landed this name. Both clauses are load-bearing — the name alone swallows the cold deep link, whose only fire IS the list arriving with the param already in place. Deliberately NOT keyed on `pendingSession`: back/forward from `/workspace/c/:id` arrives with a session set and must still land fresh (T4). New behavioural pin `portalAgentLandingRemount.mount.spec.js` (shallow-mounts Portal.vue, asserts the conversation's props and instance identity, not source text): the adopted-session case went red before this change with `sessionId: null`; the three regression cases — composing, same-agent back/forward, cold deep link — were green before and stay green. Also fixes `portalUnavailableTargets.mount.spec.js`, which still asserted ent#523's landing (`/workspace/a/scout` → `/workspace/c/t1`) and was failing on this branch; it now proves the page opened by the conversation on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): [I3] mount cases for decision #13 and the T4 guard, not source-text pins (Abilityai/trinity-enterprise#784) `landOnAgent` was pinned only by regexes over its own function body (`workspaceNewChat.spec.js:181-211`), so nothing executed it: an inverted guard, or the right lines in the wrong order, reads byte-identically to a regex. The shell is mount-testable (`portalUnavailableTargets.mount.spec.js`), so these assert STATE instead. Decision #13 (ent#551 class) — back/forward reaches the landing without passing a click door, and `activeAgentName` feeds `convKey`, so a live call must be asked about BEFORE anything is written: mid-call, the dialog opens and the chat, its props and its instance identity are untouched; confirming then performs the landing it deferred; cancelling leaves call and chat exactly as they were. T4 — one navigation to an agent page mounts exactly ONE conversation, however often the thread list moves underneath it. Mutation-verified, both directions: with `guardLeaveCall` removed from `landOnAgent`, the two mid-call cases go red; with the T4 guards removed, the composing and one-mint cases go red. The source-text pins are kept as supplements (they still pin "no await / no ensureMainListed / no router.push" inside the function, which state cannot see). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): opening an agent lands on its newest draft (Abilityai/trinity-enterprise#784) F1 of the operator's 2026-10-05 reopen. `agentLanding` always answered a new chat, so a draft typed into an existing chat was not where opening the agent landed — and the draft mark on the agent's row pointed at words that clicking it could not get back to. `e2e/workspace-drafts.spec.js:92` and `:116` describe the wanted behaviour and were red at c8af3d7. The rule is now the ruling's precedence: a link naming a chat (never reaches here), then the ent#621 `lastOpenSessionId` seam unchanged as the first arm, then the agent's chat holding an unsent draft — newest `updatedAt`, with the unsaved `new:<agent>` chat weighed on the same clock — then a new chat. A `new:` winner is `sessionId: null`, which is where those words already live; a thread winner opens that thread and its remount restores the draft. The candidate set is the set that lights the sidebar's mark: `isDraftedThread` is shared by `agentsWithDrafts` and the new `draftedLandingFor` rather than copied, so "the mark means click here to continue" cannot drift — a room and an archived thread are excluded on both sides. The drafts map is passed IN by both doors (`landOnAgent`, `resolveAgentLanding`), never read inside the rule, so one rule serves every door and stays pure. `composerFocusMode` moves above the branch in `landOnAgent`: a landing on a drafted chat is the same kind of arrival, and `openThread` does not touch that ref, so a previous gesture's `always` would otherwise have made the next landing focus on a touch device (T3(b)). The restore's own caret already shares `shouldAutoFocusComposer`. The T4 remount guard is untouched: the watcher still returns on a list-only re-fire, and the drafted-thread arm leaves the agent URL through `openThread`, so the guard is never reached with a stale answer. Tests: `portalDraftLanding.spec.js` (each arm, newest-wins both ways, the tie, room/archived exclusion, and the property that every MARKED row is a landing candidate) and `portalDraftLanding.mount.spec.js`, which replays both e2e flows at the shell's seam — red at c8af3d7 on the e2e's own symptom, 2 of 3 failing with `sessionId: null` where the draft was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): opening an agent reuses its empty chat (Abilityai/trinity-enterprise#784) F2 of the operator's 2026-10-05 reopen: "opening an agent must not create a new chat every time; if an empty chat with that agent already exists it is reused, and at most one empty chat per agent exists at any time." Arm 4 of the precedence, under the drafts arm and above a new chat. `agentEmptyChat` is the read side: an unarchived, non-room thread of this agent with no message sent — ent#523's own "unused Main" test, widened to any row that fits it. Both fields are required because the two reads disagree: the cross-agent batch omits `message_count` (so an absent count must not read as used) while the per-agent read carries it (so a count of 2 must not read as empty just because `last_message_at` is missing). Several empty rows — legacy data — resolve Main first, then newest `created_at`, then id, so list order never decides and two doors reading one list cannot reuse two different rows. `ensureMainListed` is the write side, and the only place the "at most one" half can be held: it is a GET that INSERTS (`list_sessions` → `ensure_main_session`), so for an agent with an empty chat but no Main — legacy, since nothing mints a non-Main empty row today — visiting it would add a SECOND empty chat and then land on one of the two. It now returns early for that case. #2579's "the pinned tab has to be there" still holds for every agent whose chats are all used, which is the case it was about; that is pinned as its own mount case rather than left to the comment. No backend change: nothing here asks the server for anything it does not already do. Three existing specs carried fixtures with neither message field, which under the old rule meant "a thread exists" and under the ruling means "an empty chat to reuse" — the assertion they were written for. Each is updated to a USED row and the empty case is asserted separately, so none of them went green by accident: `portalAgentsAtCentre` (the landing arms), `workspaceAgentLanding` (the `?agent=` door, both id shapes) and `workspaceNewChat`, where `?new=1` is load-bearing again — it is the one way past the whole precedence, so the two answers now have to differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): a landed new chat keeps its rail, its new-thread intent and the caret (Abilityai/trinity-enterprise#784 review) ent#784 made /workspace/a/:name the URL a landed new chat RESTS on. Three things had only been true because nobody stayed there; each was reproduced in a browser against the rebased branch. - Clicking the row of the agent whose new chat is already on stage cleared `startingNewChat` in `openAgentPage` and then pushed the URL it was already on, so the landing never re-ran. The composer still read "New chat" and sent its first message without `new_thread`, which the server resolves to the agent's Main chat. The click is now a no-op that hands the caret back. - The rail rules still read the agent URL as "a page, not a conversation", so a landed new chat had no rail, and the first send (which moves the URL to /workspace/c/:id) slid one in beside a reply mid-stream. The URL is rail-free only until the landing has put the named agent on stage, and its column is reserved mid-load like any 1:1 route. - A landing that reuses a chat (the agent's empty one) goes through `openThread`, and a thread mount focuses nothing, so the caret stayed on the sidebar row. The shell hands it over under the landing's own fine-pointer rule. Tests: portalAgentLandingStage.mount.spec.js (10 cases, mounted shell). Four were red before the fix, for these reasons. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…#3250) * feat(operator-queue): backend sink accepts the reserved (something else) answer (#3242) The #2376 sink now handles SOMETHING_ELSE = "(something else)" before membership: accepted on any approval with an instruction in response_text, refused by name otherwise (instruction_required, reserved_value), and refused on platform-minted / gate approvals (not_off_menu). Every other unoffered string stays refused. validate_response_choice takes response_text keyword-only and required. - ask_service: gate refusal; ask_operator refuses the literal as an option - both writers map ReservedAnswerError to a named 422 - OperatorResponse.response_text bounded at 4000 - Workspace projection: decided_by_options boolean (T8 ruling) - recent answers render "Something else: <instruction>" - resume frame: platform sentence above the data fence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): contract text and MCP tools name (something else) (#3242) - platform prompt (both authored copies) + agent guide: the approval bullet and the queue-file write-back say what the reserved value means and that the instruction is in response_text; never list it as an option - test_1402 SENTINELS += "(something else)" - MCP: types.ts exports SOMETHING_ELSE; ask_operator, get_my_ask and respond_to_operator_queue descriptions name it; the respond tool keeps `error` and adds the backend's {status, code, message, offered_options?} (T5, additive) - py <-> ts parity test for the literal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): desktop Something else chip (#3242) - utils/operatorQueue.js: SOMETHING_ELSE (mirrored, parity-tested), offeredChips (drops the literal and the size-cap marker), decisionLabel, decidedByOptions; buildQueueResponse needs an instruction with the literal - QueueCard + QueueItemDetail: chips from offeredChips, a 'Something else' chip outside the v-for (hidden on gate approvals); typing with no pick arms it, the label and Send copy flip; Enter sends only after an explicit pick; maxlength 4000; Send rule is the shared builder - respondToItem returns true/false; the detail panel clears only on success - ResolvedCard + detail resolved view render 'Something else', never the literal Raw-colour counts unchanged (QueueCard 28/59, QueueItemDetail 26/55). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): Something else on /m and the Workspace (#3242) - /m: a 'Something else' chip in the options group (reuses ops-option-btn, hidden on gate approvals) opens the form; the consequence line says none of the options will run; Send reads 'Send instruction' and stays disabled until the instruction is typed; maxlength 4000 - PortalAsks: the chip after the offered chips, hidden when the projection says decided_by_options (T8 ruling); typing with no pick arms it, placeholder and Send aria-label flip; Enter never sends the auto-armed state; submit() sends the armed option through the shared builder. Diff kept to the approval template, helpers beside pick(), the body of submit() and one import line (open PR #3181 edits other hunks) - portalAsksTestidPrefix: the id inventory gains the chip's id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(operator-queue): the something-else answer on approvals (#3242) Document what #3242 built: the reserved (something else) decision an approval accepts besides its own options, with the instruction in response_text; the named 422s (instruction_required, reserved_value, not_off_menu, invalid_options at raise); the Something else chip on the desktop card and detail, /m and the Workspace, hidden on gate approvals on every surface (decided_by_options on the Workspace projection); the resume frame and contract text. Corrects every doc that still said an approval's answer must be one of its options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): drop the reversed Send-predicate source pin and dead optionsOf import (C1, I1) (#3242) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): pin readback, marker mirror, guide literal, near-miss literals; public decided_by_options; learnings (I2 I4 I5 I6 I7 I12) (#3242) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): carry the sink's decided_by_options on the operator projections; gate- is only the fallback (I3) (#3242) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…or contract, option and title limits (#3243) (#3253) * feat(operator-queue): backend sink accepts the reserved (something else) answer (#3242) The #2376 sink now handles SOMETHING_ELSE = "(something else)" before membership: accepted on any approval with an instruction in response_text, refused by name otherwise (instruction_required, reserved_value), and refused on platform-minted / gate approvals (not_off_menu). Every other unoffered string stays refused. validate_response_choice takes response_text keyword-only and required. - ask_service: gate refusal; ask_operator refuses the literal as an option - both writers map ReservedAnswerError to a named 422 - OperatorResponse.response_text bounded at 4000 - Workspace projection: decided_by_options boolean (T8 ruling) - recent answers render "Something else: <instruction>" - resume frame: platform sentence above the data fence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): contract text and MCP tools name (something else) (#3242) - platform prompt (both authored copies) + agent guide: the approval bullet and the queue-file write-back say what the reserved value means and that the instruction is in response_text; never list it as an option - test_1402 SENTINELS += "(something else)" - MCP: types.ts exports SOMETHING_ELSE; ask_operator, get_my_ask and respond_to_operator_queue descriptions name it; the respond tool keeps `error` and adds the backend's {status, code, message, offered_options?} (T5, additive) - py <-> ts parity test for the literal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): desktop Something else chip (#3242) - utils/operatorQueue.js: SOMETHING_ELSE (mirrored, parity-tested), offeredChips (drops the literal and the size-cap marker), decisionLabel, decidedByOptions; buildQueueResponse needs an instruction with the literal - QueueCard + QueueItemDetail: chips from offeredChips, a 'Something else' chip outside the v-for (hidden on gate approvals); typing with no pick arms it, the label and Send copy flip; Enter sends only after an explicit pick; maxlength 4000; Send rule is the shared builder - respondToItem returns true/false; the detail panel clears only on success - ResolvedCard + detail resolved view render 'Something else', never the literal Raw-colour counts unchanged (QueueCard 28/59, QueueItemDetail 26/55). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): Something else on /m and the Workspace (#3242) - /m: a 'Something else' chip in the options group (reuses ops-option-btn, hidden on gate approvals) opens the form; the consequence line says none of the options will run; Send reads 'Send instruction' and stays disabled until the instruction is typed; maxlength 4000 - PortalAsks: the chip after the offered chips, hidden when the projection says decided_by_options (T8 ruling); typing with no pick arms it, placeholder and Send aria-label flip; Enter never sends the auto-armed state; submit() sends the armed option through the shared builder. Diff kept to the approval template, helpers beside pick(), the body of submit() and one import line (open PR #3181 edits other hunks) - portalAsksTestidPrefix: the id inventory gains the chip's id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(operator-queue): the something-else answer on approvals (#3242) Document what #3242 built: the reserved (something else) decision an approval accepts besides its own options, with the instruction in response_text; the named 422s (instruction_required, reserved_value, not_off_menu, invalid_options at raise); the Something else chip on the desktop card and detail, /m and the Workspace, hidden on gate approvals on every surface (decided_by_options on the Workspace projection); the resume frame and contract text. Corrects every doc that still said an approval's answer must be one of its options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): drop the reversed Send-predicate source pin and dead optionsOf import (C1, I1) (#3242) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): pin readback, marker mirror, guide literal, near-miss literals; public decided_by_options; learnings (I2 I4 I5 I6 I7 I12) (#3242) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): carry the sink's decided_by_options on the operator projections; gate- is only the fallback (I3) (#3242) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): cap option count/length, agent titles and chip lookalikes on the native raise (#3243) One pure predicate in operator_queue_choices (shared with the file path in the next checkpoint): at most 5 options, the reserved (something else) never counted; at most 60 characters per option; an option reading as the chip ("Something else", any case, parenthesised or not) refused as invalid_options. Agent raises also get a hard 120-character title (title_too_long). Checked after the replay lookup, so a pre-cap ask's retry still replays, and before the rate cap, so a refusal spends no token. Env-overridable, floored at load (2 options / 16 chars / 40-char title). Mutation: with the _refuse_over_caps call removed, 9 tests in test_3243_atomic_asks.py go red (TestNativeRaise refusals). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): hold over-cap queue-file entries and name them on the marker (#3243) A NEW file entry over the option caps (or a chip lookalike) is held as invalid_options, an over-long title as invalid_title — the same predicate as the native raise, after the id check and before the depth/rate caps, so no rate token is spent. The ids (<=10) and the limits ride on platform.ingestion beside `reason`, so a self-clearing queue_full cannot hide them; _marker_differs now compares the key set so a fixed entry's id drops off. Cap holds never increment `held` (no "runaway or compromised agent" flood alert). Rows already ingested are never re-judged. Also replaces the CP1a env test's module reload (it handed other suites a stale OperatorQueueSyncService) with a test of the loader itself. Mutation: with the hold disabled, 4 TestFileHold tests go red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): teach atomic asks in the ask_operator contract and the prompt (#3243) ask_operator's description carries five authoring rules (one decision per ask; a one-glance title, at most 120; options name the choice only, with one bad-then-good example; at most 5 options of at most 60 characters, aim for under 40; context is labelled facts for people), the chip-lookalike rule, and the three new refusal codes with what to do about each. Field descriptions for title/question/options/context/proposal carry the per-field rule. The platform prompt and prompt.md gain a three-line "Write atomic asks" paragraph pointing at the tool, and the queue-file paragraph names the invalid_options / invalid_title holds. Agent guide: one pointer sentence. Sentinels: "One decision per ask", "invalid_options". A pytest pins the description and prompt to the phrases built from the Python caps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): document the atomic-ask caps and their env knobs (#3243) .env.example and both compose files gain OPERATOR_QUEUE_MAX_OPTIONS (5), OPERATOR_QUEUE_OPTION_MAX_CHARS (60) and OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS (120). security.md §26, the operating-room flow (step + revision row) and the api-endpoints catalog name the new codes, where they run, and the file hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): forward the three #3243 caps on the hosted compose so parity is green (#3243) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): quote cap defaults, name the agent title limit first, fold NFKC/zero-width chip lookalikes, register the test (#3243) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): describe the #3243 cap holds in the stale ingestion docs and add the §26.7 authoring-caps bullet (#3243) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): ask_operator fits Claude Code's 2,048-character description cap (#3243) Claude Code shows a model only the first 2,048 characters of a tool description (#3234); ask_operator was 2,676 at 3c14c23, so its last rules (the refusal codes and their remedies) were invisible to every agent. The description is now 1,710 characters, ordered by what a model acts on first: what the tool does and the fire-and-park contract, then the five atomic-ask rules, then the reserved (something else) rule. Field detail moved into the parameter descriptions, which are not cut: the receipt fields, differs and idempotency (request_id); title_too_long and its fix (title); the bad-then-good option example, options_required, too_many_options / option_too_long / invalid_options with their fixes (options); field_too_large (question, context, proposal); role_unassigned (to); how an ask ends (expires_at); reask_requires_link (supersedes_expired). Tests: the published descriptions of ask_operator, get_my_ask and respond_to_operator_queue are <= 2,048 over a real listTools, and every moved phrase is published on its field; the unit test keeps ask_operator <= 1,800 for headroom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…DELETE carries the deny list (Abilityai/trinity-enterprise#792) (#3276) * fix(files): normalise leading slashes before the deny list and fences; DELETE carries the deny list (Abilityai/trinity-enterprise#792) The backend file routes normalised a user path with posixpath.normpath, which keeps exactly two leading slashes, while the agent server's Path.resolve() reads them as one. A `//`-prefixed path matched none of the path-anchored deny patterns and neither capability fence. - _normalize_user_path collapses any run of leading slashes to one. - PUT, mkdir and DELETE send the agent the normalised path that was checked, not the raw input, so the check and the action read one string. - DELETE /files applies the deny list too (_is_user_deletable_path), after the access check and the skills.manage fence, before the container lookup. A directory that holds a path-anchored protected path is refused as well (.ssh, .claude, the home dir); anchors are derived from the pattern tuple. Refusal: 403 "Cannot delete protected path: {path}". Tests run against the shipped module: test_files_protected_paths.py no longer tests an inline copy. Added // rows, a delete table, the pinned anchor set, three Hypothesis properties, route-logic checks and a real-router check on all three routes (mkdir takes its path in the JSON body); // rows on the ent#596 fence; live-tier DELETE and // rows in test_files_guardrail_bypass.py. Mutations, each restored byte-identical: reverting the normaliser line turns 30 tests red; dropping the DELETE check 11; dropping the holding-directory clause 22; forwarding the raw path 4; cutting anchors at the first glob 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(guardrails): the in-agent file and read-only guards collapse leading slashes too (Abilityai/trinity-enterprise#792) PR review found the agent-side copy of the policy with the same shape: the PreToolUse file-guardrail.py and read-only-guard.py normalised file_path with os.path.normpath, which keeps exactly two leading slashes, then matched absolute patterns such as /home/developer/.ssh/*. In the shipped image a //-prefixed .ssh or .claude/settings.json path exited 0 where the one-slash form exited 2. files.py names file-guardrail.py as KEEP IN SYNC. - Both _normalise functions collapse any run of leading slashes to one. - tests/unit/test_ent792_file_guardrail_hook.py runs the shipped hook's main() against the shipped baseline; test_read_only_guard.py gains // rows. Reverting the hook fix turns 7 and 2 tests red; the fixed hooks, mounted into trinity-agent-base, deny the // forms in the real image. Review follow-ups in the same commit: - a string prefix of an anchor ("/et", "/home/dev") is not a directory above it: allowed rows plus a segment-based oracle, so `a.startswith(head)` no longer survives (5 red); - docs: the agent echoes the normalised path (path/deleted fields and its 404/409 messages); the flow's backend list names .env.*; only the skills.manage fence exists on dev; the security report records the hook finding the first pass missed and corrects its .claude/skills claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…preter; root-owned read-only config (Abilityai/trinity-enterprise#787) (#3273) - managed-settings.json: bash-guardrail, file-guardrail, read-only-guard and output-scanner register as `/usr/bin/env -i HOME=/home/developer /usr/local/bin/python3 -I -S <hook>` (command + args, timeout 30); policy env pins CLAUDE_CODE_SHELL_PREFIX to "". - execution_env: CLAUDE_CODE_SHELL_PREFIX joins PROTECTED_KEYS. - Read-only config: the backend writes/removes root-owned /opt/trinity/read-only-config.json through a root docker exec (config JSON passed as a positional argument). The guard and the Codex runtime read it first; the home copy ~/.trinity/read-only-config.json is still written and decides only while the root file is missing. - startup.sh: the legacy ~/.claude/settings.json is removed only when its SHA-256 matches a version the image shipped there. - Dockerfile: build smoke runs every registered hook exactly as the managed file registers it. Mutation evidence: reverting managed-settings.json, startup.sh, read-only-guard.py, read_only.py or execution_env.py each turns tests/unit/test_ent787_guardrail_exec_form.py red (16 / 2 / 2 / 6 / 1 failures). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rom the message field (ent#621) (#3241) * feat(workspace): opening an agent resolves to a new chat (Abilityai/trinity-enterprise#784) Replaces `landingThread` with one pure rule, `agentLanding`, that every door which has to RESOLVE a landing calls. ent#523 landed you in the chat you were most recently active in; most visits to an agent start new work, so resuming cost two actions every time. The default is now a new, empty chat. Nothing is minted by the landing: the helper is pure and returns a null session, and the row is born on the first send (`newThread`, ent#451), so repeated visits accumulate no empty chats. An unused Main is already kept out of the chat list by `sidebarThreadsOf` — the AC 4 guard, still green. `agentLanding` takes `lastOpenSessionId` as the seam ent#621's agent-switch keys will pass, honoured only when it still names a live, unarchived chat of this agent in the principal's own thread list — so a stale or forged id falls back to the default rather than landing somewhere it should not (#3140 class). No Map is built here; ent#621 adds the state and the handler. `resolveAgentLanding` keeps its signature and delegates, so the `?agent=` deep link and the sidebar row cannot drift. `forceNew` stays accepted for existing `?new=1` links and is now redundant rather than wrong. Shell wiring (`landOnAgent`, the focus gate) follows in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(workspace): land on the new chat, and focus only when asked (Abilityai/trinity-enterprise#784) Wires the shell to the ent#784 rule and gates the composer focus. `landOnAgent` is now synchronous. The awaited `ensureMainListed` is gone from the landing path — the rule needs nothing from the network — and with it the overtake race that awaited round trip required. The pinned Main is still minted on the first visit; `watch(activeAgentName)` owns that promise (ent#523) and no longer blocks the landing. The landed chat KEEPS `/workspace/a/:name` rather than escaping to bare `/workspace`, so a reload, a bookmark or a copied link still names the agent; the first send replaces it with the thread's own URL as today. An idempotency guard makes the second watcher fire (the thread list arriving) a no-op, so it cannot remount an unsent chat and throw away what was being typed. `landOnAgent` now asks `guardLeaveCall` BEFORE touching `activeAgentName`, which feeds `convKey`. Back/forward and a typed `/workspace/a/:name` reach the landing without passing a click door, so they could end a live voice call without a word (the ent#551 class; the click doors were already guarded). Focus is gated on WHY the fresh composer mounted, not on the pointer alone. A gesture (New chat, ⌘J, the agent picker, switch-agent) passes `always` and focuses on any pointer, keeping #2579 AC 2 on Android. A landing passes `fine-pointer` and focuses only where that cannot summon an on-screen keyboard — the "unprompted" the issue names. `shouldFocusOnRestore` is renamed `shouldAutoFocusComposer`: two reasons now share the one rule. Tests: the focus gate is proven by MOUNTING PortalConversation under a stubbed `matchMedia` (#2918), asserting `document.activeElement`, with focus shown to be elsewhere first. Reverting either the rule or the gate turns 10 cases red on behaviour — session ids and activeElement, not source text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(workspace): document the new-chat landing rule (Abilityai/trinity-enterprise#784) Tiered docs for a feature change: the owning area file, the requirement, and the feature flow. - `architecture/workspace.md` — `agentLanding` is the one landing rule and what it now answers; the synchronous `landOnAgent`, the kept URL, the guard order, and the `focusOnMount` mode. - `requirements/core-agent.md` — the ent#523 "One page" rule amended rather than rewritten: what reversed, and that landing mints no row. - `feature-flows/workspace-agents-at-the-centre.md` — the rule table, the "a tab is not a landing" note (the strip is now the only way back), the two doors that RESOLVE vs the gesture doors that ASSERT, and a `## Changed by ent#784` section rather than edited history. Names the follow-up: backend adoption of an empty Main on `new_thread=True` is not done here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): [I1] a thread-list re-fire must not re-mint over an adopted chat (Abilityai/trinity-enterprise#784) ent#784's landing stays on `/workspace/a/:name`, so the watcher that resolves that route can now re-fire with the route unchanged — once per thread-list refresh — for as long as the person stays there. `landOnAgent`'s idempotency guard keys on `startingNewChat`, which the first send clears, so a refresh settling in the window between `onSessionAdopted` and the asynchronous `router.replace` nulled `pendingSession` and bumped `convGen`, remounting an empty composer over the thread whose first reply was streaming (and handing the `/c/:id` watcher a #3140 "chat isn't available" for the chat just created). Guarded in the watcher instead: a list-only re-fire is a no-op, keyed on "the route did not change on this fire" AND on having already landed this name. Both clauses are load-bearing — the name alone swallows the cold deep link, whose only fire IS the list arriving with the param already in place. Deliberately NOT keyed on `pendingSession`: back/forward from `/workspace/c/:id` arrives with a session set and must still land fresh (T4). New behavioural pin `portalAgentLandingRemount.mount.spec.js` (shallow-mounts Portal.vue, asserts the conversation's props and instance identity, not source text): the adopted-session case went red before this change with `sessionId: null`; the three regression cases — composing, same-agent back/forward, cold deep link — were green before and stay green. Also fixes `portalUnavailableTargets.mount.spec.js`, which still asserted ent#523's landing (`/workspace/a/scout` → `/workspace/c/t1`) and was failing on this branch; it now proves the page opened by the conversation on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): [I3] mount cases for decision #13 and the T4 guard, not source-text pins (Abilityai/trinity-enterprise#784) `landOnAgent` was pinned only by regexes over its own function body (`workspaceNewChat.spec.js:181-211`), so nothing executed it: an inverted guard, or the right lines in the wrong order, reads byte-identically to a regex. The shell is mount-testable (`portalUnavailableTargets.mount.spec.js`), so these assert STATE instead. Decision #13 (ent#551 class) — back/forward reaches the landing without passing a click door, and `activeAgentName` feeds `convKey`, so a live call must be asked about BEFORE anything is written: mid-call, the dialog opens and the chat, its props and its instance identity are untouched; confirming then performs the landing it deferred; cancelling leaves call and chat exactly as they were. T4 — one navigation to an agent page mounts exactly ONE conversation, however often the thread list moves underneath it. Mutation-verified, both directions: with `guardLeaveCall` removed from `landOnAgent`, the two mid-call cases go red; with the T4 guards removed, the composing and one-mint cases go red. The source-text pins are kept as supplements (they still pin "no await / no ensureMainListed / no router.push" inside the function, which state cannot see). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): opening an agent lands on its newest draft (Abilityai/trinity-enterprise#784) F1 of the operator's 2026-10-05 reopen. `agentLanding` always answered a new chat, so a draft typed into an existing chat was not where opening the agent landed — and the draft mark on the agent's row pointed at words that clicking it could not get back to. `e2e/workspace-drafts.spec.js:92` and `:116` describe the wanted behaviour and were red at c8af3d7. The rule is now the ruling's precedence: a link naming a chat (never reaches here), then the ent#621 `lastOpenSessionId` seam unchanged as the first arm, then the agent's chat holding an unsent draft — newest `updatedAt`, with the unsaved `new:<agent>` chat weighed on the same clock — then a new chat. A `new:` winner is `sessionId: null`, which is where those words already live; a thread winner opens that thread and its remount restores the draft. The candidate set is the set that lights the sidebar's mark: `isDraftedThread` is shared by `agentsWithDrafts` and the new `draftedLandingFor` rather than copied, so "the mark means click here to continue" cannot drift — a room and an archived thread are excluded on both sides. The drafts map is passed IN by both doors (`landOnAgent`, `resolveAgentLanding`), never read inside the rule, so one rule serves every door and stays pure. `composerFocusMode` moves above the branch in `landOnAgent`: a landing on a drafted chat is the same kind of arrival, and `openThread` does not touch that ref, so a previous gesture's `always` would otherwise have made the next landing focus on a touch device (T3(b)). The restore's own caret already shares `shouldAutoFocusComposer`. The T4 remount guard is untouched: the watcher still returns on a list-only re-fire, and the drafted-thread arm leaves the agent URL through `openThread`, so the guard is never reached with a stale answer. Tests: `portalDraftLanding.spec.js` (each arm, newest-wins both ways, the tie, room/archived exclusion, and the property that every MARKED row is a landing candidate) and `portalDraftLanding.mount.spec.js`, which replays both e2e flows at the shell's seam — red at c8af3d7 on the e2e's own symptom, 2 of 3 failing with `sessionId: null` where the draft was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): opening an agent reuses its empty chat (Abilityai/trinity-enterprise#784) F2 of the operator's 2026-10-05 reopen: "opening an agent must not create a new chat every time; if an empty chat with that agent already exists it is reused, and at most one empty chat per agent exists at any time." Arm 4 of the precedence, under the drafts arm and above a new chat. `agentEmptyChat` is the read side: an unarchived, non-room thread of this agent with no message sent — ent#523's own "unused Main" test, widened to any row that fits it. Both fields are required because the two reads disagree: the cross-agent batch omits `message_count` (so an absent count must not read as used) while the per-agent read carries it (so a count of 2 must not read as empty just because `last_message_at` is missing). Several empty rows — legacy data — resolve Main first, then newest `created_at`, then id, so list order never decides and two doors reading one list cannot reuse two different rows. `ensureMainListed` is the write side, and the only place the "at most one" half can be held: it is a GET that INSERTS (`list_sessions` → `ensure_main_session`), so for an agent with an empty chat but no Main — legacy, since nothing mints a non-Main empty row today — visiting it would add a SECOND empty chat and then land on one of the two. It now returns early for that case. #2579's "the pinned tab has to be there" still holds for every agent whose chats are all used, which is the case it was about; that is pinned as its own mount case rather than left to the comment. No backend change: nothing here asks the server for anything it does not already do. Three existing specs carried fixtures with neither message field, which under the old rule meant "a thread exists" and under the ruling means "an empty chat to reuse" — the assertion they were written for. Each is updated to a USED row and the empty case is asserted separately, so none of them went green by accident: `portalAgentsAtCentre` (the landing arms), `workspaceAgentLanding` (the `?agent=` door, both id shapes) and `workspaceNewChat`, where `?new=1` is load-bearing again — it is the one way past the whole precedence, so the two answers now have to differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): a landed new chat keeps its rail, its new-thread intent and the caret (Abilityai/trinity-enterprise#784 review) ent#784 made /workspace/a/:name the URL a landed new chat RESTS on. Three things had only been true because nobody stayed there; each was reproduced in a browser against the rebased branch. - Clicking the row of the agent whose new chat is already on stage cleared `startingNewChat` in `openAgentPage` and then pushed the URL it was already on, so the landing never re-ran. The composer still read "New chat" and sent its first message without `new_thread`, which the server resolves to the agent's Main chat. The click is now a no-op that hands the caret back. - The rail rules still read the agent URL as "a page, not a conversation", so a landed new chat had no rail, and the first send (which moves the URL to /workspace/c/:id) slid one in beside a reply mid-stream. The URL is rail-free only until the landing has put the named agent on stage, and its column is reserved mid-load like any 1:1 route. - A landing that reuses a chat (the agent's empty one) goes through `openThread`, and a thread mount focuses nothing, so the caret stayed on the sidebar row. The shell hands it over under the landing's own fine-pointer rule. Tests: portalAgentLandingStage.mount.spec.js (10 cases, mounted shell). Four were red before the fix, for these reasons. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(portal): the Workspace key map and its pure helpers (Abilityai/trinity-enterprise#621) Checkpoint A of ent#621: the declaration every Workspace key will be dispatched from, with nothing wired to it yet (the shell dispatcher is B). `portalKeymap.js` declares all nine shell chords — ⌘J, ⌘., ⌘/, ⌥↑/↓, ⌥⇧↑/↓, ⌥. — plus the reserved ⌘K (ent#577) and the six component-owned protocol keys (Esc, the call's M, the preview's arrows, tab roving, a dialog's Tab cycle, the typeahead's bare arrows). The protocol entries are declared precisely because `keymapCollisions` is only worth anything if it can see the whole surface: a shell chord that shadows Esc is the 2026-09-07 class, and a map listing only its own keys cannot tell you. Matching is platform-free — `primary` is meta XOR ctrl everywhere (the shipped ⌘J semantics) and a chord matches the printed `key` OR the physical `code`. The two arms disagree about Shift on purpose: without the `key` arm DE's `Shift+7` → `/` is dead, without the `code` arm `⌥.` is dead on a US Mac (it types `≥`), and with Shift accepted on the `code` arm macOS `⌘?` Help search is swallowed. - `isNewChatHotkey` / `newChatHotkeyLabel` delegate to the map, keeping their signatures and their truth table, so "what ⌘J means" has one answer. The physical `KeyJ` arm only widens the set. - `resolveComposerKey` claims BARE arrows only. It used to claim an arrow whatever the modifiers were and the caller `preventDefault`s a `move-*`, so ⌥↓ / ⌥⇧↓ were silently dead whenever an @-popup happened to be open — a handler claiming a chord it never declared. - The reserved ⌘K resolves to nothing: the shell must not `preventDefault` a key it cannot answer, so the browser's Ctrl+K keeps working until Spotlight ships. It is hidden from the key list too — a dead row in a help dialog is worse than an undocumented key. - `keymapSuppressed` is a matrix: anything modal stops every shell key (⌘J included — today it remounts under an open file preview), while a call stops only the keys that would move you. ⌘J keeps its own "leave the call?" ask; ⌘/ is allowed, because opening a dialog leaves nothing. - `hasModalOpen` is a DOM probe, not a registry: it sees overlays that do not exist yet, and its exemption is per action (the rail keys do not see the rail's own sheet; ⌘/ can close its own list). - The stale `agentLanding` header comment is corrected — the switch-agent keys ARE a caller, passing arm 2's `lastOpenSessionId`; the gesture doors (New chat, the picker, ⌘J) still mean "fresh". `portalKeymap.js` reuses `isMacLike` / `nextActiveIndex` and `portalUtils.js` delegates back, which closes a module cycle. It is benign — neither module touches an import of the other at evaluation time — and both init orders are exercised: `workspaceKeymap.spec.js` imports the map first, `portalChatTabsAndTitles.spec.js` the utils. Tests: 190 pass across the four specs. Mutation battery, restored from a scratch copy: dropping the bare-arrow guard reddens the composer row; a chat chord without its Shift reddens four keymap cases including the collision check; Shift on the physical arm reddens the Decision 29 case; a reserved key that resolves reddens the ⌘K case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(portal): the agent and chat keys, and the last-open memory (Abilityai/trinity-enterprise#621) Checkpoint B of the Workspace key map. The map and its pure rules landed in 58350ed; this wires them to the shell. * `Portal.vue`'s `onGlobalKeydown` becomes the ONE dispatcher over `resolveWorkspaceKey` + `keymapSuppressed`, with the full ladder: an unclaimed chord returns without `preventDefault` (⌘K keeps reaching the browser until ent#577), ⌘J keeps its place at the top and its own "leave the call?" ask, a nearer owner's `defaultPrevented` wins, a focused `<select>` keeps its native Alt+↓, and anything modal / the mobile drawer / a live call suppresses the rest. * `lastOpen`: a plain session-scoped Map with ONE writer. `pendingSession` is a load instruction, not the chat on stage — a null under an unchanged agent is ignored, a null under a new agent is a real unsent new chat and is recorded as such, so `agentLanding`'s own arms return to the draft. (T1 / Decision 35) * `landOnAgent(name, { lastOpenSessionId, focus })`, with the idempotency guard HOISTED above the focus-mode write and above `agentLanding` (Decision 34): the key path's own push re-fires the route watcher, and a second landing over a thread list that has meanwhile grown a minted Main would `openThread` it, replacing the chat the key just landed on. * `stepAgent(±1)` walks `orderedRoster`, lifted to the shell (T6) and handed to both sidebar instances, which stop sorting — the keys must walk the order the eye reads, and there were two of those computeds before. The fresh arm pushes `/workspace/a/:name` AFTER the state write, so the watcher re-fire is absorbed. * `PortalConversation.cycleChat(delta)` + `defineExpose` (an identifier, not an inline body): the strip's inputs live there, and it emits `open-thread` / `new-chat` exactly as a tab click does. * `PortalSidebar` gains `activeAgentName` → `aria-current`, an active-row tint on the semantic `action-primary` token, auto-expand when a walk lands beyond the fold, `visibleAgentRows(..., { keep })` and the same keep in search (Decisions 27/37). The sidebar had no notion of a current AGENT at all, by key or by click. * An `aria-live` announcer for every move; `data-ws-rail-sheet` on the same element that carries the sheet's `aria-modal`, so the probe can exclude it. Red list re-pinned in the same commit, never loosened (Decision 42): `portalVoiceMode.spec.js` exact `defineExpose` string, `workspaceNewChat.spec.js` `landOnAgent` signature + guard-first shape, `portalAgentsAtCentre.spec.js` roster-order pin moved to the shell plus a new `keep` case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(portal): mount the Workspace keys — the dispatcher and the chat walk (Abilityai/trinity-enterprise#621) Checkpoint B's behavioural half. Both files MOUNT (#2918): a regex over `Portal.vue` passes an inverted suppression condition and a reversed `delta` byte-identically. * `workspaceKeymap.mount.spec.js` — the real `Portal.vue` (shallow) with ONE custom stub: the conversation, because the auto-stub exposes nothing and `conversationRef.value?.cycleChat?.()` is optional-chained twice, so deleted wiring would hide behind its own `?.`. Real `KeyboardEvent`s on the real `window`. 18 cases: the walk and both wraps, the caret returning to the field, a roster of one, the fresh arm (route + `focusOnMount`), the announcer, the last-open memory (return, unsent-new-chat, a stale id falling through), the chat keys reaching `cycleChat(±1)`, and the whole suppression ladder — modal, the rail sheet's per-action exemption, the drawer, a live call, `defaultPrevented`, a focused `<select>`, reserved ⌘K, and `e.repeat`. Wrappers are unmounted between cases: one left standing answers the next test's key press first and `preventDefault`s it, after which the shell correctly yields and the case reads as broken wiring. * `portalChatCycle.mount.spec.js` — 8 cases on the real conversation: the walk agrees with `agentChatTabs`' own order, both directions wrap, other agents' chats are not in it, the provisional tab is walked from and onto (`new-chat`, as a click answers), under two tabs is silent, and `cycleChat` is exposed. * `portalAgentsAtCentre.spec.js` gains the `keep` behaviour (function) and the sidebar's call site (structure) — pinned inside the `visibleAgentRows(` call, because the file's other `keep:` is `searchAgents`' and a loose pin stayed green with the collapse's argument deleted. Mutation battery, each reverted from a scratch copy and restored byte-identical: `defaultPrevented: false` → "yields to a nearer owner" red; `-delta` in `cycleChat` → 5 cases in portalChatCycle red; `keep` dropped from the collapse → "orders the roster BEFORE the collapse" red; `focus: 'always'` dropped → "a key press IS a gesture" red; `lastOpenSessionId: null` → "returns to the chat that was open there" red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(portal): the rail keys, the key list, and the keys made visible (Abilityai/trinity-enterprise#621) Checkpoint C. The three remaining shell keys land on the ONE dispatcher B built — no second listener — and every control the keys drive now says which key drives it, derived from the same map the dispatcher resolves against. - `⌘.` toggles the rail in whichever form the viewport shows (column at and above `sm`, bottom sheet below it), reusing `railState.tab` and the persisted width: the key stores nothing of its own, so it cannot disagree with the expand/collapse button's memory (T5). - `⌥.` opens a closed rail on its remembered tab first, then walks `railTabs` — the tabs this session may SEE — never the registry, so a door-failed tab stays as unreachable by key as it is by click (Decision 25). - Both rail keys are suppressed, NOT dispatched to a no-op, where there is no rail: the difference is `preventDefault`, and a `⌘.` that silently ate Safari's Stop on every rail-less page would be a worse bug than a missing feature. `keymapSuppressed` reads the map's own `needsRail` flag for this. - `⌘/` opens `PortalKeyList` on `BaseModal` (#1923's Esc, focus trap and focus-return; always mounted and `v-model`-driven, because a `v-if` toggle unmounts past the close branch and focus never comes back). Every row is rendered from `keyListRows(WORKSPACE_KEYMAP, …)`; ⌘K is reserved for ent#577 and has no row, because a help dialog promising a key nothing binds is worse than an undocumented key. The list is the one action allowed to see through its own dialog, so the chord that opened it closes it. - Discoverability: agent rows, the rail's expand/collapse and tab buttons, and the chat tabs each carry the chord in BOTH channels — the tooltip people read and `aria-keyshortcuts` in ARIA's spelling — plus a sidebar-footer button so a mouse reaches what `⌘/` reaches. All of it comes through `keyHint` / `keyShortcutsFor` over the map: a typed glyph would be a second declaration of the same chord, and the copy that goes stale is always the one nobody tests. `BaseModal` now passes its attributes to the DIALOG element rather than dropping them on `Teleport`: `data-ws-key-list` has to sit on the same element as `aria-modal`, because that is the element the dispatcher's `:not(...)` exclusion matches. Without it the marker is a comment, not a selector. The sidebar footer's two icon buttons share one class string (`PortalRail`'s `ICON_BTN` shape) — two identical inline copies is two places for a hover state to drift, and the raw-colour ratchet counts every copy. Tests (all mounts, no source-text pins): the rail and key-list cases in `workspaceKeymap.mount.spec.js` prove a real `keydown` on the real `window` reaches the real dialog in the document — `teleport`, `BaseModal` and `PortalKeyList` are deliberately unstubbed, since the `[aria-modal]` probe can only see an overlay that actually reached `<body>`. `portalKeyList.mount.spec.js` asserts rows against the map (never against repeated strings) and drives Esc, the close button and focus-return with focus placed by hand first. `portalKeyHints.mount.spec.js` asserts each control's two attributes equal `keyHint`/`keyShortcutsFor` output, so a hand-typed hint fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(portal): re-pin the sidebar roster rules after the lift (Abilityai/trinity-enterprise#621) T6 lifted `orderedRoster` out of `PortalSidebar` and into `Portal.vue` — one order, computed once, handed to both sidebar instances so the switch-agent keys walk exactly the order the eye reads. Three specs pinned the identifiers at their old address and went red on the lift rather than on a broken rule: - `portalRosterRow.spec.js` asserted `visibleAgentRows(orderedRoster.value` and the `orderedRoster` computed in the sidebar's source. Both are gone from that file, and neither proved more than "the code was written". Converted to a MOUNT of `PortalSidebar`: the bound is now a count of rendered rows, and "the sidebar does not re-sort what it was handed" is a deliberately unsorted roster coming back out in the same order. A re-sort that slipped back in fails this even though it would satisfy any spelling of the old regex. - `portalSidebarRecency.spec.js` pinned the `null` primary (ent#500 does not exist) on the sidebar's call site. Re-pinned onto the shell's — the one call site there now is. It stays a call-site pin: "the primary argument is null" is an argument-position fact no rendered order can show, because with ent#500 absent a named primary and null agree on screen. - `portalSidebarSearch.spec.js` pinned the same `orderedRoster.value` spelling; re-pinned onto `props.roster`, plus the negative that keeps the lift honest — no second `orderRosterAgents(` inside the sidebar. No assertion was dropped or weakened; each was shown red by a one-line mutation (a re-sort in the sidebar, a reintroduced second sort, a guessed primary in the shell), with both source files restored byte-identical after each. Sweep green: 130 files / 2368 tests, including the raw-colour, loading-gate and source-text ratchets (the source-text baseline is unchanged — the mount conversion left `portalRosterRow` at its one existing read). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(portal): the Workspace key map in the feature flows (Abilityai/trinity-enterprise#621) Checkpoint D. The docs described a Workspace with one inline hotkey; it now has a declared map, a single dispatcher, and one landing rule the keys call. - `requirements/core-agent.md` §5.21a — the new sub-section beside §5.21: the nine working chords, the reserved one, one declaration / one dispatcher, the inverted editable-target rule (every key works from the message field), the suppression rule, the non-US `key`-or-`code` statement, the roster-order and last-open rules, and discoverability. OSS-core recorded explicitly rather than inferred from the merge. - `feature-flows/workspace-chat-tabs-and-titles.md` — "New chat in the header" keeps the ent#451 facts and gains "The key map": the table, the matcher's platform-free rule, the dispatcher's ladder in order (the order IS the design), the two rules it leans on, and the key list that is built FROM the map so a key cannot ship undocumented. Title and front matter follow; the Tests section names the ent#621 specs. - `feature-flows/workspace-agents-at-the-centre.md` — the stale half of "two doors RESOLVE, the rest ASSERT": the switch-agent keys resolve now, and they pass `lastOpenSessionId`. Walking the roster is navigation, not a request for a blank page. The pure-helper table says `orderRosterAgents` is called once, in the shell. - `architecture/frontend.md` — the Workspace shell paragraph: one map, one listener, the ladder, and the two consequences for anyone adding a key. - `Portal.vue` — one stale dispatcher comment ("the rail keys land in the next checkpoint" — they landed in C). The `agentLanding` header comment the plan also named was already corrected in Checkpoint A. Public wording only: the shipped behaviour, no enterprise design detail and no link to any private design document. The enterprise-docs guard's own pattern runs clean over `docs/` and `CLAUDE.md`. Sweep still green: 130 files / 2368 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): remember a same-agent new chat for the agent keys (ent#621 review H1) The last-open writer tested only "did the agent change", so a null `pendingSession` under an unchanged agent was always read as a load instruction. But ⌘J and the strip's New chat tab (both `newChatWithAgent`) null it on the agent already on stage — the commonest way to be sitting on an unsent chat — so ⌥↓ then ⌥↑ came back to the chat ⌘J had just left instead of the new one, against AC-8. The watcher now also observes `startingNewChat` and passes `keyChanged || (fresh && !sid)`: a deliberate fresh chat records `null` for the current agent, while every load-instruction clear (`openAgentPage`, `openRoom`, the unreachable arm, `openThread`) leaves the flag false and is still ignored, so the chat the person was reading survives those. Two mount cases: ⌘J on the agent on stage then ⌥↓/⌥↑ returns the new chat, and a load-instruction clear under an unchanged agent still returns the old one. Reverting only the watcher turns the first red and leaves the second green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(memory): key map ladder exception + platform-free collisions wording (ent#621 review I1-I3) I1 — the ladder sentence read as though nothing claims the event before the suppression rung, but ⌘J during an active voice call calls preventDefault first and routes to the leave-call guard. That is the pre-existing ent#534/551 ask, kept deliberately, so the exception is now stated in architecture/frontend.md and in AC-3 rather than the code being moved. I2 — the docs named a `platform` argument `keymapCollisions(map)` does not take. Matching is platform-free, so one call is the whole proof; the three mentions say that instead of implying a per-OS check. I3 — "one window listener" becomes "one chord dispatcher" in frontend.md (and AC-3), naming PortalRail.vue's pre-existing sheet-only Esc listener as the `close-top` protocol entry it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(memory): chat-tabs flow says one chord dispatcher, not one window listener (ent#621 review I3) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portal): a held Workspace key no longer leaks its repeats to the browser (ent#621 review) A key repeat resolved to nothing and the dispatcher returned before `preventDefault`, so the auto-repeat of a chord the shell had just claimed went to the browser. Reproduced against the rebased branch: a held ⌥. types `≥` into the message field on a Mac, and a held Ctrl+J opens Downloads on Chrome/Firefox for Windows and Linux (⌘J's repeats were swallowed before the map existed). The dispatcher now remembers the action of the press it claimed (`heldKey`) and swallows repeats of that chord, matched through the new pure `workspaceChord`. A repeat still never dispatches, the repeat of a press the shell did not claim stays the browser's, and any new press ends the hold. Also re-pins the "no rail" case after the ent#784 review fix: a landed agent URL is a chat with a rail, so ⌘. works there; the rail-less page in the test is now the unreachable-agent refusal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portal): a ⌘J parked behind the leave-call ask fires once the person confirms (ent#621 merge-train) The call branch `preventDefault`ed the event and then re-entered the ladder with that same event, so on the resumed pass the suppression rung read the shell's own mark as "a nearer owner claimed it" and bailed — ⌘J was dead exactly when the person had said "end the call and leave". The continuation now re-enters as the `resumed` pass after a `nextTick` (the confirm dialog is aria-modal and leaves the DOM on that flush), and `keymapSuppressed` waives only the `defaultPrevented` rung for it; modal, drawer and call still apply. Also from the finding: the physical-`code` arm answers only when the printed key is not a single printable ASCII character, so a Latin non-QWERTY layout keeps paste and undo (Dvorak's ⌘V arrives on `Period`, ⌘Z on `Slash`). The non-ASCII cases the arm exists for (`≥`, Cyrillic) are unchanged. Tests: pure cases for the resumed rung and the Dvorak shape in workspaceKeymap.spec.js; the shell mounted for real in workspaceKeymap.mount.spec.js — ⌘J during a call asks, confirm fires the new chat, cancel fires nothing; the two voice-mode source pins re-pinned to the new continuation. `isNewChatHotkey` stays: workspaceKeymap.spec.js and portalChatTabsAndTitles.spec.js (here and on the stacked ent#621 follow-up branch) import it as the ⌘J equivalence pin, and the chat-tabs flow documents it as the delegate kept on purpose. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…g sends (Abilityai/trinity-enterprise#665) (#3275) * feat(messaging): caller-declared idempotency key + TTL on human-facing sends (Abilityai/trinity-enterprise#665) send_message, call_user and send_group_message (Telegram, Slack) accept an optional idempotency_key + idempotency_ttl (60-86400 s, default 86400). Two sends with the same (agent, target, key) inside the TTL, from any executions, deliver once. The per-turn effect_guard (#1084) could not catch this: a recurring agent's runs are separate executions. - idempotency_service.intent_guard: scope intent:{agent}, key sha256(effect_type, target, key). The message text is never part of the key (#1422). Nested inside effect_guard for send_message/call_user; the key joins effect_guard's identity so two keys in one turn are two effects. - db/idempotency.claim(ttl_seconds, in_flight_lease_seconds): on the intent path the TTL expires only completed rows (the checking call's TTL decides) and an in_flight row is reclaimed after a 300 s lease. Other callers keep the 24 h rule. A row that vanishes between INSERT-fail and SELECT is re-claimed instead of returning NEW unrecorded. - Outcomes: completed -> sent:false, suppressed_by, first_sent_at, first_execution_id; in_flight -> retryable 409 (IntentInProgressError); failed send -> claim released. Keyless requests and responses unchanged (response_model_exclude_unset on the messages route). - Order: consent -> key -> rate limit -> deliver. - Visibility: audit event per sink, plus a Trinity-labelled system row in the first send's conversation session (scrubbed, sender_email=None). - MCP tools teach the cross-run case in their descriptions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(messaging): name the key on a voip intent 409, keep sent:true on fail-open, document late release (Abilityai/trinity-enterprise#665) - routers/voip.py: an IntentInProgressError keeps its own message (names the key, says retry) instead of the per-execution duplicate text. - intent_guard: a keyed send that went out unguarded because the store was down still reports sent: true. - db/idempotency.py + effect-idempotency.md: a stalled sender's late release() can delete the reclaimer's row, alongside the late complete(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
… per subject (#3246, part 1) (#3255) * feat(operator-queue): platform alert registry and sweep planner (#3246) C1 of #3246 (one pending row per platform-alert subject). Adds the stdlib-only leaf services/platform_alerts.py, which both migration tracks and the service graph will import: - Kind registry for every in-repo platform emitter, with its id prefix and lifetime class: 14 days by default (OPERATOR_PLATFORM_ALERT_LIFETIME_DAYS), a fixed 30-day net for edge-triggered kinds with a clear hook, or none for the four a person must act on. EXTERNAL_PREFIXES keeps role-drift- out; register() takes kinds from outside this module. - Snooze window after a person ends an alert: 7 days (OPERATOR_PLATFORM_ALERT_SNOOZE_DAYS), per the operator's T5 ruling. - Key belt and subject_for(): "<kind>:<key>", id-shaped keys kept, anything else sha256[:16]; event kinds have no subject. - derive_legacy_subject(): reads every pre-#3246 id shape back to its subject, or to "known kind, no subject" when the id does not say which condition (git-bloat-, rows missing their context key). Unknown, gate and external prefixes are never derived. - plan_sweep(): pure planner for the upgrade sweep. Keeps the newest pending row per (agent, subject) (created_at, then id; NULL oldest; a row that already holds a subject keeps it), ends the rest, stamps survivors, gives subject-less known kinds a lifetime stamp, stamps person-ended rows inside the snooze window, and plans nothing on its own output. Nothing calls the leaf yet; the migration (C2) and the seam (C4) do. Tests: tests/unit/test_3246_platform_alerts_leaf.py (78), including a bare-interpreter load by path. Mutation-checked: survivor min instead of max, greedy sid regex, no snooze window, and reversed prefix order each go red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): subject columns, backlog sweep and one-pending-per-subject index (#3246) C2 of #3246, both schema tracks in one migration, ordered columns → sweep → index: the partial unique index can only be created after the sweep has collapsed the duplicates an installed backlog holds. - `operator_queue.subject TEXT` + `last_seen_at TEXT` (nullable, no backfill beyond the sweep) in schema.py, tables.py and both migration tracks. - `run_platform_alert_sweep(run)` in db/migrations.py — one function both tracks call over their own connection (sqlite3 cursor / SQLAlchemy `text()`), so the survivor rule cannot drift. It hands the fetched rows to the leaf's pure `plan_sweep` (function-local import; neither track pulls the service graph into `init_database()`), stamps the newest pending row per derived subject, ends the rest as ONE batch in the ent#611 vocabulary (`cancelled` / `platform` / `superseded` / NULL email / `batch_id`) with a compare-and-set on `status='pending'`, stamps a lifetime only on known kinds whose subject cannot be derived (never merged on a guess), and stamps `subject` on person-ended rows inside the 7-day snooze window. Agent-raised rows, `gate-` rows and external prefixes never derive. - `uq_operator_queue_pending_subject` (partial: pending + subject) and `idx_operator_queue_agent_subject`, created after the sweep. - Alembic `0090_platform_alert_subjects` chained after the live head `0089_supersede_queue_flood_backlog`. Test (red first at collection — the migration did not exist): tests/unit/test_3246_platform_alert_sweep.py drives the registered entry against the pre-upgrade `schema.py` DDL holding duplicates: survivor and stamps, one batch, untouched rows, snooze stamp, a second run changes nothing, the index exists only after the run and bites on a second pending row, DDL parity with schema.py, both tracks registered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(operator-queue): locked platform-alert accessors and the platform ending (#3246) C3 of #3246 — the DB seam the platform-alert service (C4) will stand on. db/operator_queue.py: - `create_platform_item(agent, item, *, subject, max_pending_for_type)`: find → touch → count → insert in ONE `_lock_agent_for_create` transaction. A reading of a subject with a pending row updates it in place by a compare-and-set on `status='pending'` (title / question / priority / context with `seen_count`+1 / `last_seen_at` / `expires_at`); a lost CAS falls through to a fresh row, so a row a person ended is never overwritten. The find runs BEFORE the #1677 per-type count, so an update is never refused at budget. An `IntegrityError` from the partial unique index (a lock that failed open) re-finds and touches the winner. Returns `{"outcome": created|updated|refused_at_budget, "row", "changed"}`; `changed` is false on a bare repeat reading so nothing broadcasts. - `find_pending_by_subject`, `find_person_ended_by_subject` (the seam's snooze read, newest person ending at or after `since`). - `end_items_by_platform(ids, *, reason, batch_id)` — the `bulk_cancel_items` shape with `disposed_by='platform'`, NULL email, re-selected by batch id so only CAS-won rows come back. - `mark_expired`'s per-id CAS gains `expires_at < now`: a row refreshed between the candidate select and the CAS keeps its new deadline. - `_insert_values` takes keyword-only `subject` / `last_seen_at`; `_row_to_item` / `_SELECT_COLS` carry both. services/ask_service.py: `clear_platform(ids, *, reason, batch_id=None)` mirroring `expire()` — no Actor, `PLATFORM_ENDING_REASONS = (condition_cleared, superseded)` enforced, one `platform_cleared` audit row, one thin `operator_queue_cancelled` trigger per agent, observers get only the rows this call won. database.py facade for the four accessors. `test_ent329_operator_resume.py` G2 list gains the new CAS accessor. #3130 / #3220: `_own_pending_conds`, the flood guard and `create_bounded_alert_outcome` are untouched; their suites stay green. Test (red first — the accessors did not exist): tests/unit/test_3246_platform_alerts_db.py on the unit island's real SQLite: two readings → one row, bare repeat → unchanged, person-ended row never overwritten, budget refuses only with nothing to update, the index refuses a second pending row, `mark_expired` keeps a refreshed row and still expires an overdue one, the platform ending skips a person-ended row and hands observers only CAS-won rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(operator-queue): register the #3246 C2/C3 unit files (#3246) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(operator-queue): platform alert seam, reserved prefixes and the emitter ratchet (#3246) C4. `platform_alerts.observe / clear / reconcile` on top of C3's locked accessors: one pending row per (agent, subject), updated in place; the kind's lifetime as `expires_at`; the T5 rule (after a person ended a subject's row, nothing is filed for 7 days unless the priority rose or a material key changed); never raises. Imports stay function-local, so the module is still a stdlib-only leaf at import time. `skills-reconcile-`, `skills-fleet-reinject-`, `retention-guard-` and `ent615-git-token-scrub-` become reserved prefixes. Guards (test_3246_platform_alerts_registry.py): two-way prefix parity, the EMITTERS_NOT_YET_ON_SEAM ratchet (every platform emitter not yet on the seam, `create_bounded_alert_outcome` included, each naming its follow-up), the clear-site check, and the caller guard on the #3246 accessors. Also places C3's `subject` / `last_seen_at` on the machine side of the ent#715 row allowlist (not person data); test_ent715 re-pinned — it was red since C3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): agent circuit breaker reports through the platform alert seam (#3246) The dormant transition reports through `platform_alerts.observe` (kind `circuit_dormant`, keyed by agent, 30-day net) and the two places the code already knows the circuit closed — `record_success` recovering from dormant, and the admin `reset_circuit` — end the row through `platform_alerts.clear`. Leaves the #1677 allowlist and the ratchet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): system agent stale base image reports through the platform alert seam (#3246) Both #1816 alarms report through `platform_alerts.observe`, keyed by the system agent: `base_image_stale` (high) and `system_agent_start_failed` (critical), 30-day net. A newer reading updates the one pending row, so the N-workers-N-rows boot and the per-bucket start-failure rows collapse to one row each. Cleared where the code already knows: a running boot that finds the image current, and a start that adopted the rebuilt image (`image_drift`), end `base_image_stale`; a successful start (delegated or the pre-flight plain start) ends `system_agent_start_failed`. The per-process staleness cooldown stays as emission spacing. `START_FAILED_ALERT_BUCKET_SECONDS` no longer shapes the id (only its own constant test reads it). Five test_1816 tests re-pinned from the direct DB write to the seam call; the cross-process dedup test now proves one pending row on the real DB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): subscription headroom reports through the platform alert seam (#3246) Both ent#434 alerts report through `platform_alerts.observe` on the `_sub-headroom` host: a subscription keys on the same cleaned id the legacy `sub-headroom-{sid}-…` ids carried (`subject_key`), so a row the upgrade sweep re-subjected matches a new reading; the fleet-wide alert keys on `fleet`. A later reading updates the one pending row in place and a critical reading replaces the warning (`tier` is the kind's material key), which retires the documented "a 75% row still reads 75% at 92%" residual. The reset day moves from the id to the row's context (`window`). The evaluation pass had no place that named a recovery, so it now ends with a reconcile (`clear_recovered`): only a subscription MEASURED back under its threshold (`HAS_HEADROOM`) loses its row — an unassessable member keeps it — and the fleet row survives until some member is measured with room. Off the #1677 allowlist and the ratchet. ent#434's id-identity tests are re-pinned to the subject; test_433_headroom_history asserted nothing about the id shape and needed no change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): legacy skills-library adoption reports through the platform alert seam (#3246) `_record_adoption_failure` reports through `platform_alerts.observe` on the `_skills-sync` host with ONE subject per URL (`sha256(raw url)[:12]`), shared by the steady-state refusal and both failure branches. A repeat — any branch — updates the one pending row (latest message and priority, seen count) rather than filing another, so one sync never files a second row (#2744's guarantee, now stated on the subject). Severity per branch is unchanged (steady state low/info, failures high/error); the URL echo stays scrubbed. Cleared where the code knows: adoption success and a URL that already names a source end the URL's row (`_clear_adoption_alert`); every adoption pass reconciles against the current setting (`_reconcile_adoption_alerts`), so a removed or changed `skills_library_url` ends the rows it no longer backs. Off the #1677 allowlist and the ratchet, which now holds only PR-2 entries. test_2744 re-pinned from request_id to subject (test 4 now asserts the failure branches keep `high` on the URL's one subject); three ent#346 tests re-pinned from the direct DB write to the seam call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): platform-ended alerts and the seen count on the queue surfaces (#3246) A row the platform ended (disposed_by='platform', reason condition_cleared | superseded) now reads "Ended by the platform — <why>" on ResolvedCard, QueueItemDetail, /m and the Workspace ask views (one rule: utils/operatorQueue.js::queueEnding/queueEndingText) — never a person's answer, never a timeout, never the raw reason token as an operator note. An expired alert reads "nobody acted on it". client_portal/asks/service.py::_ending_of returns "platform" for such a row instead of falling through to "operator". A pending alert seen more than once shows "seen N times · last seen <when>" (queueSeenLine) on QueueCard, QueueItemDetail and /m; the Workspace asks panel never renders platform alerts, so it gets no line. Canary _PLATFORM_ALARM_SENTINELS gains _skills-sync (L-03 would otherwise read the seam's rows as orphans). Stale "person | timeout" strings corrected in dependencies.py, mcp-server types.ts and the get_my_ask description. Tests: platformAlertSurfaces.spec.js (mounted; 12/15 red with the util change reverted), test_3246_platform_alert_surfaces.py (4/7 red with the backend change reverted). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(operator-queue): platform alerts are conditions — one row per subject (#3246) Requirements §26.14 (OPS-001-PLATFORM-ALERTS), the operating-room flow (status lifecycle + a Platform alerts section), the platform_alerts.py catalog line, the subject/last_seen_at columns and the `platform` ending in the database doc, and a learnings fragment. States plainly that only four emitter families are on the seam in this PR (headroom, skills adoption, system agent, circuit breaker); the rest follow in PR 2. Corrects claims this branch made false: the headroom alert's id as its state machine (security §20.x, backend catalog), #2744's one-row-per-URL that a dismissal holds forever (skills), the base-image alarm filing one item per worker that stays until acknowledged (internal-system-agent), and disposed_by as an enum of two. OPERATOR_PLATFORM_ALERT_LIFETIME_DAYS (14) and OPERATOR_PLATFORM_ALERT_SNOOZE_DAYS (7) in .env.example and the dev, prod and hosted compose files (hosted is the prod twin, #2280 parity). tests/registry.json registers test_3246_platform_alert_surfaces.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): the upgrade sweep reads raised_by, never an id shape alone (#3246) The sweep derived a subject from the request id and never read raised_by, so an agent's own ask under a prefix that was unreserved before this branch (hosted on the agent's own name) grouped with the platform's alarm and, if newer, survived while the platform row was ended as superseded. The SELECT now takes only platform-raised rows, every write carries the same predicate, and plan_sweep skips any row with raised_by set even when handed one. The survivor stamp no longer binds a NULL into COALESCE on the SQLAlchemy track: expires_at is in the statement only when the planner set one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): the platform-alert sync broadcast names its host agent (#3246) platform_alerts._broadcast_sync carried no agent key, so the trigger reached every logged-in client (ent#467). It is now keyed by the row's host agent the way the create path's operator_queue_new is, so the scope filter delivers it only to clients who may see that agent's queue. The poller's fleet-level operator_queue_sync stays allowlisted: it is one trigger per cycle that spans agents; this one is always about one row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): pin the snooze window at the headroom emitter (#3246) Mutation check: a zero-day snooze window left every emitter test green. A warning a person just ended must not be re-filed by the next identical reading, while an escalation to critical still reaches a person. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): the #3246 refresh-vs-CAS test patched a module name, not the dict mark_expired reads (#3246) `test_a_refresh_between_select_and_cas_keeps_the_row` monkeypatched `db.operator_queue.select` by module name. Under the full suite a sibling file evicts or stubs `sys.modules["db.operator_queue"]`, so the name resolves to a re-imported or stand-in module while the `database` singleton's class still reads the original module dict — the patch never fires, the refresh never lands, and `assert state["refreshed"]` fails as `assert False` (CI seed 12345, one failure in 21,569). Alone, the file is green on every seed. Same claim, proved deterministically: patch `type(real_db._operator_queue_ops).mark_expired.__globals__["select"]`, the dict the running method looks names up in — the suite's established shape (test_1632 `_patch_engine`, test_ent611 race tests). `mark_expired` itself is unchanged: its per-id CAS already re-checks `expires_at < now`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): the platform ending reaches the loop from any thread, not only anyio's (#3246 merge-train) `spawn_on_loop` handled a running loop or an anyio worker thread and re-raised from anything else — "not a shape any production caller has". #3246 made it one: the headroom sweep (`subscription_recovery_service`) and the skills reconcile (`skills_sync_service`, `routers/skills.py`) end platform alerts through `asyncio.to_thread`, the loop's DEFAULT executor, which anyio does not own. The anyio portal raised, `platform_alerts._spawn` / `ask_service._ended` swallowed it, and every such ending committed with no `platform_cleared` audit row and no `operator_queue_cancelled` trigger, behind a WARNING traceback. Reproduced: loop thread OK, `anyio.to_thread` OK, `asyncio.to_thread` → RuntimeError. Fixed in the helper rather than at the call sites: a captured host loop with a `call_soon_threadsafe` fallback. `main.py::lifespan` records the loop before any boot phase, and every on-loop spawn refreshes it, so the next thread-origin caller — a `threading.Thread` a service starts itself included — inherits the fix instead of each site having to remember `anyio.to_thread.run_sync`. The task is still created ON the loop thread (`_schedule_on_loop`'s contract) and the caller never waits for the work. A closed or non-running captured loop is not a target (the private-loop footgun below), and with nothing to hop to the helper still raises loudly — never the silent no-op ent#430 removed. Pinned by `test_3246_platform_alerts_thread_origin.py`: `clear` / `observe` driven through `asyncio.to_thread` land the audit row and the trigger with no "could not schedule" warning; the spawn from the default executor and from a plain thread; the lifespan capture is first in the boot sequence; the loud failure with no loop; a closed captured loop is ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(1816): a private loop cancels and flushes what the run spawned before closing (#3246 merge-train) `test_1816_system_agent_adoption` ran `ensure_deployed` on a `new_event_loop()` and closed it with the #3246 follow-ups (audit, broadcast) still registered in `operator_resume_service._inflight` — pending tasks bound to a dead loop, or finished ones whose `_inflight.discard` callback was queued when `stop()` landed and never ran. The next test to gather `_inflight` on ITS loop failed deterministically with "The future belongs to a different loop" (`test_ent329_operator_resume::test_spawn_keeps_a_strong_reference`); CI shuffles, so it recurred. `_run` now cancels the leftover tasks (cancelled, not awaited: a follow-up may wait on a transport this island never provides), gathers them, and runs one more tick so the done callbacks drain, before closing. Verified in both file orders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(operator-queue): an open but stopped host loop is not a spawn target (#3246) Merge-train validation found the `not loop.is_running()` rung of `_live_host_loop` unpinned: deleting it left all 104 tests green. Such a loop accepts `call_soon_threadsafe` and parks the task forever. The new case captures one, expects the loud RuntimeError, and asserts nothing was queued on it. Deleting the rung now turns it red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * merge-train: restore #3276's registry descriptions reverted by the #3275 dev merge The merge-train's registry rebuild for #3275 took the member branch's list plus dev's new entries, so dev-side EDITS to existing entries were lost: #3276's trinity-enterprise#792 text on test_files_guardrail_bypass.py and test_ent596_skill_manager.py was reverted when #3275 landed (58c7b80). Recomputed with a per-entry three-way merge from dev before that landing: both descriptions are restored and #3276/#3273's entries return to their positions. Metadata only; no test behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
* feat(pull): /chat on the durable queue for pull pilots (#3127) On a pull-pilot agent, POST /api/agents/{name}/chat (MCP chat_with_agent, the connector tool and the trinity CLI) is admitted onto the durable queue and claimed by a worker. Every interactive trigger is now pull-owned on a pilot. Memory on a pilot is one Claude conversation per chat_sessions row, i.e. per (agent, user), resumed by id through run_resumable_turn (ResumeLock, persist_session, cold retry on resume-not-found). The id is cached on chat_sessions.cached_claude_session_id (SQLite migration + Alembic 0084) and joins the session reaper's keep set. The post-turn work /chat did inline runs in the caller after the terminal: assistant message, collaboration close, response shape, idempotency complete, timeout receipt. GET /chat/history serves the caller's session from the database on a pilot; DELETE /chat/history also clears the cached ids. execute_task carries chain_depth so a cold-retry row keeps its depth. Non-pilot agents are unchanged. Fixes #3127 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pull): /chat reset on a pilot closes the sessions /chat used (#3127) DELETE /chat/history forgot the cached Claude ids but left the sessions active, so GET /chat/history still showed the old conversation. The sessions that carry a cached id are now closed with it; sessions /chat never used stay open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(security): /cso --diff report for #3127 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(pull): renumber the chat_sessions migration to 0086 after dev's 0085 (#3127) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(pull): renumber the chat_sessions migration to 0089 after dev's 0088 (#3127) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pull): address review on pulled /chat (#3127) - Skill gate runs once: the pulled turn passes gate_checked=True, and the pilot admission branch audits a self-approved gate like the push branch. - Agent-to-agent /chat (trigger agent) on a pilot: run_resumable_turn opts into the claim phase (caller_waiting=True), the claim orders session:-keyed rows with interactive turns, and the collaboration activity rides the queue payload so the pull sink closes it. - Resume lock waits one turn's lock TTL; lock-busy closes the activity and emits the terminal event only on a CAS win, and answers 429 capacity. - Full backlog returns FAILED/CAPACITY and maps to 429 capacity. - Docs: chat_with_agent description, execution.md, three feature flows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(security): CSO report names the 0090 revision (#3127) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * merge-train: re-parent the chat_sessions revision onto #3255's 0090 (#3127) — mechanical, per the merge-train note on the PR #3255, #3145 and #3269 each added an 0090 revision off 0089_supersede_queue_flood_backlog, which is the #2068 two-heads fork once two of them land. The tables are disjoint (operator_queue vs chat_sessions), so this is a re-parent: 0090_chat_session_claude_id becomes 0091_chat_session_claude_id with down_revision 0090_platform_alert_subjects. The SQLite entry is keyed by name and needs nothing; only the two doc references to the id change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat): pulled /chat keeps the default 30s resume-lock wait (#3127) A longer wait outlived the #106 no-session sweep: the waiting turn's admission row is running with no Claude session, so the sweep failed it at 60s and the enqueue CAS then refused it, answering 429 for a turn that never ran. Drop the lock_wait plumbing; a test pins the default wait under NO_SESSION_TIMEOUT_SECONDS. chat_with_agent's parallel description notes per-user sessions on a pilot; execution.md says the self-approval audit rides the row's setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…he limit and says its total (#3303)
…ases 2026-10-06) (#3327)
…led a route that never existed (#3510) (#3758) * fix(agent-runtime): drop the startup credentials auto-import that called a route that never existed (#3510) startup.sh called POST /api/internal/decrypt-and-inject when an agent started with a .credentials.enc and no .env. No handler was ever added, so the call failed on every start and the message blamed a missing encryption key, while the agent ran without its credentials. Ruled: remove it rather than implement it. An implementation needs an agent-callable decrypt (the master internal secret never reaches an agent), and the file lives in the agent's own home, so an agent could stage another agent's blob and have the platform decrypt it. Decrypting stays the owner's Import from Git / import_credentials. - startup.sh: the curl is gone; on .credentials.enc without .env it says the file is not imported at startup and names Import from Git. - models.py: drop the unreferenced InternalDecryptInjectRequest. - test_3510: every literal backend call in the agent image must resolve to a route in the live table (via the #2996 census helper); the scanner is checked against the old call. - test_2007: the render-after-.env ordering anchors on /generated-creds. - Docs: api-endpoints, credential-injection flow, two user docs and the agent guide no longer describe an automatic import. Fixes #3510 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent-runtime): describe the credentials import truthfully; scan f-string backend calls (#3510) startup.sh and four docs claimed .credentials.enc is never imported at start; the backend imports it after the agent is ready (skipped for subscription, ephemeral, no-key). The route-existence test now matches {backend_url}/api forms and asserts a non-empty scan. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(credentials): subscription agents import a committed .credentials.enc too (#3510) Review decision: the #612 skip silently dropped a subscription agent's MCP keys and other non-Anthropic credentials. Under subscription the agent server force-unsets Anthropic API-key variables at every spawn (#2114), so an imported ANTHROPIC_API_KEY cannot shadow the subscription. The #606/#612 harm (a missing file read as failed, retried, failing the trigger) stays fixed: it is the clean no_credentials_enc_file skip, tried once. startup.sh and the docs drop the subscription exception. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…3637) (#3805) send_email is async, but its SMTP branch called the synchronous _send_smtp inline, so smtplib's connect/auth/send blocked the worker's event loop and a caller's asyncio.wait_for bound could not fire. The SMTP branch now awaits asyncio.to_thread(self._send_smtp, ...), and smtplib.SMTP gets timeout=SMTP_TIMEOUT_SECONDS (30s) so a slow or unreachable server releases the thread. The send's boolean return value is unchanged. _send_smtp is the only smtplib caller in src/backend. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…) (#3806) * fix(auth): email-login endpoints return 422 for a malformed body (#3676) Root cause: POST /api/auth/email/request and /verify take a raw Request and built their models by hand (`await request.json()` then `Model(**body)`) with no exception handling. A JSONDecodeError, a non-object body (TypeError on `**`) or a pydantic ValidationError escaped the handler as an unhandled 500 with a traceback. The app-level RequestValidationError handler never saw them, because only FastAPI's own body parsing raises that type. Fix: one `_parse_body` helper in routers/auth.py used by both handlers. A non-JSON or non-object body is a 422 with a one-line detail; a ValidationError is re-raised as RequestValidationError so the shared handler shapes it and drops the rejected value. The parse stays after the setup / email-auth / login-policy gates, so their ordering is unchanged. On /verify a malformed body records a failed per-IP login attempt before the 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): access-request returns 422 for a malformed body (#3676) POST /api/access/request read `await request.json()` and called `body.get("email").lower()` unguarded, so a non-JSON body, a non-object body or a non-string email escaped as a 500. It now parses through `_parse_body`: a non-JSON or non-object body is a 422, and a non-string email takes the existing 400 "Valid email address required" branch. The setup, self-signup and email-auth gates and the per-IP rate-limit check still run before the parse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…t loading (#3807) * fix(public-links): reject an invalid expiry with 422 and keep the list loading PublicLinkCreate and PublicLinkUpdate validate expires_at with datetime.fromisoformat, so a value such as "not-a-date" gets a 422 before the handler runs and no row is written. An empty string still passes, as before. _link_to_response parses the stored expiry through _parse_expiry, which logs and returns None for an unparseable value. A row already holding a bad value shows in the list without an expiry, so the owner can see and revoke it. PublicLinksPanel shows LoadFailed with a retry when the list request fails. The "No public links" empty state is shown only for a list that loaded and is empty. Closes #3583 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(public-links): an unreadable stored expiry counts as expired db/public_links.py gains parse_link_expiry, the single parser for a link's expires_at. A value that does not parse returns UNREADABLE_EXPIRY (1970-01-01 UTC), so is_link_valid rejects the link as expired and the owner list shows it as expired. Both paths use the helper. PublicLinksPanel derives its state from loadingState.viewState. A refresh that fails after links loaded keeps the list and shows the stale InlineError banner with a retry; LoadFailed shows only when nothing has loaded. The bare loading gate is gone, so its loading-gate baseline entry drops 2 -> 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…itself (#3677) (#3804) * fix(model): an agent's 4xx on the model routes reaches the caller as itself (#3677) PUT /api/agents/{name}/model caught every httpx.HTTPError in one branch and answered 503 'Failed to set model', so an invalid model (the agent's 400 naming the valid ones) read as an outage and wrote an ERROR line. An agent 4xx now passes through with its status and detail, logged at INFO; an unreachable agent, a timeout or an agent 5xx is still 503. GET .../model had the same shape and gets the same split. The integration test no longer skips the 400 path as 'agent not ready'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(model): an agent's 401 stays a 503, never a sign-out (#3677) Review finding: the agent server's auth middleware answers 401 when the backend's per-agent token is refused (#1159). Passed through, the browser's platformSession reads a bare 401 as 'session lost' and signs the person out on the agent page. It is the platform's credential failing, so it stays a 503 with an ERROR line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(learnings): never relay an agent's 401 to the browser (#3677) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…es (#3679) (#3809) * fix(asks): a capped read of pending asks keeps the longest-waiting ones (#3679) list_items ordered every row by sort_time descending inside its priority, so pending asks came newest first; the Workspace Inbox and the operator queue each read one capped page (200) and show it oldest first, so past the cap the asks that had waited longest were never shown. Pending rows now sort oldest first; ended rows keep their newest-ended-first order (#627 AC6). The two age keys are NULL across each other's group, so NULL placement never decides an order on SQLite or PostgreSQL. The Workspace footer's '200 most recent asks' note now says what the page holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(asks): the ent#428 crowd-out fixture raises the client's ask last (#3679) The fixture proves the addressee filter runs in SQL by building a page that crowds the client's ask out without it. It relied on newest-first ordering; with pending oldest first, the client's ask is now raised after the noise. On the old ordering its own precondition trips, so it also guards #3679. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…) (#3811) * fix: webhook and MCP URLs use https behind an HTTPS proxy (#3601, #2907) The schedule webhook URL came from request.base_url and the auto-detected MCP URL hardcoded http://<host>:8080, so a TLS install behind a proxy advertised http:// for both. Both now take their origin from utils.public_url.public_base_url, which upgrades to https on the proxy's X-Forwarded-Proto and never downgrades. Behind an HTTPS proxy the MCP default is https://<host>/mcp, since the frontend nginx serves /mcp on the same origin. The API Keys page now takes the server-detected MCP URL when it is https, and keeps its hostname guess otherwise (behind the Vite dev proxy the backend sees its own host). uvicorn proxy-header trust is unchanged: enabling it would also change request.client.host for every per-IP limiter, and agents share the network with the frontend nginx. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(security): cso diff report for #3601 / #2907 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
) An agent-scoped MCP key resolves to its owner with the owner's role. Fleet-level routes used that role directly, so on an admin-owned install any agent key read every agent's executions, notifications, activities, systems and tags, and could dismiss all notifications. - One reach builder in dependencies (self, permission edges, agents spawned with the current key; bounded by a non-admin owner's accessible set; every DB read fails closed). Operator queue reuses it. - Roster accessors (get_accessible_agents, accessible_agent_names, visible_agent_names) narrow to reach for agent principals; direct admin shortcuts in event subscriptions, tags, system views, permissions, onboarding, subscriptions, slots and telemetry narrow too. - Person-only: ws ticket, proactive share PUT, system views. - /ws/events with an agent key connects as a scoped, non-admin slot whose roster is the key's reach. - GET /api/mcp/keys/{id}: an agent key reads only its own key. - chat resume-session ownership check applies to agent keys. - Deploy refuses (403 NAME_OWNED_BY_ANOTHER_USER) to version over an agent the caller cannot manage. - Fleet sync audit and fleet health stay owner-wide for agent keys (trinity-enterprise#706 D12), allowlisted in the guard. - AST guard over role compares, roster accessor calls and db access helpers, with a frozen shrink-only allowlist. - Test isolation: test_validate_runtime evicts only stand-in modules; test_fleet_status_resilience uses the sys.modules restore pattern. fixes Abilityai/trinity-enterprise#872 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#3776) * fix(files): rule a tier per verb on the agent Files surface (ent#820) The six agent Files routes inherited the accessor tier (admin | owner | shared): anyone an agent was shared with could change its instructions and startup files, list hidden directories, and act through links. Each verb now resolves through one path policy, decided per tier (owner, share, key). - services/file_path_policy.py: path classes, the tier x verb table and the agent server's platform floor; pure stdlib, vendored byte-identical into docker/base-image/agent_server/ (Invariant #5) with a parity test. - Below the owner tier: instruction files, .claude/** (skills included) and files that run code are owner tier; the list withholds hidden files and flags each node with the verbs the caller cannot use; a file with a second hard-linked name is not served. - For every caller: nested and bare-repo git metadata is write-protected; CLAUDE.md is not deletable; PUT/mkdir/DELETE and the list root refuse a link that changes any tier's verdict, then act through an O_NOFOLLOW dir_fd walk; DELETE of a link unlinks the link; the list never descends a link (20 000 node cap); list/delete containment is is_relative_to (#3032). - One audit row per write, delete and mkdir; tier refusals audited. - Share holders' writes on an agent still on a pre-change image are refused until it restarts; agent keys pass and the row records it. - Files tab reads the per-node flags instead of its own path lists; the Access tab states what a share grants. Mutation-checked: reverting the tier gate fails 33 tests, the link-verdict check 4, the containment fix 1; the old FilesPanel fails 7 of 8. Fixes #3032 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(files): ent345 deny-list guard asks the path policy, not files.py source (ent#820) The write deny lists moved from services/agent_service/files.py into services/file_path_policy.py; the guard grepped files.py for the literal paths and failed in CI. It now checks the policy's write verdicts and still pins the guardrails-baseline.json entries. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(files): close the review gaps in the path policy (ent#820) Review of #3776 found paths the policy still left at the share tier: - the Python user-site (`.local/lib`, where the base image runs `pip install --user`): a `.pth` or `usercustomize.py` there runs as the agent at the next `python3`. Added to RUNS_CODE_DIRS. - shell-init siblings missing from RUNS_CODE_FILES: .zshenv, .zprofile, .zlogin, .zlogout, .bash_login, .bash_logout. - five fixed-name credential files readable at the share tier (the set predates this PR; the policy is now its single home): .netrc, .git-credentials, .docker/config.json, .config/gh/hosts.yml, .gemini/oauth_creds.json. Now owner tier to read. Both policy copies stay byte-identical. Census and read-tier tests gain a row per path; with the old policy 35 of them go red. Left for a ruling: nested instruction files, bare repos not named *.git. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test(files): pin the five fixed-name credential files as reviewed read-only owner-tier patterns (ent#820) CI regression diff: test_files_protected_paths pins the read-only owner-tier set as a literal so a new entry is a reviewed decision. The review-fix commit added .netrc, .git-credentials, .docker/config.json, .config/gh/hosts.yml and .gemini/oauth_creds.json without updating it. They are readable-only-at-owner and writable like any file, same as .kube/config. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…, ent#899 retire per-agent endpoint register) (#3819) Pins src/backend/enterprise at trinity-enterprise main 1cfaf0f, which carries #947 (agent-scoped keys see only their reach on the a2a exposed map and the permissions matrix, the private half of trinity#3810 / ent#872) and #899 (retire the per-agent outbound endpoint registration route, ent#898). Without the bump the a2a /exposed route stays un-narrowed in deployed builds, since it rosters via db.get_accessible_agent_names at the DB layer, which #3810's principal_reach does not cover. Part of Abilityai/trinity-enterprise#872 Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…, Storage, People, one restart banner (ent#887, #888, #890, #891, #353) (#3798) * feat(ui): one Settings home for the agent — rail, General, Guardrails, Storage, People, one restart banner The agent page splits into a working strip and ONE Settings tab, pinned at the end of the strip, with a rail of panes each answering one question (decision on #966). The pane is in the URL (?tab=settings§ion=…); old ?tab=info|guardrails|credentials|folders|access links land on their pane. - General (Abilityai/trinity-enterprise#887): identity (label, id rename, avatar, description, tags), runtime/model/funding, execution timeout (Abilityai/trinity-enterprise#353 — named range error, #929 schedule conflict with an Open Schedules action), parallel capacity, read-only, memory/CPU, template details, Danger zone (reset to main only with a bound repo; delete). The header keeps identity, state, the autonomy chip and the doors; live usage and spend move to Overview; Pull/Push move to the Git tab. - One restart banner: rows report to stores/agentRestart.js; memory/CPU is saved and applied on the next restart instead of restarting at once. - Guardrails (Abilityai/trinity-enterprise#890): rules, self-change permissions (ent#756), gated-skill count, autonomy read-only until PR #2984 merges, cross-model validation, reliability. - Storage (Abilityai/trinity-enterprise#888): repository, GitHub token, git sync, credentials, shared folders, file sharing. Credentials and Folders tabs removed; Sharing loses Distribution. - People (Abilityai/trinity-enterprise#891): the Access tab, unchanged. - Channels/Integrations hold the Voice / MCP cards from the old flat list and point at the tabs that still hold the rest. Red before green: agentSettingsHome.spec.js (7 strip/alias cases) and the file-sharing restart report fail against dev's agentTabs.js and FileSharingPanel.vue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent-settings): restore the controls the Settings home dropped, settle the restart banner on the status edge (ent#887, ent#888) A completeness audit against dev found five controls with no new home and three defects in the one restart banner: - Memory/CPU: the "Inherit default" choice is back, so a per-agent override can be cleared again; the editor seeds from the override, not the effective value, and saves an empty choice as null. - Capacity: a stored value above the fleet ceiling says so, and names the value that applies. - Git tab: the "Refresh status" button the header had; the Settings › Storage link is shown only to owners, who have that tab. - General › Avatar: "Next emotion" for a prompt-generated avatar. - Overview: a stopped agent's "Created" line. - The banner's list is cleared on the agent's stopped → running edge, not by the button: a start that fails keeps every waiting change listed (the lifecycle composable reports and returns normally, so the old code read a failed start as success and lost the guardrails / GitHub-token entries), and a manual stop/start from the header settles the list too. - FileSharingPanel resets on an in-place agent switch, so agent A's "restart required" is never announced for agent B. Also: the two @Interactive e2e specs that deep-linked to ?tab=info now open Settings › General → Technical details; user docs name Settings › Storage / People instead of the retired Credentials / Access / Folders tabs. Red first: agentSettingsRestart.mount.spec.js (failed start kept nothing, manual start settled nothing), fileSharingPanelAudience.spec.js (switch carried the wait over), agentSettingsHome.mount.spec.js (no inherit option, no ceiling note) — 5 assertions red on the previous commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(screenshots): drop deleted SettingsPanel.vue from settings-general sources The agent Settings home (ent#887) deletes components/settings/SettingsPanel.vue. That component was the agent page's old Settings tab, never part of the platform Settings view the screenshot shows, and screenshot-guard fails on a missing source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(1712): repoint the ReliabilityPanel wiring guard at the Guardrails pane The agent Settings home (ent#887) deletes components/settings/SettingsPanel.vue and mounts ReliabilityPanel in agent-settings/GuardrailsPane.vue. The guard now reads the pane and asserts the rendered tag, not just the name. Negative control: removing the <ReliabilityPanel> tag turns it red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
… New + the agent Settings home (#3822) - v1.0.0 What's New: everything since 0.9.5 (Inbox and approvals, gated skills, Projects, roles and metrics, Settings home, agent limits, pull sync, A2A, AWS/Vultr), fixes, hardening, upgrade notes and known limitations. - Agent page: the Settings home (rail of six panes, restart banner, retired Info/Credentials/Access/Folders tabs and their aliases) across every page. - Files: who may read, write and delete each kind of file, with refusal codes. - Agent-key reach on fleet-wide routes; peer skill grants; role approvers. - Approval card, withdrawn asks, Inbox sorting, Workspace rail and composer, project page and Steward card. - A2A: credential required inbound; AAuth agent identity (off by default). - Sign-in without an email provider, https URLs behind a proxy, public-link expiry, Telegram chunking, chat_with_agent receipts, agent-dev 1.22.3. - Corrections: MCP tool count 168, schedule webhook URLs come from the request host, Telegram/WhatsApp webhooks read the Public URL setting. - FAQ: 632 -> 658 questions; index regenerated from page headings (it said 630 on dev and had drifted). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…d Workspace doors out (Abilityai/trinity-enterprise#950) (#3823) * feat(ui): agent header — repo, tags and a context chart back; Talk and Workspace doors out (Abilityai/trinity-enterprise#950) Amends the ent#887 header (#3798) per product review: - Repo line: the bound GitHub repo from the live git status (linked via utils/gitRemote.js — http(s)/ssh on a dotted host only, built from host + path), branch, "N changed" / "N ahead" / "N behind" or "in sync". Pull/Push stay on the Git tab. - Tags: two BaseBadge chips + a counted "+N"; owners edit in a BaseModal around the existing TagsEditor, same update/add/remove-tag handlers. - Context ring (AgentHeaderContext.vue) for a running agent, fed by stores/network.js contextStats through useAgentContextStats (15 s, visibility-gated, no stacked requests). Started/stopped in AgentDetail's start/stopAllPolling and status watcher, since the page is KeepAlive-cached. A 0 / offline reading shows "—", never a 0% ring. - Talk and Workspace doors removed from the header; the call lives in the Workspace composer. armVoiceAutoStart now has no in-app caller (docs say so). - AgentDetail clears gitStatus on agent switch and on stop, and useGitSync drops a git-status answer that lands for a previous agent or after a stop. - The meta line reserves its height (two lines below sm) so late git/tag data never moves the tab strip. Specs: header mount spec, gitRemote, useAgentContextStats, useGitSync stale answer; door specs flipped to absence; e2e Talk-door spec split into a hermetic agent-header spec (worst-case repo/tags at 375/768/1440) and workspace-voice-query (the ?voice= one-shot cases). source-text baseline lowered for portalVoiceMode (10 -> 9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): agent header fits 375px and fetches context stats once per load (Abilityai/trinity-enterprise#950) The first CI run of the new e2e specs caught two defects: - agent-detail-request-dedupe: /api/agents/context-stats was fetched twice on one page load. AgentDetail starts its polls from onMounted, onActivated (both fire on a KeepAlive view's first mount) and the running-status watcher; useAgentContextStats.start() restarted and re-fetched for each. start() is now a no-op while the poll runs. - agent-header @ 375px: the body scrolled 33px and the repo name was crushed to zero width — the header has ~184px of content width and the worded count badges alone need ~218px. The repo row now has two inner groups so the counts wrap under the name; below `sm` the branch hides and the counts read ±N / ↑N / ↓N (words at `sm` and in the tooltip); the name keeps a 4rem minimum. The e2e case now also asserts the name stays legible and every badge stays inside the header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): Overview trends header wraps at 375px; agent-header e2e names the overflow offender (Abilityai/trinity-enterprise#950) The second CI run of e2e/agent-header.spec.js measured the page 5px too wide at 375px with the header itself fitting. The offender is OverviewPanel's "Activity trends" title row — title + live running/queued counts + the 7d/14d/30d toggle on one rigid non-wrapping row (the #2197 class). It predates this PR; this spec is the first CI check that measures Agent Detail at phone width with a running agent. The row now wraps. The overflow assertion now reports the widest unclipped element, as body-horizontal-overflow.spec.js does, so the next failure names its cause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ing switcher, toggles, live sparklines, 7-day spend, GitHub line, capacity bars (Abilityai/trinity-enterprise#955) (#3826) * feat(ui): edit the display name and change the avatar in place on the agent header again (Abilityai/trinity-enterprise#955) ent#887 (#3798) moved the label edit and the avatar prompt into Settings › General and ent#950 (#3823) kept the header slim. The operator asked for the quick path back: a pencil beside the name edits the display label inline (editor inside the h1 so it inherits the heading's type and ink; pre-filled with the label, never the slug; Enter/blur save, Esc cancels, empty clears), and hovering — or focusing into — the avatar shows the camera (no avatar) or change-prompt + next-emotion (avatar with an identity prompt). Owners only, never a system agent — the same predicate the General pane uses. The header emits the SAME three events the General pane's Identity rows emit (`set-label`, `open-avatar-modal`, `cycle-emotion`), and AgentDetail wires them to the handlers it already owned, so the two surfaces cannot drift. Settings › General keeps its rows; the id rename stays there only (it stops the agent and re-keys ~20 tables, #1664). No new raw palette classes: the header's gray count stays at its baseline (9), the overlay's scrim and ink are `bg-black/40` / `text-white` on a photograph, the pencil uses action-primary tokens, and the inline editor inherits the h1's colour. Keyboard reaches the overlay via `group-focus-within`. Tests: `tests/unit/agentHeaderIdentityEdit.mount.spec.js` mounts the header (pencil visibility per principal, label-not-slug prefill, Enter/blur/Esc/ unchanged/empty, overlay per avatar state and principal) and shallow-mounts the page (set-label reaches PUT /label and the heading follows; the modal opens from the header; `hasAvatarPrompt` is passed). Full frontend unit suite green (386 files, 6122 tests, ratchets included); `check:tokens` OK. Docs: requirements core-agent (§ Settings home), feature flows agent-settings-home + agent-avatars, architecture frontend.md, user-docs managing-agents. Closes Abilityai/trinity-enterprise#955 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): the header's inline label editor gets a definite width and swallows its underline (Abilityai/trinity-enterprise#955) Seen in the browser on the first build: the identity block is content-sized, so the editor's `w-full` resolved against the input's intrinsic ~20ch and a long name scrolled its first letters out on select-all; the 2px underline also dropped the badge rows by 2px while editing. The block and the h1 take `flex-1` only while editing (at rest nothing moves), and the input carries `-mb-0.5`. Also drops a `font-inherit` class that is not a Tailwind utility — preflight already gives the input the heading's font. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(ui): the live strip — CPU/memory and the 7-day spend row — is back in the agent header (Abilityai/trinity-enterprise#955) ent#887 moved the header's second and third rows (live CPU/memory, and the 7-day cost sparkline with today, trend and lifetime) to the top of Overview as `AgentLiveStrip`. The operator wants them on the header, where they were. The header gains a `strip` slot at the card's bottom; AgentDetail slots the same `AgentLiveStrip` there with a new `embedded` prop (a top rule instead of card chrome — mutually exclusive class arms, #2662) and no longer renders it on Overview, so the strip stays one component with one data path and is shown once. No new raw palette classes in either file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(ui): the agent header is the pre-#3798 header again — funding switcher, toggles, live sparklines, 7-day spend, GitHub line (Abilityai/trinity-enterprise#955) Operator ruling on the release build: the header goes almost back to the way it was before the Settings home (#3798). Four rows now: 1 identity — the ent#955 in-place label pencil and avatar overlay stay; the badges regain the funding badge with the overlaid native subscription select (owners), which emits the same `change-subscription` the General pane does; right: Brain door, context ring, Start/Stop 2 the Autonomy and Read-only toggles (`toggle-read-only` → the page's existing handler), the ent#950 tag chips (AgentHeaderMeta, tags only), and the live CPU / MEM / uptime sparklines against the configured limits (skeleton before the first sample, allocation when stopped) with a gear that opens Settings › General for memory / CPU (`open-resources`) 3 the 7-day spend row (sparkline, today, trend vs avg, lifetime · runs) 4 the GitHub line — repo, branch, last commit, changed / ahead / behind — only when the live status says a repository is connected, and WITHOUT Pull / Push / Refresh: the Git tab keeps the verbs (the one exclusion) AgentHeaderMeta gains `show-repo` / `show-tags` so the header renders its two halves in two rows, plus the last-commit sha the old git row showed. AgentLiveStrip is deleted and Overview no longer shows a strip. The identity spec pins the restored rows (funding select → change-subscription, viewer gets no switcher, read-only + gear events, live stats running vs stopped, spend row gated on a first run); the ent#950 spec pins the commit sha and the absence of Pull / Push on the GitHub row, and stubs `matchMedia` hoisted, which the sparklines' module-level theme read needs under jsdom. The raw-colour baseline is re-frozen in the next commit, on its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(ui): re-freeze the raw-colour baseline for the restored agent header (Abilityai/trinity-enterprise#955) Deliberate growth, named: AgentHeader.vue raw_gray 9 → 53 (the pre-#3798 rows came back with their chrome ink: toggles divider, live-stats meta, spend row, gear); AgentHeaderMeta.vue gets its first entry at raw_gray 19 (it was created after the last freeze; adding the last-commit sha made it worth pinning). No raw non-gray anywhere in either file; no hex. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(ui): stub the sparkline in the header identity spec — jsdom has no canvas 2D context, and its async clearRect failed the run as an unhandled error (Abilityai/trinity-enterprise#955) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): the agent header keeps one footprint while its data lands (Abilityai/trinity-enterprise#955) Measured on an in-app agent switch: the tab strip moved twice while the header filled in. Now every late-arriving piece has a reserved home: - the slug / description line is always one line tall; - the funding badge shows a same-size skeleton until the auth status lands; - the 7-day spend row is always rendered — skeleton in flight, "No spend yet" after a first fetch that found no runs — at a fixed min-height; - the GitHub row is reserved up front for a `github:` template (skeleton while running and the status is in flight, a sentence while stopped) and otherwise appears only when the live status names a remote; - row 2's tag block and live-stats cluster have fixed widths from `sm` up, so whether the row wraps no longer depends on which data has arrived (the skeleton, the stopped allocation and the running cluster were three different widths — a 32px jump a beat after paint). Pinned by the identity spec (reserved spend row, reserved GitHub row per template, funding skeleton). Raw-colour baseline: header raw_gray 53 → 63 for the three skeleton fills, named here rather than re-frozen apart since it is the same change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): no spinner above a still-rendered agent on an in-app switch (Abilityai/trinity-enterprise#955) Agent Detail is KeepAlive-cached: on a switch the previous agent stays on screen for the ~100ms the new one takes to land, and the page's loading spinner stacked ABOVE it, pushing the header and the tab strip down 112px and back. The spinner now renders only when there is no agent on screen at all; a switch is stale-while-loading, like a refresh. Measured: header top, header height and tab-strip top are constant across a round-trip switch. Loading-gate baseline regenerated (the gate was narrowed, not added). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(ui): one-line settings row, and an activity + capacity indicator on the agent header (Abilityai/trinity-enterprise#955) Two operator notes on the restored header. (1) Row 2 broke into two lines: the live cluster's fixed width was a hair under its running content, so uptime and the gear dropped under CPU / MEM, and the tags block's fixed width pushed the cluster itself to a second line. The cluster is now `flex-nowrap` at a min-width that fits its widest state, and the tags block is `flex-1` with truncating chips — one line wherever toggles + cluster fit, and the wrap point no longer moves when data lands. (2) "Is something showing when the agent is actively working?" — now yes, the dashboard's capacity meter with a word: a `Working` badge on a pulsing green dot plus the slot bars and `active/max`, or a quiet `Idle`, beside the context ring for running agents. "Working" is the WS `workingState` first (instant), else the polled activity state; the slot count never reads below it, so the bars and the word cannot disagree. `useAgentContextStats` now also refreshes `fetchSlotStats` on its 15 s tick, so the header and the fleet tile read one map. Pinned by the identity spec (Idle / Working / WS-before-poll / hidden when stopped). Raw-colour baseline: header raw_gray 63 → 65 (the `active/max` meta text), named here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): drop the uptime label and the gear from the header's live cluster (Abilityai/trinity-enterprise#955) Operator: the CPU / MEM line carries the sparklines and their limits only. The `open-resources` emit goes with the gear (memory / CPU are in Settings › General, one click away on the tab strip). Raw-colour baseline: header raw_gray 65 → 59, named here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): the header's right cluster is Brain, capacity bars, Start/Stop — no Idle/Working word, no slot count, no context ring (Abilityai/trinity-enterprise#955) Operator: just the capacity indicator. The dashboard's CapacityMeter stands alone (its own tooltip names the slots); the Working / Idle badge, the `active/max` text and the ent#950 context ring are gone, and `AgentHeaderContext.vue` is deleted with its unit and e2e assertions. The page no longer passes `contextStats` / `working` to the header; the composable keeps the slot map fresh on its tick. Raw-colour baseline: header raw_gray 59 → 57, named here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(ui): the context-stats composable spec's fake store gains the slot fetch the tick now makes (Abilityai/trinity-enterprise#955) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): a short run lights the header's capacity bars at once (Abilityai/trinity-enterprise#955) The bars read the slot map, refreshed every 15 s; a task or message that finishes between two polls never reached them. The WS activity event lands the moment an execution starts (`stores/network.js::workingState`), so the composable now exposes it, the header fills one bar on it immediately (the bars never read below the WS signal), and the slot map is re-read on that event so the real count follows without waiting for the tick. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): the agent page sees executions start — activity events reach the working map over the app-wide socket (Abilityai/trinity-enterprise#955) Root cause of "a 10-second task never lights the capacity bars": the fleet store's WS-driven `workingState` updated only through the store's OWN socket, which the Dashboard opens on mount and closes on unmount — on the agent page nothing fed it, so the header's instant signal was dead and the bars waited for the 15 s slot poll, which a short run slips between. The app-wide `/ws` (App.vue, `utils/websocket.js`) carries the same `agent_activity` events; its handler now feeds the store's `handleActivityStatusChange` for operator roles. The dashboard "working" chip benefits the same way when the Dashboard is not the page that opened the socket. Pinned in websocketWorkspaceOnly.spec.js through the real connect path: an operator's start/terminal events light and clear the state; a Workspace-only member's do not touch it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): the capacity bars clear as soon as the run ends (Abilityai/trinity-enterprise#955) Measured on a 5 s turn: the start event lit the bar at once and re-read the slot map, but the end event only cleared the WS flag — the map still said one slot taken until the next 15 s tick, so the bar stayed lit for up to 15 s after the run. The map is now re-read on both edges; measured again: lit within a second of the start, cleared half a second after the end. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): the header's loading skeletons never widen a 375px page, and each AgentHeaderMeta half carries its own test id (Abilityai/trinity-enterprise#955) The PR-side e2e run caught two things the mounted specs cannot see: - At 375px the live cluster's "Loading live usage…" skeleton (`w-64`) pushed the page 25px wide. The cluster is now `min-w-0 max-w-full` and all three skeletons (live usage, spend, repository) are `max-w-full`, so a fixed-width placeholder is bounded by its row. - `AgentHeaderMeta` is rendered twice (tags half in row 2, repo half in row 4) and both carried `data-testid="agent-header-meta"`: Playwright's strict mode refused the locator, and the 768px fit check measured the badges against the TAGS card's right edge. The repo half keeps `agent-header-meta`; the tags half is `agent-header-meta-tags`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…mmits) Merge origin/main so main is contained in dev again. Conflict in docs/screenshots/MANIFEST.yaml resolved to dev's side (the more specific portal source paths, all of which exist on dev). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…) (#3831) recreate_container_with_updated_config replays the old container's Config.Env and Labels and re-derived only TRINITY_AGENT_AUTH_TOKEN. A renamed agent kept AGENT_NAME and trinity.agent-name under the old name, so /health, heartbeat, pull worker ids, the result callback and git auto-sync called the backend under the dead name and got 403. After that recreate the token matched, so nothing recreated the agent again. - recreate sets AGENT_NAME and the trinity.agent-name label from the live name, beside the token re-derive - check_agent_name_env_matches: present-and-different env or label drifts; absent values and unreadable attrs match. Cold start only, so an ensure-running start never kills a live execution, and never-renamed agents are not recreated - registered in the readiness-probe stub and the #1816 convergence list Mutation: reverting the recreate override reds test_recreate_after_rename_bakes_the_live_name; reverting the start wiring reds test_cold_start_recreates_an_agent_stuck_under_its_old_name; dropping the cold-start gate reds test_start_on_a_running_agent_does_not_recreate_for_name_drift. Fixes #3813 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ed key (#3814) (#3832) * fix(pull): the system agent claims its own queue with its system-scoped key (#3814) trinity-system's MCP key is scope="system", bound to its own name. The pull seams and the heartbeat accepted only scope="agent", so a system agent put in PULL_MODE_PILOT_AGENTS had every poll and heartbeat refused (403) while the backend routed its turns to the durable queue. Push is being retired, so the system agent needs a claim path rather than an exemption. - authorize_heartbeat accepts a system-scoped key only when the key row's agent_name equals the path and the path is the system agent. It gates next-task, the pull result seam, the heartbeat and the #1083 callback. A system key never acts for another agent (403 / uniform 404). - The result seam's pre-check runs the same predicate against the key's own bound name, so an unbound system key still gets 403. - apply_pull_mode_env gives a piloted system agent TRINITY_BACKEND_URL (its worker cannot start without it) and removes it, clearing the heartbeat seen marker, on de-pilot. _create_system_agent now applies the helper. - check_pull_mode_env_matches treats a piloted system agent without the URL as drift, so a pre-fix container is recreated on its next cold start. - ensure_deployed logs a WARNING while the system agent is piloted and files a system_agent_pull_drift platform alert while its running container has no worker (it is never recreated while running). Mutation check: reverting each production change turned a test red (test_system_key_acts_for_the_system_agent, test_result_system_scoped_key_403, test_piloted_system_agent_gets_pull_flag_and_backend_url, test_creation_arms_a_piloted_system_agents_pull_worker, test_piloted_system_agent_without_backend_url_is_drift, test_boot_reports_the_pull_state_of_a_running_system_agent, test_the_kind_is_registered_under_a_reserved_prefix). Fixes #3814 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pull): review follow-ups for the system agent as a pull pilot (#3814) - Boot logs the pull-mode WARNING on a fresh install too (create branch). - POST /api/system-agent/restart re-reads the replacement container, so the system_agent_pull_drift alert ends at the restart it names as the remedy. - Legacy-subject parser for the new base-image-stale-pull- prefix. - apply_pull_mode_env no longer clears the heartbeat itself; every caller recreates the container and clear_agent_breakers already does it. - Stale wording: the 403 detail, pull_mode and reports docstrings, and the internal-system-agent flow. - Learnings fragment for the self-acting-gate class. Mutation check: removing each new wiring line turns its test red (test_boot_reports_the_pull_state_before_a_fresh_create, test_restart_re_reports_the_pull_state_of_the_new_container, test_a_legacy_row_with_the_prefix_parses_to_this_kind). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… config writes are audited (Abilityai/trinity-enterprise#903) (#3845) Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ler's own task (#3751) (#3836) The bearer branch checked only that the execution belonged to the agent, so any principal that passed _authorize_inbound (owner, shared user, admin, any agent key of the owner) read or cancelled every execution id on it. AAuth callers were already bound by execution_owned_by, payers by the payer binding. _bearer_started_by: the row was started through A2A (triggered_by='a2a') by this user id AND this key id (None on both sides for a JWT). The key, not the user, is the identity: every agent key of one owner resolves to the owner's user id and role. No owner/admin override for the same reason. Anyone else gets the unknown-id answer byte for byte, and a refused cancel signals nothing. This scopes the A2A task handle; the REST execution routes are trinity-enterprise#875. Mutations: removing the gate turns 21 of 28 new tests red; removing the triggered_by check turns 5 red. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… apply (trinity-enterprise#858) (#3837) /ws/events authenticates its MCP key itself and never runs get_current_user, so each check that function makes has to be repeated in the handler. The #995 deactivation check was the one missing; it now reads the owner row the handler resolves, before accept(), and closes the same way as an inactive key. Mutation: removing the guard turns both placement tests red. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ity-enterprise#860) (#3843) * fix(rooms): every principal's access is re-checked at wake time (trinity-enterprise#860) The wake-time access re-check in shared_sessions._after_landing ran for Workspace principals only, on the reasoning that a platform member's access was resolved when the room was created. Access can change after that, so a room now asks every principal the question room creation asks (_assert_can_reach_agent, as the predicate _can_still_reach) before each wake: the portal roster for a Workspace principal, the platform ACL for everyone else. An agent that fails it is not woken and the room gets the existing "could not be reached" line. An agent's reply carries the person whose turn started the chain, so a cascade is held to that person's access. Mutation: reverting the service turns the 3 platform and agent-chain tests red; the Workspace tests pass either way, as that path was already checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * merge-train: re-point the ent#362 wake-control pin at the #860 reach check (trinity-enterprise#860) — mechanical The source pin asserted `agent_on_roster` appears in `_after_landing`. #860 replaced that inline portal-only check with `_can_still_reach` -> `_assert_can_reach_agent`, which still reads the roster. Pin the new chain link by link instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…ts, add the agent header and late security fixes (#3850) Embeds screenshots for the Inbox approval card, the Skills tab, Projects (list + overview), the Settings home and Guardrails pane, the rebuilt agent header, the New agent dialog and Workspace suggestions. Folds in the agent header restoration and three security fixes that landed after the What's New was first written (room wake-time access re-check, live event stream for suspended accounts, A2A task scoping). Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…3852) * chore: set version to 1.0.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(learnings): fold fragments into the ledger for v1.0.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(releases): add v1.0.0 release notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
| is_sensitive = _is_sensitive_chained_key if chained else _is_sensitive_kv_key | ||
| if not is_sensitive(key.group(1)): | ||
| continue | ||
| pair = _KV_PAIR_RE.match(text, key.start()) |
| pos = 0 # where the next key search starts; only ever moves forward | ||
| prev = -1 # end of the previous harmless `KEY=`; -1 = none in this chain | ||
| while True: | ||
| key = _KV_LINE_RE.search(text, pos) |
| if any(secret in value for value in query.values() for secret in credential_values): | ||
| return False | ||
| if (query["response_type"] != "code" or | ||
| not re.fullmatch(r"[A-Za-z0-9_-]+\.apps\.googleusercontent\.com", query["client_id"]) or |
vybe
approved these changes
Oct 20, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
Release v1.0.0 — pre-release checklist passed, CI green (CodeQL alert-count artifact verified: all 37 alerts pre-exist on dev).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v1.0.0 — cumulative changes since v0.9.5.
443 commits · 406 merged PRs · 2,485 files (+352k/−34k) · 217 public + 156 private-tracker issues · enterprise submodule 08cd4dd → 982735f · release candidates rc1–rc4 cut from
devalong the way; the release is cut from thedevtip after rc4.Full release notes (every issue across both trackers, upgrade notes, config-key changes, known limitations):
docs/releases/1.0.0.md· user-facing: What's NewHighlights
1.0 is built around one story: an agent takes a seat on the team. It has a named
role, serves named people, works inside projects, is bounded by approvals the platform
enforces, is measured against business numbers, and reaches people through one Inbox.
who approves for it, who collaborates and who views; the agent knows this on every
run. One companion can serve several seats, an agent can hold a seat itself, and every
assignment change is audited. Workspace shows each person a role card with the
role, objectives and metric freshness (ent#500, ent#527, ent#810, ent#811, ent#814,
ent#817, ent#676).
All, reading pane) across every agent. Agents ask over MCP; an ask can be answered with
an option, "something else", discussed in a linked chat, or dismissed, and stays attached
to the chat it was raised in. Asks are addressed to a role, never a named person
(ent#610 in part, ent#611, ent#606, ent#816, ent#748, ent#747, ent#738, bug(operator-queue): a pending approval can sit unseen for days — the human's card goes stale while the container↔platform sync stays silent #2915, Approval asks have no "something else" answer — the person must pick an option they reject before Send unlocks #3242,
Agents write long, compound asks — teach atomic asks in the ask_operator contract and cap option count/length #3243, Scheduled runs re-ask what is already pending — show an agent its open asks and let it replace one #3247).
person who can open the agent before the agent ever sees the request, an approved
run executes once with exactly the approved input, and an in-container hook refuses the
bypass lane. The requester is told how it ended (ent#751–fix(tests): fix sys.modules leaks causing 47 cross-file test errors (#752) #756, ent#844, ent#881,
Gated skill: the person who asked never sees the outcome in their chat; the requesting agent's wake-up run carries no channel context #3653, Gated skill: the Chat tab says "Finished successfully" without the run's reply, so an approved run that did nothing reads as done #3654).
instructions, other agents or projects only with an admin-granted toggle. An agent key
reaches only itself, the agents it may call and the agents it spawned — on fleet routes
too; an edge exposes the skills the target declares, and library skills need a per-skill
grant (ent#164, ent#756, ent#662).
Tasks / Log / Files tabs and a Steward agent; agents work on projects over MCP
(ent#661, ent#896, ent#588).
MCP (batched, idempotent, restatements reported); dashboards and the role card show
target vs actual with freshness, and a stale metric says so (ent#477–Architecture Validation: 4 violations found (2 critical) #479, ent#666,
ent#812, ent#727, ent#729–fix(schema): backfill schema.py to match migrations.py reality (#691) #731, ent#671).
stays in
calibrating(no proactive brief) until its owner marks it ready(ent#465, ent#637, ent#663, ent#689).
funding switcher, live sparklines, 7-day spend, GitHub line, capacity bars; a two-pane
New agent dialog (ent#887, ent#888, ent#890, ent#891, ent#353, ent#950, ent#955, bug(ui): Create Agent dialog overflows the screen, scrolls both ways and jumps — redesign as a fixed two-pane picker #3792).
are recorded; divergence over 24h turns red on the agent card and fleet health
(ent#703–bug(slack): startup connect failure leaves Slack permanently offline — no retry, watchdog never spawns #708, bug(git-sync): the auto-sync toggle is not authoritative — OFF never takes effect (the baked env is OR'd forward at recreate), ON waits for the next recreate #3010, bug(git-sync): the auto-sync heartbeat pushes without fetch or rebase — on a shared branch it fails non-fast-forward forever after the first foreign push #3011).
usersigns in to Workspace only; sharing anagent no longer creates an operator login (ent#837).
CloudFormation) and a Vultr Marketplace listing join DigitalOcean (bug: one-click hosted installs (DigitalOcean 1-Click first) create new instances on SQLite after its end-of-support — bundle PostgreSQL in the hosted compose #3159, feat: AWS one-click — Marketplace free AMI (Launch from Website) + CloudFormation Launch Stack link #3004, feat: Vultr Marketplace imageless listing for Trinity (cloud-init Vendor Data) #2282).
needs a credential (the keyless trusted-network path was removed before release,
ent#885). Signed AAuth agent identity between instances ships dark behind
AAUTH_ENABLED(PRs feat(a2a): AAuth agent identity across instances, behind an off-by-default flag (abilityai/trinity-enterprise#623) #2956, feat(aauth): person-token binding through a person server — an agent acts for its owner (abilityai/trinity-enterprise#878) #3659).longer act as their owner on admin/owner routes, guardrail hooks run from an isolated
read-only config, the Files surface has one tier-per-verb rule, rooms re-check access at
wake time, suspended accounts lose
/ws/events, A2A callers see only their own tasks.Closes #1482
Closes #1925
Closes #1989
Closes #2105
Closes #2107
Closes #2197
Closes #2201
Closes #2202
Closes #2282
Closes #2318
Closes #2329
Closes #2372
Closes #2392
Closes #2514
Closes #2711
Closes #2771
Closes #2806
Closes #2840
Closes #2842
Closes #2843
Closes #2845
Closes #2846
Closes #2885
Closes #2888
Closes #2889
Closes #2890
Closes #2899
Closes #2900
Closes #2907
Closes #2914
Closes #2915
Closes #2918
Closes #2919
Closes #2930
Closes #2938
Closes #2944
Closes #2945
Closes #2952
Closes #2955
Closes #2957
Closes #2958
Closes #2959
Closes #2964
Closes #2968
Closes #2969
Closes #2971
Closes #2973
Closes #2975
Closes #2987
Closes #2991
Closes #2994
Closes #2995
Closes #2996
Closes #3001
Closes #3004
Closes #3010
Closes #3011
Closes #3012
Closes #3024
Closes #3032
Closes #3041
Closes #3046
Closes #3052
Closes #3053
Closes #3055
Closes #3059
Closes #3063
Closes #3064
Closes #3079
Closes #3080
Closes #3088
Closes #3102
Closes #3103
Closes #3104
Closes #3105
Closes #3106
Closes #3109
Closes #3114
Closes #3115
Closes #3123
Closes #3127
Closes #3130
Closes #3134
Closes #3135
Closes #3138
Closes #3139
Closes #3140
Closes #3159
Closes #3163
Closes #3164
Closes #3166
Closes #3170
Closes #3175
Closes #3177
Closes #3186
Closes #3187
Closes #3192
Closes #3194
Closes #3200
Closes #3206
Closes #3210
Closes #3214
Closes #3215
Closes #3218
Closes #3232
Closes #3233
Closes #3234
Closes #3237
Closes #3242
Closes #3243
Closes #3244
Closes #3245
Closes #3246
Closes #3247
Closes #3262
Closes #3264
Closes #3265
Closes #3272
Closes #3274
Closes #3278
Closes #3293
Closes #3295
Closes #3305
Closes #3307
Closes #3311
Closes #3312
Closes #3313
Closes #3314
Closes #3316
Closes #3319
Closes #3320
Closes #3321
Closes #3323
Closes #3324
Closes #3325
Closes #3340
Closes #3356
Closes #3357
Closes #3385
Closes #3404
Closes #3406
Closes #3430
Closes #3431
Closes #3432
Closes #3433
Closes #3434
Closes #3435
Closes #3436
Closes #3442
Closes #3443
Closes #3446
Closes #3447
Closes #3449
Closes #3450
Closes #3452
Closes #3453
Closes #3454
Closes #3455
Closes #3456
Closes #3458
Closes #3459
Closes #3460
Closes #3461
Closes #3463
Closes #3465
Closes #3470
Closes #3510
Closes #3520
Closes #3521
Closes #3524
Closes #3525
Closes #3526
Closes #3527
Closes #3533
Closes #3534
Closes #3545
Closes #3554
Closes #3574
Closes #3583
Closes #3588
Closes #3589
Closes #3590
Closes #3637
Closes #3642
Closes #3644
Closes #3653
Closes #3654
Closes #3655
Closes #3660
Closes #3672
Closes #3676
Closes #3677
Closes #3679
Closes #3688
Closes #3692
Closes #3693
Closes #3694
Closes #3695
Closes #3702
Closes #3708
Closes #3710
Closes #3712
Closes #3714
Closes #3715
Closes #3719
Closes #3725
Closes #3737
Closes #3738
Closes #3746
Closes #3749
Closes #3751
Closes #3769
Closes #3792
Closes #3795
Closes #3797
Closes #3813
Closes #3814
Private-tracker (trinity-enterprise) issues are closed manually after merge from submodule-pin ancestry (closing keywords do not cross repos).
Pre-release checklist passed 2026-10-20 (CI green on dev tip, no P0 outside dev, secrets scan clean, enterprise pin at enterprise main tip, freeze-plan MUST-LAND cleared). Ready for squash-merge.
v1.0.0publish-images.yml(tag),publish-cli.yml(src/cli/**changed),sync-docs-to-vertex.yml(docs/user-docs/**changed)🤖 Generated with Claude Code