The grant junctions (plugin_access, mcp_global_access, mcp_catalog_access)
stay deny-by-default internally, but the rows are written for you at two
moments and never again:
— an object is CREATED: PluginManager::update_config (first toggle —
the plugins row's birth), mcp::catalog_upsert, marketplace install,
mcp::global_enable
— a user is CREATED: UserManager::register_user
Who is included is the role attrs.auto_grant flag (default true, so every
role predating the attribute behaves like an adult member). The seeded
Children preset sets it to false, which is the whole reason the attribute
exists. Admins are skipped because they hold everything implicitly. The
role editor now exposes the switch as a checkbox.
New crate module: db::access_defaults (seed_new_object, seed_new_user,
set_grant_by_default). Additive columns: grant_by_default on plugins,
mcp_catalog, mcp_global_servers (INTEGER NOT NULL DEFAULT 1).
On the frontend the Roles page gets a "New extensions" column and
checklist; the user's plugin/connector rosters are unchanged. i18n:
en, fr, it.
Docs: new docs/access.md for the assistant, plus index.md cross-link.
CLAUDE.md updated with a full default-access section.
Every full-page view now renders the same sticky top bar (back button,
title, right-side actions) from the new web/css/page-header.css,
replacing a dozen per-page duplicates (.page-panel-header,
.project-page-header, .task-page-header, .llm-page-header, .um-header,
.apr-header, .llmr-header, .pv-header, .sa-header, .config-page-header,
.agents-page-header). Pages that padded the whole container (config,
agents, system-agents, llm-requests, models-hub) move that padding into
a body wrapper so the bar sits flush and stays pinned on scroll.
Back buttons are standardized to the icon-only .page-header-back.
Granting was a checklist of every user on each plugin's page, so "what may
this person use?" meant opening every plugin in turn — and the answer lived
on N pages while the connector half of it already lived on one. Both grant
sections now sit together on #users/{id}: same row list, same disabled chip,
same replace-the-whole-set save. The plugin's own page keeps a read-only
roster of who holds it, linking back to each person.
- db: plugin_access::set_for_user, the per-user twin of set_for_user on
mcp_catalog_access; set_access stays as the inverse read model
- PluginManager: list_grants_for_user / set_grants_for_user, which omit and
reject manages_own_access plugins (a box that controls nothing is worse
than no box)
- GET/PUT /api/users/{id}/plugins, mounted next to /users/{id}/connectors;
PUT /api/plugins/{id}/access is gone, GET remains as the roster
No push after the write, unlike a connector grant: that one gates a runtime
snapshotted at login, while a plugin grant is re-read from plugin_access on
every request that depends on it (sidebar pages, /plugins/mine, and each
inbound channel message), so a revoke lands with no bus event.
Docs updated with where access is granted, and why mobile-connector is
absent from that list.
TIC said nothing about what the agent does, and named the wrong thing: the
tick belongs to the scheduler, which is generic and lives outside it. The
agent's only decision is whether an incoming event deserves an interruption
— it sorts, it never acts — so it is now event-triage, matching the
functional naming of the two memory lints.
- agents/tic/ -> agents/event-triage/, module tic/ -> event_triage/,
TicManager -> EventTriageManager, TicConfig -> EventTriageConfig
- agent id and chat source: "tic" -> "event-triage"
- config keys: tic.* -> event_triage.*, and the config.yml section tic: ->
event_triage: (greenfield: previously set values fall back to defaults)
- i18n en/it/fr: Event triage / Triage eventi / Tri des evenements; dropped
the stale "TIC sessions" mention from the debug-pages description
- docs/system-agents.md, docs/index.md, docs/settings.md, CLAUDE.md, SKALD.md
Memory is kept as a maintained wiki, and a wiki nobody prunes rots. This adds
the scheduled maintenance pass, and generalises the machinery TIC had grown so
that a background agent is a trait impl rather than a loop of its own.
Two lint agents, not one. The private pass runs per user over `user-memory/`
and reports to them; the shared pass runs once over `shared-memory/`, where the
interesting defect is different — a note failing the table rule, i.e. private
business written where every member can read it. It names the note and the
category without repeating the content, since restating it spreads the very
thing being flagged. Both share `agents/common/memory-lint.md`.
Both are read-only, and that is enforced twice: the prompt says report-never-
repair, and `shared-memory/*` writes are already `@fs_write require`, so an
agent that tried to fix something would raise an approval card from an
unattended pass, which is auto-denied. Read-only is the only design that works
here, not merely the safe one.
One scheduler for cadences three orders of magnitude apart. TIC runs every few
minutes, a lint weekly — the case that tempts a second loop. It stays one
because the wake-up decides nothing: `base_tick` picks only how often to look,
and whether an agent runs for a user is `is_due` against persisted state.
Due-ness moves out of the run log into a new owner table, `system_agent_state`.
The two answer different questions: the run log skips idle ticks so it stays a
history rather than a heartbeat, while scheduling needs every attempt. Reading
due-ness off the log would re-run an idle agent on every tick and never bring a
weekly one due once its last productive run aged out. Persisting it is also
what makes a long interval survive a restart — an in-memory deadline is fine at
TIC's scale, but a weekly agent on a box rebooted every few days would have it
re-armed before it ever fired.
The shared store belongs to nobody, so `AgentScope::Instance` runs that pass as
the first unlocked admin. An ownerless run would write its trace into system.db,
which the runs endpoint shows to nobody by design, and its notify() would have
no recipient; attributing it to a user keeps the whole per-user surface working
unchanged.
Settings move to where the run log is. `ConfigSet` gains `owner`, so placement
is data on the set rather than a page that knows set names; the System agents
page grows one tab per agent holding its description, its settings (admin only)
and its runs — "why did this do nothing last night?" is half a schedule
question and half a log question. The form is shared with the Config page, and
writes still go through PUT /api/config/{key}.
Fixes an authorization gap found on the way: neither /api/config handler took
the caller into account, so any authenticated session could read and write
instance-wide config. The sidebar hiding the page is presentation, not access
control. Both are now admin-gated.
The generic per-user #plugins page is gone: a plugin with per-user
settings hosts them in its own web_pages() sidebar page instead
(Telegram's pairing page is new; Honcho's opt-in page already existed).
The admin catalog moves from #plugin-catalog to #plugins (old hash
redirected), and user_config_schema is removed from the Plugin trait,
the API DTOs and both plugins — the my-config endpoint, the
plugin_user_configs store and the update_user_config hook stay, now
driven by each plugin's own page fragment.
The two admin-only console pages become a single "Mobile App" page
visible to every logged-in user: connection status (with the last
connection error for troubleshooting), the device list (admin sees all,
others only their own), a pairing dialog with the QR, and — admin-only —
a settings dialog hosting the plugin config, including a relay picker
(official grayed out, test, custom URL). The generic plugin-detail
config form defers to it via the new Plugin::config_in_detail_page flag.
Pairing is now self-service: any user opens a window and the device
auto-binds to them; revocation is admin-for-anyone, owner-for-self;
(re)binding to another user stays admin-only. Binding-managed plugins
(manages_own_access) now expose their non-admin pages to all users and
self-scope per caller (web_pages_for). The relay client records the
error that ends a WS session and clears it on reconnect.
The connectors-assignment dialog was the fourth modal on the Users page
and the first to break: a checkbox list taller than the viewport with no
scroll. Same failure the connector activation and manual-add dialogs had,
same fix — a page. The list stays a table, but rows are clickable and
open the user's own page with three sections:
- Profile: the old edit form (username, display name, role, directory
fields, active switch) with a saved tick;
- Connectors: the grant checklist as the Connectors page's row list
(icon, name, description, search, global/personal groups), one Save;
- Security: password reset (disabled with an explanation for encrypted
users) and the delete action.
Only user creation stays a modal — three fields and a role fit.
Row descriptions clamp at three lines instead of one. Metadata chips
(scope/type/auth) lose the loud accents — only status chips keep colour —
and the auth chip speaks human ("Requires an API key") instead of the
raw enum, via a shared authLabel().
Drop the access-grant section from the connector detail page: the Users
page modal already grants both global and per-user connectors, so
"who has what" now has a single surface.
The standalone Catalog page added nothing the Connectors page could not
do: drop it (component, route, sidebar entry) and move its affordances
onto the Connectors page — the Add-connector dropdown (marketplace /
manual form, now at #connectors/new) and per-row removal for the admin.
Replace the card grid with a sharper row list (4px radius) built for
scanning status and acting; the marketplace's back link now returns to
#connectors. i18n keys renamed catalog.* -> connectors.add/new/*.
Reframe TIC from an ownerless global loop into a per-user system agent.
The events it reads live in each user's own encrypted mcp_events, the
connectors that produced them run in that user's container, and the
notifications go to that user's hub — so the previous design (built
against the ownerless Conversation bundle, writing into system.db and
notifying a hub with no subscribers) was inert by construction.
Core changes
- TicManager owns no timer and no user list. It now exposes
run_for(user_id, pool, sessions, hub): one tick for one user, over
deps unpacked from that user's UserContext. Removed from the
Conversation bundle; Skald::tic_manager() is gone.
- New spawn_system_agents in wiring.rs: one instance-wide loop, spawned
post-construction with a Weak<Skald> (like spawn_user_lifecycle).
Each pass walks the directory and runs TIC for one user at a time —
sequential, because a pass is N container round-trips and N LLM calls
nobody is waiting on. A ConfigKeyUpdated on the interval key cuts the
current wait short; enabled is re-read per pass.
- A user whose database is still locked is skipped (normal, not an
error): the pool is the unlock token, so a user who hasn't logged in
since restart has no readable events and nowhere to record a skip.
- The configured tic.security_group is re-checked per user through
run_context::reconcile_group_for_user — a restricted member never
gets a tool set their role wouldn't grant; unconfigured starts from
role_default_run_context, never None (None = catch-all = wider).
- New system_agent_runs owner table (no user_id column — the file is
the owner): start/finish split so a crash leaves a visible 'running'
row, swept to 'failed' by the next start; safe because the scheduler
is sequential and single-instance. An idle tick writes nothing.
- counting_notify wraps the notify tool so the run log can report
notifications emitted without the tool knowing it's counted.
- The session's event channel is drained by a spawned task instead of
a dropped receiver — the translator awaits its sends and would wedge
at capacity.
EventLog::{Persist,Discard} on McpManager::new
- mcp_events is an owner table and its only reader (TIC) is per-user,
so an event is something that happened to someone. The per-user
runtime gets Persist; the ownerless global runtime gets Discard (its
pool is system.db, rows would be unattributable and unread).
API + UI
- GET /api/system-agents/runs: the caller's own run history, scoped
through require_context with no admin override (same promise as the
rest of the private pool).
- web/components/system-agents.js replaces tic-sessions.js. The old
#tic debug page inferred runs from leftover ephemeral sessions; the
new #system-agents page (sidebar group 'extensions', visible to
everyone — the data is the caller's own) reads the real run log.
- i18n: tic.* keys replaced with system_agents.* in en/it/fr.
Docs
- New docs/system-agents.md (user-facing: what TIC does, why it runs
per person, why a run can be missing). Updated docs/settings.md and
docs/index.md.
- agents/tic/AGENT.md reframed per-user: events are that person's,
memory is user-memory/ (private) — never shared-memory/.
- CLAUDE.md records the system-agents design and the EventLog seam.
Remove the scope system end-to-end (llm_models.scope column, agent meta
scope field, scope-based tier in model selection, UI checkboxes/pills):
it was only a soft ranking hint, had drifted (6 UI scopes vs 3 used by
agents, 'general' not even selectable) and duplicated what strength
already decides. Strength stays the single AUTO-selection axis.
Compaction: the summary model is now pickable from the Settings page
via a new PropertyType::LlmModel config property (registry key
compaction_model), instance-wide and live (no restart). Fallback chain:
explicit pick -> compaction.strength from config.yml -> priority order;
a deleted configured model degrades to AUTO. ContextCompactor reads the
key at compact time through GlobalConfigManager.
Add an inline auth gate to mobile.html that checks /api/auth/me on load.
Skipped when ?native=true (the iOS shell handles auth in background).
Includes a vanilla JS login form (no Lit) reusing the existing login styles.
Bump version to 0.1.2.
Auto-scroll now yields when the reader scrolls away from the bottom, so a
fast-streaming reply no longer fights someone reading the start. Stickiness is
a flag driven by a passive scroll listener (not a per-flush distance check,
which breaks when one flush adds more than the threshold of content); scrolling
back within the band re-arms it.
Centralised in chat-session.js (_scrollToBottom(force) + _forceScrollToBottom
+ _messagesContainer hook), removing the duplicated overrides in copilot.js /
chat-page.js. Force-scroll is used where the latest must be shown: history
load, chat reopen, approval/clarification prompts, and message send.
A sticky 'jump to latest' affordance appears only while scrolled up.
- GET /api/file returns ETag (mtime+size) + X-Writable on disk files;
PUT /api/file accepts optional if_match -> 409 Conflict on stale version
(last-write-wins preserved when omitted), echoes the new ETag
- FileViewerBase: View | Edit tabs for .md when the caller can write;
source textarea with Save/Cancel, live preview while editing
- Watcher no longer clobbers the buffer mid-edit: while editing with
unsaved changes it probes the server ETag and only raises a conflict
when the remote actually moved on (own-save echo is ignored)
- Conflict banner: Reload remote | Copy mine, then reload | Overwrite
- i18n (en/it/fr) + CSS; docs/projects.md updated
Show file gains image and video display for capable agents. Docs add
projects.md and update index. Wire ws file-watch in project-board.
Minor fs tool and CLAUDE.md updates.
Add project-files component with tree navigation. Extend UserFs with
shared-folder resolution. Wire API routes for file browsing. Improve
WS session lifecycle and project-board layout. Add i18n keys for
projects and inbox across all locales.
install.sh / install-nightly.sh:
- Write .release-channel file ('release' or 'nightly') for future updates
- install.sh also writes .release-version (for update --check)
update.sh (new):
- Reads .release-channel to determine which channel to pull from
- Release: checks releases/LATEST vs .release-version, skips if current
- Nightly: always downloads latest
- Stops service before extraction, restarts after
- Rebuilds Python venv on update
ci/package.sh:
- Include update.sh in distribution tarball
Web / mobile:
- Add settings-page component for mobile
- Wire settings page into mobile-app navigation
- Chat page: load current user (/api/auth/me) for sender identity
- Full mobile.css redesign
- i18n: add mobile settings strings (en/fr/it)
Rework projects from single-user leftovers into shareable endeavours.
- DB: move `projects` from the owner bucket to the registry (system.db,
not encrypted); add `owner_user_id` + `slug` (drop free `path`); new
`project_members(project_id, user_id, can_write)` mirroring
`shared_folder_members`. Drop `project_tickets` entirely. Only user↔agent
conversations stay encrypted (per-user DB) — each member keeps a private
project chat. Registry home dissolves the cross-DB-FK problem.
- Filesystem/container: on disk `{WD}/projects/{owner_userid}/{slug}`,
agent/container path `projects/{owner_username}/{slug}`. Two-segment routing
in UserFs (ProjectMount + host_base_and_tail arm) and a second loop in
build_user_fs; read-only members get a :ro mount. Reuse the shared-folder
remount machinery (refresh_user_shared_folders -> refresh_user_mounts).
- Remove the ticket system: ProjectTicketManager, UserContext.tickets, its
wiring, and the project_tickets references in scheduled_jobs/cron.
- API: repoint handlers to the registry pool + membership scoping. Sharing is
self-service — owner or any write-member may add/remove members and set
read/write; only the owner deletes; the owner cannot be removed. New
POST/DELETE /api/projects/{id}/members[/{user_id}]. Seed `@fs_any allow
projects/*`; build_runtime_run_context sets working_directory to the agent
path and drops the host-path allow_fs_writes.
- Frontend: create form without the free path field, owner/read-write badges;
the detail page becomes header + description + sharing panel + Open chat + a
file-explorer placeholder (the future primary surface). i18n en/it/fr.
Turn the flat sidebar into a data-driven nav: each entry declares a
group (workspace/extensions/config/dev) and a numeric priority, and each
section renders by sorting on that key. Split axis is function, not
permission — adminOnly/debugOnly gate individual entries and an empty
section is hidden, so Configuration vanishes for non-admins without a
section-level role check.
- Shared folders moves into 'Your space' (still admin-gated per-entry).
- Plugin pages merge into the workspace group on the same priority line
(>=100 by default); rebase the two in-repo plugins accordingly.
- Configuration and Development are collapsible, closed by default,
state persisted in localStorage. Simple mode unchanged.
- Rename nav.catalog to 'Connectors Catalog' to disambiguate from the
Plugin Catalog; add nav.section.* header keys (en/it/fr).
- Replace dead .sidebar-section-toggle CSS with real section classes.
- Add MoonshotProvider and MoonshotCodeProvider (OpenAI-compatible)
- Extract `fetch_openai_models()` and `build_openai_llm()` shared functions,
deduplicating model-listing and client construction across OpenAI,
OpenRouter, DeepSeek, LM Studio, and Z.AI providers
- Add `lists_models` field to `ProviderUiMeta` — drives frontend model
picker dynamically instead of hardcoded `type_id` list
- Port DeepSeek, LM Studio, OpenRouter model listing to `fetch_openai_models`
- Set `lists_models: true` on Z.AI provider (missed in prior pass)
feat(mcp): named key placeholders {SECRET:name}/{ENV:name} in connector URLs
- `apply_key_placeholder` now accepts an `env` map alongside `api_key`
- Named tokens resolve from the connector's described `env[]` fields,
with `{SECRET:name}` falling back to `api_key` for backward compat
- Replace `substitute_secret_tokens` with `substitute_named_tokens`
- Unresolved tokens are left in place (visible misconfig) rather than
silently producing a wrong URL
fix(ui): connector detail hides generic API key when schema has secret
fix(ui): model picker uses `lists_models` from provider types endpoint
Fills a gap the blueprint names: the admin had to hand-author every
`mcp_catalog` entry. A remote feed of vetted connectors now proposes them
and the admin installs — the feed is *consultative*, so §14's risk axis is
untouched and the trust anchor stays on the box.
Marketplace client (`src/frontend/api/marketplace.rs`):
- Fetches the feed server-side (it sends no CORS headers) and caches it;
icons are proxied for the same reason.
- Verifies every declared SHA-256 before writing, fail-closed and
all-or-nothing. Feed-supplied paths are refused if they escape
`./scripts/<id>/`. Importing an `mcp_local` entry still demands the
admin-only `mcp.register_local_script`.
- Translates the feed's vocabulary into Skald's: `user`→`per_user`,
`mcp_local`→`local_script`. Scope is read, never inferred from transport
(a remote connector can be per-user — that is what `mcp.register_remote`
is for), and an unreadable `type` fails closed to the answer needing more
authority. The feed's `llm_short_description` maps to `description`, the
column `render_mcp_list` puts in front of the LLM for `activate_tools()`.
- Feed URL is config (`marketplace.url`), not a constant: an on-premise
product must not hard-require reaching one vendor's host.
Two silent failures found while wiring it:
- `transport_of` maps anything unknown to Stdio, so the feed's
`streamable-http` would have tried to spawn a command. Normalised on import.
- Some servers want their key as a query param, not a bearer header, and say
so with a `{key}` placeholder. Substituted at connect time in
`global_row_spec`/`user_row_spec` — never at rest, so the key stays in its
own column and the stored URL stays a template.
Pages, split by the question each answers:
- Connectors — what runs (`UserMcpView` = global ∪ per-user) and what I can
add. Same page for everyone; the admin just has more verbs. One Available
list with the verb per row: `per_user`→Activate, `global`→Enable globally.
Enabling a global is the admin's counterpart to activating a per-user one,
so the catalog picker dropdown is gone — the entry comes from the row.
- Connector Catalog (admin) — what this box offers. One `Add connector`
with two sources: marketplace first (vetted, hashed), manual second
(unvetted by nature) — the order mirrors the trust model.
- Marketplace (admin) — reached from the catalog, not the sidebar: it is a
destination of an action, not a place.
`available()` no longer returns `McpGlobalServerRow`: that row carries
`api_key` and this view now reaches every logged-in user. A slim `GlobalView`
crosses instead, and an admin sees every global (with `can_use` marking their
own) so one enabled for someone else stays manageable.
Also fixes `connectors-page` having no CSS rule at all — every sibling page
has one, so it never got `flex: 1` and left an empty column beside it.
Re-architects MCP from one owner table + agent-written registration into an
admin-curated catalog with two runtimes unioned per session, surfaced in the UI
as "Connectors" (mcp/schema stays neutral, §0.1).
Two runtimes behind one seam (§7):
- Global runtime: shared, stateless connectors (web-search, Tavily…) on the
HOST, connected at boot from mcp_global_servers, access-filtered per user via
mcp_global_access.
- Per-user runtime: a user's activated connectors run INSIDE their container,
started at first login from mcp_user_servers and living until restart (§9);
docker exec -i children die via kill_on_drop when the UserContext drops.
- McpProvider trait (mcp/provider.rs): the session round-loop never learns which
runtime owns a server. McpManager implements it directly (inert ownerless
bundle); UserMcpView implements global ∪ user with an accessible_global
snapshot. Both share McpManager::connect_all; McpServerSpec +
global_row_spec/user_row_spec turn a DB row into a connectable spec.
- mcp-client: McpServerConfig.launch_in runs a stdio command inside a container
via docker exec -i (set at runtime, never parsed from config).
Authorization is a capability on the role, not `if role==admin` (§0.1/§14):
role_capabilities table + db/role_capabilities.rs — register_remote and
register_local_from_catalog are self-service (seeded on every new role), while
register_local_script and manage_catalog are admin-only. admin holds every
capability by construction. This removes the agent-facing register_mcp/delete_mcp
tools and the mcp kinds of list_items/toggle_item, closing the §14 RCE vector.
Schema:
- Registry: mcp_catalog (vetted templates — schema only, no live creds),
mcp_global_servers + mcp_global_access, role_capabilities.
- Owner: mcp_user_servers (per-user activations; api_key encrypted at rest,
catalog_name a bare TEXT snapshot, never an owner→registry FK).
- Drops the old owner table mcp_servers.
API + UI: src/frontend/api/mcp.rs (admin catalog/global/access + user
available/activate/activated, all capability-gated via require_cap);
web/components/connectors.js (<connectors-page>) renders the user view always
and the admin view for role_id === 'admin'.
Deferred: interactive per-user auth (OAuth callback / QR / SSH elicitation, §15)
— only none/api_key wired; no boot seed of catalog presets; per-(user, session)
MCP grant model still open.
- New skald-setup crate: interactive first-run wizard that creates the
admin user, prompts for encryption choice and password
- Auth system: session-based login/logout with cookie, guard middleware
- Roles API: CRUD for data-driven roles, seeded on first boot
- Users management API: create, list, edit, delete users
- Setup state API: check if first admin has been created
- Frontend: login-page, setup-page, users-page, roles-page, profile-page
components with corresponding CSS
- Topbar: avatar dropdown with profile link and logout
- Sidebar: nav entries for Users and Roles (admin only)
- Page shell CSS: layout support for the new pages
- build.sh: builds both skald and skald-setup binaries
- run.sh: runs skald-setup before the server loop
- CLAUDE.md: updated workspace layout and build/run docs