Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ jobs:
# asserted: whether the XLA library loads in Burrito's ERTS is what they measure.
grep -q '^TRINITY_SMOKE_VEC=ok:' smoke.log
grep -E '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' smoke.log
# Slice 100, AC9: FTS5 and the model cache bind (exit 5 and 6 otherwise); the watcher line
# is recorded, because a polling fallback is the skills registry's documented behaviour.
grep -q '^TRINITY_SMOKE_FTS5=ok$' smoke.log
grep -q '^TRINITY_SMOKE_MODEL_CACHE=ok:' smoke.log
grep -E '^TRINITY_SMOKE_WATCHER=' smoke.log
echo "--- ours, before ---"; cat ps-before.txt
echo "--- ours, after ----"; cat ps-after.txt
diff ps-before.txt ps-after.txt
Expand All @@ -281,6 +286,14 @@ jobs:
throw "the vector search did not run inside this binary (slice 032)"
}
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_(EXLA|SEMANTIC)='
# Slice 100, AC9, as on the other two.
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_FTS5=ok$')) {
throw "FTS5 did not work inside this binary (slice 100)"
}
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MODEL_CACHE=ok:')) {
throw "the model cache did not resolve inside this binary (slice 100)"
}
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_WATCHER='

# AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped.
# `bash` on every OS, Windows included: the job that never curled was the one gap in
Expand Down Expand Up @@ -334,15 +347,24 @@ jobs:
exit 1
fi
elapsed=$(( $(now_ms) - start ))
code=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/")
echo "HTTP $code on port $PORT"
# A fresh data directory has no model key, so `/` answers 302 to `/setup` (slice 100,
# AC11): run 37743757349 failed all three OSes on that redirect. Follow it, and require
# that it lands on `/setup`, so a redirect anywhere else still fails here.
answer=$(curl -sS -L -o /dev/null -w '%{http_code} %{url_effective}' "http://127.0.0.1:$PORT/")
code=${answer%% *}
landed=${answer#* }
echo "HTTP $code on port $PORT at $landed"
case "$landed" in
"http://127.0.0.1:$PORT/" | "http://127.0.0.1:$PORT/setup") ;;
*) echo "GET / ended at $landed, neither / nor /setup" >&2; exit 1 ;;
esac
echo "COLD_START_MS=$elapsed" | tee -a "$GITHUB_ENV"
echo "### ${{ matrix.name }}: cold start to first HTTP 200: **${elapsed} ms**" >> "$GITHUB_STEP_SUMMARY"
# A non-200 says nothing on its own: run 35518054546 (slice 013) printed "HTTP 500"
# and nothing else, and the log that would have named the exception was never
# read. The body and the tail of serve.log go to stderr before the step fails.
if [ "$code" != "200" ]; then
echo "--- body of GET / ---" >&2; curl -sS "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2
echo "--- body of GET / ---" >&2; curl -sS -L "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2
echo "--- serve.log ---" >&2; tail -60 serve.log >&2
fi
test "$code" = "200"
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ evidence for each increment is retained by the maintainers and summarised here.
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
the public record of delivered work.

## 2026-10-08

### `slice/100`: Trinity as a desktop application

The Tauri window now does what a desktop application is expected to do. A tray icon shows whether
Trinity is idle or working and how many approvals are waiting, and offers New session, Pause
gateways, Open data folder and Quit. An approval requested while the window is hidden or in the
background becomes an operating system notification, and clicking it brings the window up on that
approval. A global shortcut (Ctrl+Shift+Space by default, configurable) shows and hides the window;
a second launch brings the first one forward instead of starting another; Trinity can start at
login.

Provider keys typed into Settings are stored in the operating system's keychain (the macOS Keychain,
the Windows Credential Manager, the Secret Service on Linux) and nowhere else: not in the database,
not in a settings file, not in a log. The shell binary itself is the keychain helper, so the key is
reachable from the moment Trinity starts. A key in the keychain takes precedence over the same name
in the environment, and with no keychain (a server, or `mix phx.server`) the environment works as
before. The receipt signing key moves into the keychain the same way, by rotation: a new key is
created there, the old one is marked retired with the moment it stopped being used, and receipts
signed before the move still verify.

Quitting during a reply keeps what had arrived, marked as interrupted, and the conversation shows
that on the next launch. A machine with nothing configured now opens a short setup (a model and its
key, the data folder, the first project folders) instead of a chat that cannot answer. The packaged
binary's self-check reports three more things: full-text search, the file watcher, and the model
cache.

Verified by the test suite (the keychain through a stand-in helper, the shell's channel through a
real socket, shutdown and startup through separate operating system processes) and on Linux by
driving the real shell on an isolated display with a real keyring. The window, tray, notification,
shortcut and second-launch behaviour on macOS and Windows have not been observed yet and are listed
for manual verification.

## 2026-09-26

### `slice/128`: the checks that guard attribution now say what they check
Expand Down
10 changes: 10 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ config :trinity, Oban,
),
notifier: Oban.Notifiers.PG,
queues: [agent_tasks: 1, memory: 2, maintenance: 1],
# Slice 100, AC7: on quit, a running job gets this long to finish before Oban stops it. Named
# rather than inherited (Oban's own default is the same 15 s) so a change upstream is a diff
# here; a job cut short is retried by the lifeline below, and a turn it was driving keeps what
# arrived through the sessions' interrupt path.
shutdown_grace_period: :timer.seconds(15),
plugins: [
{Oban.Plugins.Cron,
crontab: [
Expand Down Expand Up @@ -234,6 +239,11 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

# Slice 100: the Settings page and the setup path take provider keys in a form whose parameter is
# `secret`. Phoenix and LiveView log a request's or an event's parameters at debug level; anything
# whose name contains one of these is logged as "[FILTERED]". Phoenix's own default is the first.
config :phoenix, :filter_parameters, ["password", "secret"]

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
Expand Down
5 changes: 5 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,8 @@ config :trinity, :oban_web, true
# counter-example found at 20,000 runs is reproducible with that seed at any width.
config :stream_data,
max_runs: String.to_integer(System.get_env("TRINITY_PROPERTY_RUNS") || "100")

# Slice 100: the suite runs on no desktop shell, and its settings file lives under the project's
# ignored tmp/, never in the data directory of the machine running the tests.
config :trinity, :desktop, impl: Trinity.Desktop.Noop
config :trinity, Trinity.Settings, path: Path.expand("../tmp/test_settings.json", __DIR__)
16 changes: 13 additions & 3 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ Trinity.Application
├── Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080
├── Trinity.Sandbox.Supervisor # Luerl workers. Slice 110
├── Trinity.Desktop # ex_tauri bridge (tray, notifications). Slice 100
│ # As built: `Trinity.Desktop.Shell`, after the sessions and
│ # before Oban; the tray, the notifications, the tray's
│ # actions, on the `Trinity.Desktop` implementation the
│ # process selected (`Tauri` when the shell launched it,
│ # `Noop` otherwise). `ExTauri.ShutdownManager` (the
│ # channel and heartbeat) is first in the tree outside :test
└── TrinityWeb.Endpoint # LiveView. Slice 013
```

Expand Down Expand Up @@ -95,7 +101,7 @@ without anything failing.
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing (as built at 070: `Adapter` (the behaviour: `child_spec/1`, `capabilities/0`, `deliver/2`, `format/2`, `render_approval/2`, plus `name/1` deriving the row's adapter name from the module), `Console` (the in-process adapter the tests and `mix trinity.console` talk to), `Format` (chunking to the channel's limit, re-opening a split code fence, markdown reduced to text where a channel renders none, the default approval rendering), `Identity` and `Identities` (the `gateway_identities` rows, the pairing code and the allowlist), `Router` (one process: admission, then the token bucket, then commands, then the session; it subscribes to `session:<id>` and `approvals:<id>` per conversation and coalesces deltas into an edited message), `Commands` (the slash registry, including `/attach`, `/approve` and `/deny`), `Cap` (the channel trust cap) and `Receipts` (the capped-decision receipt)) | Sessions, **Permissions**, PubSub |
| `Trinity.Subagents` | Delegation, result collection (as built at 080: `delegate/3` and `delegate_many/3` creating child sessions with `origin: "subagent"` and `parent_id` set, one turn each under a monitor, the awaiting shape reused from slice 050's `RunTask` including its drain of the start `:idle`; budgets of turns, tokens and wall clock; `restarts` defaulting to **0**, because replaying a turn replays arbitrary tool calls and a `:session` or `:always` approval would cover the replay silently; `cancel_subtree/1` depth first; `running?/1` asked of the process rather than of `SessionRow.status`, which is the session lifecycle and stays `active` after a subagent finishes; `short_id/1` taking the **tail** of a UUIDv7, whose head is shared between children created in the same second) | Sessions |
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
| `Trinity.Desktop` | ex_tauri bridge | PubSub |
| `Trinity.Desktop` | ex_tauri bridge (as built at 100: the behaviour with `Noop` and `Tauri`; `Shell`, the process that keeps the tray and decides notifications; commands to the Rust window over ex_tauri's channel, events back; `Trinity.Secrets`, `Trinity.Settings` and `Trinity.Setup` sit in `Trinity` itself because providers, tools and receipts read them) | PubSub (as built at 100: `Trinity`, Gateways, Sessions, Permissions, Scheduler: it counts approvals and busy sessions, creates a session, pauses the gateways, and notifies on a finished task) |
| `Trinity.Telemetry` | The event catalogue and the only module that names an event (as built at 090: `span/3` and `emit/3` routing everything through one place; emitters for the LLM, tool, approval, session, gateway and budget events listed in `docs/telemetry.md`, which was written **before** them because an event name is an interface and a handler attached to a name that no longer fires simply never runs; `trace/2` giving a turn one trace id so its events nest, with no tracing library and no dependency; `Telemetry.Costs` reading `usage_events` schemalessly, because `Trinity.LLM` depends on `Trinity` and naming its schema would be a cycle; `Telemetry.Activity`, a bounded buffer whose handler does one `send` because a telemetry handler runs in the emitting process; `Telemetry.Redaction`, a primary Logger filter that masks shapes rather than names and never drops a line) | Repo |
| `TrinityWeb` | LiveViews, components, API | all `Trinity.*` public APIs |

Expand Down Expand Up @@ -173,7 +179,10 @@ development under `TRINITY_FAKE_PROVIDER=1`.
is size 1 for writes; reads may use a second pool). FTS5 virtual table for message search. `sqlite_vec` for vectors.
- Secondary: **Postgres + pgvector**, selected by `TRINITY_DB=postgres`. Enables Oban Pro Workflows later. Kept
compiling and tested in CI (matrix), not default.
- Durable settings/secrets: OS keychain via `Trinity.Secrets` (Slice 100); before that, env vars.
- Durable settings/secrets: OS keychain via `Trinity.Secrets` (Slice 100); before that, env vars. As built at
100: secrets in the keychain (through the shell binary's `--keychain` mode), the environment as the fallback;
non-secret desktop settings (hotkey, notifications, launch at login, chosen folders, the setup's default model)
in `<data dir>/settings.json` (`Trinity.Settings`), mode 0600.

## Concurrency and state rules

Expand All @@ -194,5 +203,6 @@ priv/personas/ default SOUL.md
test/support/ Mox definitions, factories, fake tools/providers
docs/ architecture, data model, security model, standards, ADRs
docs/ this plan's docs and ADRs
tauri/ desktop shell (Slice 001+)
tauri/ desktop shell (Slice 001+; as built, `src-tauri/`, the generator's name: main.rs
and keychain.rs, slice 100)
```
54 changes: 54 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,60 @@ regulated effect.
- Never in DB, never in logs, never in prompts. `mix gate` runs a regex secret scan on the diff.
- Provider keys are read at call time by the provider module; not held in Session state.

**As built at slice 100.** The first line above named two candidates; neither was chosen.

- **Where.** `Trinity.Secrets` asks the OS keychain first and the environment second, by name (an
environment-variable name such as `OPENROUTER_API_KEY`); `Trinity.Config.secret/1`, the one reader
providers use, goes through it. The keychain is reached through the Tauri shell's own binary run as
a command, `trinity --keychain probe | get | set | delete <NAME>`, whose `keyring` crate uses the
macOS Keychain, the Windows Credential Manager or the Secret Service. The shell hands the sidecar
its path as `TRINITY_KEYCHAIN_HELPER`. A command rather than the window's channel because the
channel exists only once the window has attached, and the receipt signer needs its key before
that; a command rather than a NIF because the Rust is already in the shell.
- **How a value travels.** On the helper's stdin (set) or stdout (get), hex-encoded, one line; never
on argv, where any process on the machine could read it with `ps`. The helper reports a failure by
kind and never prints a value (`keyring`'s encoding error carries the stored bytes, so its form is
never printed).
- **Never anywhere else.** `store/2` with no keychain is `{:error, :keychain_unavailable}`, not a
file. The Settings page and the setup path submit the key as a parameter named `secret`, which
`config :phoenix, :filter_parameters` logs as `[FILTERED]`, and never render it back.
`Trinity.Settings` (the non-secret settings file) refuses an unknown key and any string shaped like
key material. Tests scan the database files, the settings file and the captured log for the value;
the Linux run scans the data, config, cache and runtime directories and greps the database with
`strings`.
- **The receipt signing key** moves into the keychain by rotation when the keychain first answers:
a new key whose private half is stored there and read back before anything changes, a registry row
for it (`custody: keychain`), a `retired` row for the old key with `valid_until` and
`superseded_by`, the old key file kept (renamed, mode 0600), and the boot receipt naming the
custody. The verifier refuses a retired key's receipt dated after its `valid_until`. A process
with no helper (a `mix phx.server` on a data directory the desktop app has migrated) cannot sign
and denies every effect, the fail-closed shape of slice 024; `TRINITY_KEYCHAIN_HELPER` pointed at
a built shell binary restores it. An export made with the private key does not carry a
keychain-held key; that is a follow-up of slice 034's backup story.
- **Not yet in the keychain:** the MCP OAuth client tokens (`<data dir>/secrets/oauth/`, slice 062)
and the MCP server's token and state key (slice 061). They stay files, mode 0600, until a slice
moves them.

## The desktop shell's channel (slice 100)

The Rust window and the BEAM share the channel `ex_tauri` keeps for its heartbeat: a Unix socket in
the temporary directory, mode 0600, on macOS and Linux, and a loopback TCP port whose number is in a
file on Windows (slice 001 finding F2: any local process can connect there). The shell generates a
token per launch, hands it to the BEAM as `TRINITY_SHELL_TOKEN`, and presents it in a `hello` on
every connection; `Trinity.Desktop.Shell` compares it in constant time and, until it has matched,
ignores tray clicks and dialog answers and refuses requests. A peer that is not the shell therefore
cannot quit Trinity through the tray, open a folder, or put a folder of its choosing into the
filesystem allowlist. Window focus is believed from anyone; the worst it does is cause or suppress a
notification. **What remains open:** any peer can still connect and then stop sending heartbeats,
which shuts the BEAM down (the denial of service F2 names), and `ex_tauri` 0.2.0 does not tell
subscribers when a peer disconnects, so trust lasts until the next hello rather than until the
connection ends. Closing those needs a change to the heartbeat itself.

**What leaves the machine, or the app, through the shell.** OS notifications are shown and kept by
the operating system's notification centre, so they carry the tool's name, the task page or the
channel's name, and nothing else: no tool arguments, no message text, no result. A notification's
click can send the window only to a path of this app, never to a URL.

## Gateways (Slice 070, as built)

- **An unknown sender gets a pairing prompt and nothing else.** No session is created, no model is
Expand Down
Loading
Loading