Skip to content

Release: v1.0.0 - #3853

Merged
vybe merged 1372 commits into
mainfrom
dev
Oct 20, 2026
Merged

vybe merged 1372 commits into
mainfrom
dev

Conversation

@trinity-ability

Copy link
Copy Markdown
Contributor

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 dev along the way; the release is cut from the dev tip 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 New

Highlights

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.


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.

  • Tag after merge: v1.0.0
  • Triggers: publish-images.yml (tag), publish-cli.yml (src/cli/** changed), sync-docs-to-vertex.yml (docs/user-docs/** changed)

🤖 Generated with Claude Code

dolho and others added 30 commits October 5, 2026 18:49
…#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>
dolho and others added 25 commits October 19, 2026 17:37
…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>
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Sonnet 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&section=…); 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 vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Release v1.0.0 — pre-release checklist passed, CI green (CodeQL alert-count artifact verified: all 37 alerts pre-exist on dev).

@vybe
vybe merged commit ef8112e into main Oct 20, 2026
47 of 48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

10 participants