027d815b66b42ca0c9c22461cc85143d09821efc
65
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5c2bec043e |
docs: reading a dev-doc is not conditional on the size of the change
Nightly Build / build (push) Successful in 9s
The routing table said "before you touch one of these areas, open its file", and the closing line explained why a pointer is not a summary. Neither survives contact with a change that looks trivial: the diagnosis feels complete after a grep, the edit is one line, and the file never gets opened. That is how the narrow-page bug in the previous commit was nearly shipped as a one-line addition to the very enumeration that was the defect. State the missing half. "The fix is obvious" is what triggers the rule, not what excuses you from it, because a dev-doc is not a description of the code — it is the rules and traps the code cannot state about itself, and grepping the source finds what the code does, never what you must not do to it. Add the two consequences that make it cheap to comply: the same-change update rule means the file has to be opened regardless, so opening it first is free and is the only moment it can still change what gets built; and the file is read whole, since the paragraph that saves you is not the one matching the grep. Give the write-side standing rule its read half explicitly, where it was only ever phrased as an obligation to type into the file. |
||
|
|
9c9ad5dd44 |
docs: name the sibling repositories in CLAUDE.md
Nightly Build / build (push) Successful in 9s
The marketplace, the iOS client and the Android client are checked out beside this repo and are invisible from inside it, so a change here could break one of them with nothing in context to say so. CLAUDE.md now lists all three by relative path, states what each is, and — the part that matters — names the coupling: plugin-mobile-connector for the two clients, the manifest format for the marketplace. The marketplace row repeats the existing rule rather than softening it: the authoring spec is CONNECTOR_MANIFEST_GUIDE.md in that repo, edited there and never restated here. The mcp-connectors dev-doc now uses the same relative path instead of an absolute one under a home directory. |
||
|
|
902f47ecd8 |
docs: split CLAUDE.md into an always-loaded core plus dev-docs/
Nightly Build / build (push) Successful in 10s
CLAUDE.md had grown to 152 KB (~21k words, ~40k tokens) and is loaded into
every coding-agent session. The cost is not the cache read, it is attention:
the rules that are genuinely invariant were drowning in the mechanics of
subsystems that most tasks never touch.
The split criterion is blast radius, not importance. A rule a change anywhere
could violate stays in CLAUDE.md — the commit rule, the production/schema
constraint, domain neutrality, the event-bus rule, the crate boundaries, and
the module map. The mechanism of one subsystem moves to dev-docs/, opened on
entry to that subsystem via a routing table at the top of CLAUDE.md.
Nothing was rewritten: every section was moved verbatim by line range and
verified line-by-line against the original. The only edits are cross-reference
repairs ("see the DB section" -> a link), the promotion of headings in the
extracted files, and a condensed "Current state" whose full text now lives in
dev-docs/users-auth-and-boot.md.
CLAUDE.md: 152 KB -> 31 KB. Twelve subsystem files plus an index under
dev-docs/, which now carries the same standing rule as docs/ and CHANGELOG.md:
a change to a subsystem updates its dev-doc in the same change.
No CHANGELOG entry: this is documentation for coding agents with no observable
effect on the application.
|
||
|
|
52a63286ce |
docs: the connector manifest guide belongs to the marketplace repo
Nightly Build / build (push) Successful in 9s
The file existed here and at the root of ~/projects/marketplace, byte for byte identical — two copies of one contract, which is a drift waiting to happen. The one an author reads is the one sitting next to the connectors, so this repo keeps a pointer instead of a copy: CLAUDE.md now names the marketplace checkout and says that CONNECTOR_MANIFEST_GUIDE.md there is the file to consult, and to edit, if the specification changes. |
||
|
|
488c702517 |
feat(files): a Files section over the caller's whole space
Nightly Build / build (push) Successful in 5m36s
Until now file browsing existed only inside a project, and the two memory
stores were reachable only by the agent's tools. `#files` is the general
surface: home, both memory stores, the shared folders and projects the
caller belongs to, plus the read-only skills and docs trees.
The root is virtual, and that is the design. Anchoring at `~` is wrong:
the explorer reads host-side, while `shared/`, `projects/`, `skills/` and
`docs/` are bind mounts inside the container — a page rooted at the home
would show less than the user has with no way to reach the rest, and on
native Linux would show Docker's empty mountpoint stubs, a door that
appears to work and leads nowhere. So level 0 is a synthetic list from the
new `GET /api/files/roots`, serialized from the caller's `UserFs` plus the
two virtual memory roots. It sends `kind`, never a label: labels are copy
and get translated.
`GET /api/files/dir` now answers `{ path, can_write, entries }`, and a
memory path is classified before `resolve_view_path` (which refuses one)
and listed from `memory_docs`: one level derived from the flat key space
by the pure `memory_docs::immediate_children`, over a single query whose
unslashed prefix also spots an exact note as "not a directory". Memory is
read-only from the page — every writer routes through `resolve_view_path`,
and `shared-memory/*` is `@fs_write require` for the agent, so a button
that walks past that rule is a decision of its own.
The explorer moves out of projects into `shared/file-explorer.js`, taking
`root` + `rootLabel` and reading `can_write` from the listing rather than
from its host: writability changes per branch and comes from the same
`UserFs::can_write_to` the server rejects writes with, so the buttons
offered and the writes accepted cannot disagree. Deep-linking needed it
steerable without a two-way binding, hence `rel` in and
`explorer-navigate` out — the event fires only for a click, never for a
`rel` the host set, so echoing it back is a no-op.
The URL carries the agent path of the open folder in one parameter, the
same vocabulary the assistant uses, so a link is shareable and pasteable
into a conversation; which root it belongs to is derived, not stored.
docs/: a new files.md, plus two pages this made false — shared-folders.md
claimed in three places that a shared folder has no explorer, and
memory.md never said a user can now read their own notes.
|
||
|
|
0042f3dbcb |
fix(image-generate): save generated images into the caller's workspace
Nightly Build / build (push) Successful in 5m42s
image_generate wrote the file into the server's own data/images/ and handed
that host path to the model. It is a path in nobody's vocabulary: not the
caller's home, not their container. Telegram's send_attachment therefore
resolved it under the user's home and answered "file not found", and
read_file, execute_cmd and the viewer could not reach it either. The web URL
was the only surface that worked, which is why the failure only ever showed
on Telegram -- and why the model there, having no working way to hand the
file over, started inventing send_photo and send_media.
Placement moves to the tool, the one place holding a ToolContext:
- The manager returns bytes (generate_bytes) and no longer knows where an
image goes. It has no UserFs and no session, so it never could have.
- run_with saves through uploads::save_to_home into uploads/{session}/. The
returned path is agent vocabulary, so every consumer resolves it, and that
is the one directory the media inliner is authorized to read from -- a
vision model can be shown the image it just made. execute_async, the
context-free path, now fails loudly rather than writing somewhere nobody
can read; same shape as execute_cmd.
- The extension is sniffed rather than assumed png: it is what decides
whether Telegram sends the picture inline or as an anonymous document, and
providers return jpeg and webp too. The file is named after the prompt, so
it reads as something in the explorer and in Telegram.
The result still carries a url, since the chat renders Markdown images and
 beats naming a file the user then has to open. It points at
/api/file?path=..., which resolves through the caller's own UserFs. The old
/api/images/{id} route is removed: it had no writer left once placement
moved, and it addressed one instance-wide directory behind require_auth
alone, with no notion of who owned the image -- the same shape as the /data
static mount removed before it. That leaves data_root unused, so the manager
no longer knows about the server's filesystem at all.
Docs: the Telegram page explains send_attachment as the channel's equivalent
of show_file_to_user; the ComfyUI page says where a generated image lands and
which of the two handles to use where.
Also introduces CHANGELOG.md and the standing rule for it in CLAUDE.md.
|
||
|
|
e7c802f0d7 |
feat(event-triage): per-user check interval, overriding the instance one
Nightly Build / build (push) Successful in 5m4s
Event triage is the one system agent whose right cadence depends on who it
runs for: it fires on inbound events, so someone on a dozen mailing lists
has something waiting on nearly every tick while a quiet account has
something waiting almost never. A single instance-wide interval serves one
of them badly, and the observed failure is the first: the agent starts on
practically every pass.
An admin can now set a per-person interval on that user's page (Users ->
the person -> Event triage). Empty means "follow the instance setting",
which stays the state nobody has a row for.
- New registry table `system_agent_user_settings(agent_id, user_id,
interval_secs)`. A row is an override and its absence is inheritance --
no sentinel value, no row seeded at user creation, clearing the field
deletes the row. Registry rather than the user's own file because the
writer is the admin and a member's database is unreadable unless they
happen to be logged in; a setting that could only be changed during its
subject's session would not be a setting. Keyed by agent_id though only
one agent uses it, so a future agent's schedule is not a schema change.
- `SystemAgent` gains `interval_secs_for(user_id)`, which `is_due` now
measures against, and `shortest_interval_secs()`. Both default to the
existing `interval_secs`, so every other agent implements nothing. The
second is the non-obvious half: `base_tick` sleeps for the shortest
interval any enabled agent asks for, so without it an override below the
instance value would be rounded up to it -- an override that works when
it lengthens and silently does nothing when it shortens.
- `GET/PUT /api/users/{id}/event-triage`, admin-gated, minutes on the
wire, null to clear. Nothing rides the bus: the scheduler re-reads the
interval every tick and due-ness is counted from the user's own last
attempt, so a change lands on the next wake-up with no push.
Both helpers fail open onto the instance value -- an unreadable registry
must not turn into an agent that stops running for someone.
Docs: docs/system-agents.md gains the per-person section and no longer
reads as if the interval were one number for everybody.
|
||
|
|
2dad4824c9 |
fix(mcp): rebuild the prompt prefix when the connector set changes
The `## MCP servers` table lives inside the frozen system prefix, which PrefixCache holds for twenty idle minutes. Refreshing a user's global-access snapshot fixed what `mcp.tools()` offers but left the table describing the world before the change, so an admin could enable a connector, ask for it in an open conversation, and be told in good faith that it does not exist — with the tools sitting right there. Same gap on a reinstall, whose new llm_short_description reached the runtime and not the prompt. Both refreshes now call `invalidate_prefixes()` on the live contexts they were already iterating, the seam the skill tools use. Order matters and runs against the intuition: `render_mcp_list` renders the runtime's in-RAM state, not the DB, so the invalidation goes last — after the snapshot refresh and after the servers restart. Rebuild earlier and the prefix is repopulated from the very descriptions being replaced, with nothing left to invalidate it a second time. In the reinstall that means waiting out a dependency install; those users were already reading a stale table, and an early rebuild would only freeze the stale one in place. Also warn when a feed's connector.json and index disagree on the integer version. The manifest silently wins, so if the index is the lower of the two the strict `feed > installed` comparison is false forever: the connector never offers an Update and nothing anywhere says why. |
||
|
|
59549d2b3b |
fix(mcp): install and expose a global connector's deps where they are needed
Nightly Build / build (push) Successful in 8m7s
Two halves of the same failure, found debugging a marketplace connector that logged "connected — 6 tool(s)" while every call died on a missing module. The verify ran as a bare `sh -c` and inherited nothing, so a python connector was rejected by its own verify for a dependency installed one directory away — `global_enable` installs before it verifies, so the deps were provably there at the moment the check denied them, and the row ended up disabled. Only connectors that bother to declare a verify could hit it. `verify_env` now builds the verify's environment in one place and derives PYTHONPATH from the workdir, which is already the connector dir in both targets; `or_insert`, so a value the form declares still wins. The global branch of the reinstall refresh restarted the server without ever installing its deps: `ensure_installed_host` was reachable from `global_enable` alone, so a marketplace Update that adds a requirements.txt landed the file and brought the connector back exactly as broken. It now runs once per connector folder before the restart loop, best-effort. The per-user branch had always reinstalled, which is why nothing with scope=user ever showed the bug. Known gap, deliberate: POST /api/mcp/test shares run_verify but not the install, so testing a python connector never enabled on the box still fails on missing deps. Making a "try it" button write to disk for minutes is the worse trade. |
||
|
|
5980bdb5b9 |
feat(users): unlock and start unencrypted users at boot
Nightly Build / build (push) Successful in 8m10s
A login is what makes an *encrypted* database readable; for an
unencrypted one it gated nothing but the runtime — the file has no key
and is already readable by this process. The cost was user-visible and
read as a bug: after every restart the Telegram bot answered "your
account is locked, log in via the web app", cron fired nothing and no
background agent ran, until a human opened the SPA.
`Skald::new` now calls `UserManager::unlock_all_unencrypted`, which
registers the pools exactly as a login would and refuses an encrypted or
inactive user. Unlocking alone only makes the data readable, so
`wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for
each — cron, the notify queue, the hub and the per-user MCP runtime all
hang off it. That build is a background supervisor task rather than part
of `new()`: it starts every member's MCP servers inside their container,
and the HTTP listener must not wait behind it. The same two steps run
per user off the lifecycle bus (`UserCreated`,
`UserActiveChanged{active:true}`, after the container `ensure`), so a
member created at runtime does not wait for the next restart.
Two boundaries stay where they were. Authentication is untouched:
`SessionStore` sits above `UserManager`, so no HTTP request
authenticates as anyone because of this. And the auto-unlock is
deliberately not on a lazy path such as `Skald::user_context` —
`revoke_user_runtime` locks a pool synchronously and expects nothing to
re-open it, so the writers of that map stay boot, login and the bus.
`open_db` and the two unencrypted openers now share `register_unlocked`
and `open_unencrypted_file`; `open_unencrypted` (the supervision path)
still does not register its pool.
|
||
|
|
5765941758 |
feat(prompt): tell the agent what its sandbox can run
Nightly Build / build (push) Successful in 8m6s
The agent had no way to know its container ships ffmpeg, ripgrep or tesseract, so it either declined work it could do or spent a round finding out. This adds a command list to the system prompt as a **discovery hint** — explicitly not an inventory. Every decision follows from it being a hint: - The allowlist (~35 entries, `container/commands.rs`) is the curation; a full PATH dump is 800 entries of coreutils noise. The probe exists so the list cannot *lie*, not so it can discover: `command -v` at login means we never announce something a container recreate threw away. - The rendered prose says the list is partial and names `command -v`, so a tool outside the allowlist costs one check rather than a wrong conclusion. An empty probe renders as an explicit "could not be read", never as silence under a heading promising a list. - Order is the allowlist's own, grouped by kind of work — the grouping is the curation, and the reader is a model, not a grep. - Staleness is cheap both ways, so there is no invalidation machinery: a login-time snapshot on `UserContext`, non-fatal, refreshed at next login. The gate is the tool, not the sentinel. Every AGENT.md carries `common/sandbox.md` — the four system agents included — and the section is emitted iff the turn's model is shown `execute_cmd`, derived from `allow_tools` plus the security group's visibility filter for a root turn and from `child_defs` for a sub-agent: always the same definitions the model will see. `has_execute_cmd` therefore joins the PrefixCache key, since the group is switchable mid-conversation and that switch already rewrites the tool payload in the same provider cache. The fragment holds only the heading and one stable sentence; every conditional claim lives in the renderer, because prose promising `sudo apt-get install` is not the renderer's to retract when the tool is absent. `execute_cmd`'s own description loses `(python + node available)`: its job is steering away from the shell, and a capability advertisement diluted it. |
||
|
|
c27da4e6ab |
feat(skills): rebuild the skill system for the multi-user model
Nightly Build / build (push) Successful in 8m6s
Per blueprint/skill-project.md: the old single-namespace, hand-maintained
index is gone, replaced by a read-only, two-scope tree whose index is a
runtime function of its content.
- skills/ index generated at runtime (crates/skald-core/src/skills/:
inventory, install, validate, watch), injected through the new
<!-- SKILLS_LIST --> placeholder in AGENT.md (agents/common/skills.md);
meta.json inject_skills flag removed. 11 chat/task agents carry the
include, the 4 system agents do not.
- Two trees, both read-only in both directions: skills/shared/{id} (the
group's) and skills/{username}/{id} (one member's own, on the stable
userid). The root is closed too: UserFs::SkillMounts + RouteError (alias
probe, plain-denied paths, no home fallback) and a per-user
.skills-root/{userid} container mount with the two scope mounts nested
inside, plus the fifth self-heal axis (skills_mounted).
- Agent verbs: skill_register/skill_delete (Config group, global scope
behind the new skill.manage capability), fetch_repo for public repos,
list_items(type="skills"); reads are plain read_file on the printed
path. Seeded @fs_read skills/* allow.
- Freshness: a digest-gated watcher on the two trees emits
SystemEvent::SkillsChanged, whose subscriber rebuilds the frozen prompt
prefix via Skald::invalidate_prompt_prefix; in-process writers invalidate
directly.
- The build ships no skills: the three bundled skills (ics2json,
mcp-builder, skill-creator) and skills/index.md are removed, skills/ is
instance data (gitignored, not packaged, no longer pruned by update.sh).
- Docs: skills.md, agents.md, shared-folders.md added; docs/index.md and
agents/README.md updated.
|
||
|
|
de21d9a64b |
fix: give the notification home a place to live in the owner's database
/sethome answered "no such table: config" from every surface. ChatHub is
owner-bound, so its pool is a {userid}.db, and `config` is a registry
table that only exists in system.db — the write had no table to land in.
The visible half was the lesser one. The notification consumer resolves
the home source before it delivers anything, and on an error it dropped
the batch: every `notify` from a background agent and every cron-job
completion has been discarded, silently, for as long as the hub has been
per-user. That error path now degrades to the default home instead — a
batch that got that far is data nobody can recreate, and the destination
is the one thing there with a sane fallback.
Where the setting belongs was never in doubt: one member choosing
Telegram must not move anybody else's notifications, so it is owner
state and it goes in their own file. The new owner table is `user_config`
and it deliberately does not reuse the registry name. The two hold
different namespaces — instance settings the admin owns versus one
person's own preferences — and a table called `config` in both files
would have turned this exact mistake into a silent read of the other
scope, which is strictly worse than the loud failure that revealed it.
Additive, so no migration: open_user_pool re-applies the owner schema on
every unlock, and the table appears at each user's next login.
|
||
|
|
78cdcf4cc7 |
feat: let one source carry several chats, and open them with a +
Nightly Build / build (push) Successful in 7m49s
A source had exactly one live conversation, so the copilot could only ever replace a chat, never add one: the trash button reset the source and the old conversation was left orphaned. Working on two things at once meant losing one. The tab bar now holds two kinds of tab. A primary tab is a source — it shows whatever `web` or `project-7` currently points at, which is where background delivery lands (notify, a finished async task, an inbound Telegram message) and what a reset moves to a fresh row. A secondary tab, opened with `+`, is one specific conversation: its source points elsewhere, so it is unreachable by source name and is addressed by id throughout — REST, WebSocket, event filtering. `POST /api/sessions/new` creates one without touching `sources`, which is the whole difference from a reset; its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context. Project "Open chat" is untouched and still resumes the project's own. The load-bearing half is in ChatHub: the input queue and the model pin are now keyed by session, not by source. Two tabs on one source would otherwise serialize into a single queue and a single turn, and share a `/model` pin — the odd one out, since the security group was already per-session and persisted. The source-taking methods survive as one-line resolvers, so Telegram, mobile and cron are untouched. Because queues now grow with conversations rather than with the handful of sources, a reset retires the queue it replaces instead of leaving a consumer task parked forever. Events are filtered per conversation, so anything a chat must see has to carry a session id: `show_file_to_user`'s OpenFile and the security-group revalidation were emitting untagged and would have reached nobody. A primary connection additionally follows NewSession for its source, so a second window does not keep talking to a conversation another window just reset. Tabs can be renamed by double-click — `chat_sessions.title` existed and was dead until now. An empty name stores NULL, so the box is also the undo. |
||
|
|
8f5c5382c8 |
feat: keep the chat tabs you left open, and keep them with you
Nightly Build / build (push) Successful in 7m39s
Reopening the app closed every project tab: the copilot's tab bar lived in
RAM, so a reload dropped it and each conversation had to be found again from
its project board.
The set of open tabs is now a column on the session row, `chat_sessions.is_open`
(additive, `ensure_column`), restored by `GET /api/sessions/open` and written by
`PUT /api/sessions/{id}/open`. Not localStorage: that store is per-origin, so on
a shared laptop one member's tabs would greet the next, whereas the owner table
sits in their own encrypted file and follows them to another device. Which tab
is *selected* stays in sessionStorage — that one is genuinely per window, and a
shared value would have two windows fighting over it.
`is_open` defaults to 0 and `chat_sessions::create` never sets it: every `/new`
leaves its predecessor behind and every system-agent pass mints a row, so the
opposite default would restore a bar full of conversations nobody opened. Only
the copilot writes the column, at the moment it opens the tab. A reset moves the
flag rather than copying it — `POST /api/sessions` now returns the new id and
`new_session` carries it, and the old row is closed as the new one opens, or the
source would restore twice and a later close would clear the stale row.
Closing a tab clears the flag and nothing else: the conversation is kept and
comes back with its history when the project is reopened.
|
||
|
|
daaceff6ba |
feat: show a conversation its own background tasks, and give it back every outcome
Nightly Build / build (push) Successful in 7m34s
An `execute_task mode="async"` was invisible from the chat that started it.
The only trace was the receipt in the transcript and a row on the Tasks page
— which does not say *which* of those rows the assistant just spawned — so
"is it still going?" had no answer where the question is asked.
Worse, a task that did not simply succeed never came back at all. `run_job`
branched on `Ok`/`Err` first and routed by `job.kind` only inside the `Ok`
arm, so a failure or a kill left through the `Err` arm's unconditional
`hub.notify` — the home source (`/sethome`), worded "Cron job … failed" —
while the parent conversation sat waiting for a `task_completed` that would
never arrive. The wrong chat, and a wedged one.
The fix is a shape, not a branch: one `JobOutcome` classification, then one
`match job.kind` delivery site for every ending. An async task now ends in
its parent conversation whatever happened to it. The sink has a single
channel deliberately — to the model reading it, "it broke" is a result like
any other and must not be overlookable — so a failure is delivered as prose,
carrying whatever partial output the run produced, which is usually the only
clue about why. A cron job keeps the home notification: it belongs to nobody's
conversation. Cancellation becomes a third outcome rather than a flavour of
failure (`job_runs.status` has always had `'cancelled'` in its CHECK and
nothing ever wrote it), classified off the new typed `TurnCancelled` error so
nothing keys on a message string.
The strip above the composer is the visible half. `ServerEvent::TaskUpdate`
announces state to the source of the parent conversation only; the list is
`renderTaskStrip` (shared by the desktop copilot and the mobile chat), fed by
state on `ChatSession`. Each row links to `#session/{id}` — the page that
already shows, live, what a background agent is doing, and without which
"a task is running" is a fact you can do nothing with. Stopping is the
existing kill endpoint. A finished row clears itself after 20 s (its result
is in the conversation by then); a failed one stays until dismissed, and the
dismissal is remembered across reloads.
`GET /api/{source}/tasks` is what makes the strip survive a browser refresh:
the event is a broadcast with no replay, so without a load-time read a reload
would empty a chat that still has work running under it. It answers with the
running tasks plus failures from the last 30 minutes — the two states a person
can still act on. Successes are absent on purpose. Its window compares through
`datetime()` on both sides: `completed_at` is RFC 3339 and the cutoff is
SQLite-shaped, and `'T' > ' '` would let every same-day row through a window
meant to exclude it.
Not addressed, and worth doing next: a cron job's result should go where its
creator says, not always to the home chat.
|
||
|
|
6cb4ea0ce8 |
feat: let the fs-tools reach the whole container, and stop rebuilding the system prefix every round
Nightly Build / build (push) Successful in 7m33s
Two changes to what a turn costs and what it can see.
## The system prefix is frozen per conversation
`AgentSystemContext::system_context` is called once per round and reassembled
`base` from disk and SQLite each time, so an agent writing `user-memory/index.md`
in round 3 turned round 4 — seconds later, with the provider cache certainly
warm — into a full miss. `base` is the head of every provider's cache key, so it
is the most expensive string in the request to touch.
`PrefixCache` builds it once per (conversation, agent) and holds it on
`UserLoopRuntime`. The refresh rule is the only free one: rebuild once the
conversation has been idle longer than a provider's cache could survive
(20 min). The clock is idle time of the conversation, not time since a file
changed, and reading restarts it — every get is a request about to go out.
Writes are deliberately not reacted to. The agent's own edits are already in the
context, two messages downstream. A write from elsewhere is invisible until the
TTL: that is precisely where an immediate rebuild costs the most, and the
cheaper freshness path already exists — a `read_file` result appends, and
appending invalidates nothing. The injection header now says so.
Also removes 4 DB queries and 2 file reads per round.
## The security boundary is the container, not the mounted subtree
`read_file /tmp/cv.txt` answered "path escapes your workspace" and the agent
re-read the file with `cat`. It was right to refuse — /tmp exists only inside
the container — but the refusal protected nothing: `execute_cmd` already runs
there with passwordless sudo. The mount is the fast path, not the perimeter.
`resolve_target` now routes an absolute path through `container_to_agent`
first. Landing on a mount takes the host path, which also fixes a real bug:
`/root/x` IS `~/x`, yet every tool rejected it, because `PathBuf::join` with an
absolute tail discards the base and the result then failed the prefix check
(`/root/shared/{X}/…` too). Landing nowhere means container-only, served by the
new `container::exec_fs` over `docker exec`, with paths passed positionally so
a path containing `$(…)` stays data. Membership still holds: `/root/shared/{X}`
for a non-member fails exactly as `shared/{X}` does.
Single-file tools get this without a second implementation: `fs::Shuttle` pulls
the file out, runs the unchanged tool on the copy, and pushes it back if the
bytes changed. `list_files` lists in place, `read_file` reads container paths as
text (a shuttled copy cannot back a MediaRef), and `grep_files` refuses them
with a pointer to `rg` rather than approximating its own semantics. The viewer
follows the same routing, so the user can open what the agent read.
Host containment is untouched and still guards every mounted path — it is the
defence against a symlink planted in the container pointing at the host's /etc,
and the container branch never touches the host filesystem at all.
Verified end-to-end against a live skald-runtime:v3 container: write/read/edit
on /tmp round-trip, /etc/os-release reads, binary and shell-metacharacter paths
survive, and /root/notes.md lands in the host home.
|
||
|
|
080ea736e4 |
feat: signpost the virtual memory roots inside the container, instead of leaving them absent
Nightly Build / build (push) Successful in 7m38s
`user-memory/` and `shared-memory/` live in SQLite, so nothing of them existed on disk — and that nothing was worse than it looks. `cat user-memory/x.md` returned a bare ENOENT, which a model reads as "the note is missing" rather than "wrong door"; and `mkdir -p user-memory && echo … > user-memory/x.md` *succeeded*, writing a real file into the home that no reader ever visits (every reader goes to `memory_docs`) and that the next `ls` then confirms as if it had worked. Each root now gets a read-only bind mount holding a README that names the tools to use instead. Read-only as a mount rather than as a mode: the container user has passwordless sudo, so a chmod would be a suggestion, while `:ro` holds — remounting needs CAP_SYS_ADMIN. Verified in a scratch container: write, sudo write, sudo chmod, sudo mount -o remount,rw and sudo rm all fail. And a README rather than an empty directory, because "Permission denied" is an error, not an instruction — models answer it by reaching for sudo; the README puts the correction in the same directory the failing command just named. The mounts are deliberately not part of `UserFs`: they back no agent path and the host-side fs-tools must never resolve into them. They reach existing containers as a fourth self-heal axis in `reusable()`, not as an IMAGE_TAG bump — the image is unchanged, and a bump would make every installation rebuild it to fix a mount. The matching half is in `classify_memory`, which now strips the home spellings (`./`, `~/`, `/root/`) before matching the root. Without it `~/user-memory/x.md` missed the match and fell through to the disk router — becoming exactly the invisible physical file the signpost exists to prevent. `agents/common/memory.md` says the rule outright: the stores are reachable only through the file tools and `memory_search`, never through `execute_cmd`. |
||
|
|
da0830aefa |
feat: an hour-precision clock that says so, and a cron tool that names the real timezone
Nightly Build / build (push) Successful in 7m30s
The datetime block claimed second precision it never had. It is built once per request and a turn can run for minutes, so `17:54:31` is a lie by the time the model reads it — and the model, having no way to know, wrote cron expressions from it. Rounding was already there but configurable (`round_minutes`, shipped at 60) and justified by the prompt cache. That justification was false: the block is the LAST system message, after the whole conversation, so the cached prefix is identical from turn to turn whatever the timestamp says. Rounding buys nothing for caching today. So the knob goes and the granularity becomes part of the contract: always truncated to the hour, stated in words, with a pointer to `date` for the cases that need the minute. `DatetimeConfig` keeps only `enabled`. - truncation happens in the DISPLAYED zone, not on the UTC epoch: +05:30 zones would otherwise render 20:30 — an hour off and not on an hour boundary, which reads as precise again. - the weekday is spelled out. "next Tuesday" is a far more common ask than the minute, and weekday-from-date is exactly the arithmetic models get wrong. Also fixes a real bug found on the way: `execute_task` told the model, twice, that cron expressions are evaluated in Europe/London — hardcoded, while TaskManager uses the configured timezone. On a non-UK box every scheduled job was written against the wrong clock. The description now names the zone the scheduler actually uses (`TaskManager::timezone_name`), and the assistant's AGENT.md stops repeating the literal. CLAUDE.md: record that the instance is in production. The greenfield licence has expired — schema changes need a versioning mechanism, and per-user SQLCipher files mean it cannot be a boot-time sweep. |
||
|
|
baf68878e4 |
fix: stop shrinking conversations behind the user's back — both automatic context guards ship off
Nightly Build / build (push) Successful in 7m35s
The shipped default combined a sliding history window with no compaction, which is the worse of the two available trades in both directions it is measured on. `max_history_messages: 30` is a sliding tail window (`projection::window` — `drain(..len - max)`). Past 30 messages it drops from the head on *every* turn, so the prompt prefix changes on every single request and every provider that caches one (Anthropic breakpoints, OpenAI automatic prefix caching) misses every time. It also drops those messages with no summary standing in for them: silent amnesia, not just a cold cache. Compaction rewrites the prefix once per compaction and leaves a summary behind — yet it was the half that was commented out, while the window's own doc-comment already said the two were exclusive. Both are now `Option` and both ship unset, so nothing shrinks a conversation unless a human types `/compact`. Which surfaced the real bug: `/compact` did not work either. The compactor was `Option<Arc<ContextCompactor>>` keyed on the config section existing, so commenting out `compaction:` disabled the manual command too — `force_compact` returned `Ok(false)` and the chat answered "compaction disabled". Manual compaction is a command a user types; it cannot depend on an admin having filled in a token threshold. The compactor is now built unconditionally and `threshold_tokens: Option<u32>` arms only the automatic pass; `try_compact` early-returns without it, `force_compact` deliberately never consults it. The projection accordingly yields to the *automatic* pass rather than to the compactor's existence (`LoopConfig.auto_compaction_enabled`), so a configured message cap is not silently voided by `/compact` merely being available. `CompactionConfig::Default` is hand-written for the same reason `RoleAttrs`'s is: a derived one gives `keep_recent: 0`, which would compact away every recent message on any box omitting the section — now the default. Also fixes two documentation bugs in the same file: `event_triage` was documented nested under `llm:`, where it parses fine and is then silently ignored (it is a top-level field), and `datetime` was documented twice with conflicting examples. A new test asserts the shipped default actually deserializes and that both guards are off — a field the default omits must be genuinely optional, or a brand-new install fails to boot. Automatic compaction returns later, triggered off the resolved model's own context window instead of a hand-tuned token count that cannot know which model is answering. |
||
|
|
d4b34e6130 |
feat: give the sandbox a real shell toolbelt — and make an image bump reach existing users
Nightly Build / build (push) Successful in 7m32s
The per-user container shipped python+node and little else, so an agent asking for `unzip`, `ffprobe` or even `ps` found nothing and had to `sudo apt-get install` mid-task. That fallback works, but it re-runs on **every container recreate**, inside the task, where it costs latency and can fail — while the image is **one, shared by every container**, so preinstalling costs its size once for the whole box. Anything an agent reaches for repeatedly is therefore cheaper baked in. Added on that rule: jq, ripgrep, zip/unzip, xz-utils, sqlite3, wget, openssh-client, procps, less, file, tzdata, dnsutils, iputils-ping, ffmpeg (+ffprobe), imagemagick, poppler-utils and tesseract — with the ita/fra language packs, matching the app's supported UI locales (eng and osd arrive as hard deps). Deliberately left out: build-essential/python3-dev (~270 MB, only for a pip package with no wheel) and pandoc (~216 MB) are big *and* self-recoverable, so they stay on demand. 687 MB -> 1.3 GB, ffmpeg being most of it. The image tag goes v2 -> v3, which alone would have equipped nobody: a container pins the image it was created from, so `ensure()` would have rebuilt v3 and then happily reused every existing v2 container — the new tools would have reached only users created from here on. `reusable()` now compares `.Config.Image` too, turning a tag bump into a recreate, safe for the same reason the `--user`/`--init` self-heal already is: the container holds no durable state, everything lives in the bind mounts. An unreadable inspect answers true, so a docker hiccup never churns a working container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4f10528368 |
feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s
The first AgentScope::PerSubject system agent, and the reason that scope exists. Once a night, for each person with a supervision edge, it reads every message that person and the assistant exchanged since the previous review — across all their conversations — and writes one report for the people who supervise them. Schema (all registry except reports): - supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge, answering both 'whom does a background agent look at' and 'who may read what it produced', with real FKs so deleting a user cascades both ways - system_agent_coverage(agent_id, subject_user_id, covered_through): the per-subject watermark that makes 'everything since last time' a window — neither system_agent_runs (history for humans) nor system_agent_state (advances before the work), and advanced only on a completed pass so a crash re-covers instead of skipping - reports (owner schema, the second two-homes table after memory_docs): instance rows land in system.db, deliberately cleartext to the box owner, who is the intended reader (§2); the subject cannot see them structurally The pass reads the subject's database inside a supervisor's runtime, so the ephemeral session and run row land in the watcher's file; iteration is over subjects, so two parents watching one child get one review; and the subject need not be logged in when their space is unencrypted — via the new UserManager::open_unencrypted, which refuses an encrypted user outright (no key to be had) and never registers the pool as unlocked. The agent declares the new AgentMeta flag allow_tools: false, so its turn gets an empty tool registry — nothing for a prompt injection in the transcript to call — and produces its report as its final assistant message, read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on quiet days). chat_history::conversation_window is the transcript query; its four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a specific way the review would otherwise be wrong, and tool calls are absent by construction. Cadence is Run at (hour) rather than Interval — 4am local by default — with due-ness answered inside has_work against the coverage watermark, so a machine off for three days covers the whole stretch in one pass. Reports announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn gains a per-pass system_substitutions map, which the review uses to hand the model the subject's profile under __SUBJECT_PROFILE__ — the system-context substitutions describe the session owner, the wrong person here. docs/system-agents.md gains the conversation review section; CLAUDE.md documents the scope, the tables and the tool-less design. |
||
|
|
e6818408cb |
feat: grant a new plugin or connector to everyone by default — the admin's job is now removal, not distribution
Nightly Build / build (push) Successful in 7m23s
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.
|
||
|
|
da8a835d70 |
move per-user plugin grants to the user's page
Nightly Build / build (push) Successful in 7m16s
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.
|
||
|
|
8bcf09a67e |
chore: delete scripts/ and cut requirements.txt down to its real consumers
Nightly Build / build (push) Successful in 7m13s
scripts/ held the pre-marketplace MCP servers (gmail, gcal, gmaps, ssh, weather, google_trends, whatsapp, serpapi_flights). Nothing referenced them any more: connectors are admin-curated and installed into connectors/ from the marketplace, and ci/package.sh never shipped scripts/ in the first place — so on every installed box requirements.txt was pulling google-auth, googlemaps, paramiko, trendspyg and friends for files that did not exist there. requirements.txt now states what it is actually for: the two TTS plugins, which spawn a bare `python3` on an embedded server script and so have no dependency reconciler of their own. A connector's deps stay with the connector — `ensure_installed` puts them in .pydeps/node_modules inside the user's container, `ensure_installed_host` beside the files for a global one. The venv itself stays load-bearing for those two plugins and for the host pip that installs a global connector's deps, so the run/install/update scripts keep creating it — but their "Python MCP servers will be unavailable" warning was naming the one thing that no longer depends on it, and now says what really breaks. CONNECTOR_MANIFEST_GUIDE.md moves to the repo root: it was the one thing in scripts/ still referenced (CLAUDE.md), and being under a gitignored directory it had never been committed at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
046f060fcd |
rename the TIC system agent to event triage
Nightly Build / build (push) Successful in 7m16s
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 |
||
|
|
434e27d7c2 |
system agents: generalise the scheduler and add the two memory lints
Nightly Build / build (push) Successful in 7m14s
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.
|
||
|
|
4b1affa600 |
plugins: merge the user Plugins page into per-plugin sidebar pages
Nightly Build / build (push) Successful in 7m1s
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. |
||
|
|
fadb31832f |
users: turn the four modals into a per-user page at #users/{id}
Nightly Build / build (push) Successful in 6m58s
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. |
||
|
|
776748435b |
connectors: quieter chips, 3-line descriptions, single access-grant surface
Nightly Build / build (push) Successful in 7m2s
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.
|
||
|
|
b198ac923b |
connectors: merge the catalog page into a row-list Connectors page
Nightly Build / build (push) Successful in 6m57s
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/*. |
||
|
|
165af19774 |
tic: run per-user under a system-agent scheduler, with a run log
Nightly Build / build (push) Successful in 6m58s
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.
|
||
|
|
305bdbdd2b |
connectors: announce global-server and reinstall refreshes on the bus
Nightly Build / build (push) Successful in 6m56s
Five call-sites reached into the live-runtime refresh helpers from HTTP handlers, the same shape as the container remounts. Only three of them belonged on the bus, and finding out which was the point. global_enable and global_delete now emit McpGlobalServersChanged, and the marketplace reinstall emits ConnectorReinstalled. All three are pure reconciliation: the first only makes a connector appear; the second is already enforced by stop_server, with the snapshot refresh just tidying each user's filter; the third pushes metadata and code into what is already running. The reinstall gains something from being off the response path, since it re-copies files and restarts servers inside every live user's container. global_set_access and user_connectors_set keep calling refresh_global_mcp_access directly. Their writes *replace* a grant set, so anyone dropped from the list is being revoked and that refresh is what enforces it — on a best-effort broadcast a revoked user would keep the connector until their next login. Both carry a DELIBERATELY SYNCHRONOUS comment, since they are otherwise indistinguishable from the announced call-sites and are exactly what a later cleanup would sweep up. No behaviour change for the two synchronous paths; the three announced ones now return without waiting for the refresh. |
||
|
|
0ba140186f |
auth: make deactivation and group revocation actually revoke
Nightly Build / build (push) Successful in 6m58s
Two variants of the same defect: an admin took away access and the running system kept granting it. Deactivating or deleting a user only stopped the *next* login. `login` checks the active flag, but `require_auth` maps token -> id without re-reading the row, so an already-open session kept working over a pool whose key was still in RAM. There was no way to stop one user either: the per-user cron, hub and MCP loops all observed the *instance* shutdown token. They now take a per-user child token stored on UserContext, and Skald::revoke_user_runtime tears a single user down in a load-bearing order — revoke every session, evict and cancel the context, then lock the database, so nothing is left querying a pool we are about to close. Revoking a security group had a durable version of the same problem. The group is validated when selected and then persisted on chat_sessions.run_context, which was replayed verbatim on every later load — so a group removed from a role stayed in force on sessions that already had it, across restarts. get_or_create_handler now runs the stored value through run_context::reconcile_group_for_user, making it advisory: every load re-checks it, whether or not anyone announced the change. The degrade target is the role's default group, never None: a context with no group resolves to the catch-all `default`, whose rules are the fallback tier under every other group, so clearing widens rather than narrows. The reconcile touches only security_group, so a project session's server-built project_root and system_prompt survive a permissions edit, and it leaves the stored group alone when the role cannot be resolved — guessing on a transient error could only widen. role_default_run_context moves into the core seam so the group a session starts on and the group it falls back to cannot drift apart. Both fixes run synchronously in their handlers. Only the container half of deactivation rides the bus, as the new UserActiveChanged event: a lossy 64-slot broadcast whose contract is "settles at the next login" is the wrong transport for taking access away. Tests: revoke_user drops all of one user's sessions and nobody else's, and is a no-op when nothing is live; the reconcile degrades a revoked group to the role default, keeps an allowed one, preserves project fields in both directions, never touches an admin, and stays put when the role is unresolvable. Not exercised at runtime: no Docker/live-server run, so the end-to-end paths (deactivating a logged-in user, editing a role with sessions open) are covered by unit tests only. |
||
|
|
c50a0d84da |
containers: drive user provisioning and remounts from the system bus
Nightly Build / build (push) Successful in 6m51s
The endpoints that changed a user or a membership row also reached into ContainerManager themselves: users_mgmt called ensure()/remove(), and both shared_folders and projects called refresh_user_mounts through a local remount() helper. Every future endpoint that grants membership would have had to remember to do the same. Announce instead. SystemEventBus gains UserCreated / UserDeleted / UserMountsChanged, emitted after the DB write, and one subscriber — wiring::spawn_user_lifecycle — does the Docker work: sequentially (which serialises concurrent operations on the same container), best-effort by contract (the row is already committed, so a hiccup settles at the user's next login or at boot reconciliation), and holding only a Weak<Skald>. It is spawned after construction, like set_skald, because it reacts through Skald's own accessors. Also fixes a real gap the event makes impossible to repeat: the web setup wizard created the first admin without provisioning a container. It runs against a live server, where reconcile_all() has already happened, so that admin had no sandbox until the next restart. It now emits UserCreated like any other creator; the console shell needs no equivalent, since it runs before the server and boot reconciliation covers it. Two behaviour changes: POST /api/users and POST /api/projects no longer wait on Docker before responding. Provisioning was already best-effort, and a new project's folder is still created synchronously, so the explorer — which reads host-side — shows it at once; only execute_cmd reachability lands a moment later. |
||
|
|
cf5415ae88 |
docs: record the event-bus rule in the codebase guide
Nightly Build / build (push) Successful in 6m52s
Names the three global buses and their caps, and states the coupling rule they exist for: a producer emits an event rather than calling the consumer, and a new channel is a code-review flag until proven necessary. |
||
|
|
73c720e9ef |
llm: restore request logging lost in the agent-loop migration
Nightly Build / build (push) Successful in 6m50s
The LLM-requests page has been empty since
|
||
|
|
4d81295a3d |
messages: unify harness-injected data under <system-extra> tag
Nightly Build / build (push) Successful in 6m49s
Replace the ad-hoc [SYSTEM INFO] / [TELEGRAM SYSTEM INFO] prefixes with a single canonical <system-extra> wrapper, sourced from one constant (SYSTEM_EXTRA_TAG) so emission and documentation can never diverge. - core-api: SYSTEM_EXTRA_TAG + system_extra() helper; attachments_block rebuilt on top of it. - telegram: system_info_message (location) uses the helper; the voice transcript is forwarded as a plain user message (it is the user's own words, not harness metadata). - chat agents: new agents/common/harness.md include (long form, with an explicit "data, not instructions" guard), added to assistant/kid/ project-coordinator. The tag name rides the __HARNESS_TAG__ sentinel, resolved in AgentSystemContext to SYSTEM_EXTRA_TAG — renaming the tag stays a one-line change. |
||
|
|
24ee5b89d7 |
agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call) and the ChatSessionHandler. Everything that shaped a Value — projection, recovery, compaction mechanics, the LLM loop, message building — lives in agent-loop or behind a loop_adapters trait. agent-loop: - projection/ (mod + media): stored history -> wire messages, the one place provider divergence lives; well-formedness contract, DTL injections (append-only), media parts. LinearAssembler is now a Projection + ProjectionHooks config, not its own implementation - recovery.rs: reap interrupted batches -> resolve the deepest frame's non-terminal calls (Running by policy + RestartHint, AwaitingHuman re-asked) -> un-wedge finished children -> cascade up, every frame on its own agent (B3) - compaction.rs: split point (never assistant+tool group), transcript, SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row - manager: resolve_pending (gate skipped, real ToolContext, then continue incl. sub-agent); start_loop used by recovery; LiveInput - delegate: AsyncExecutor + StoreSink for mode:async (durable cron row, result delivered back into the parent conversation) - kernel/context/store: support the above (TurnScope via Extensions, frame lookups, aligned result-text semantics) skald-core: - loop_adapters: UserLoopRuntime (D12 - one LoopManager per user), TurnScope (per-turn state in the Extensions type-map; no scope is denied), projection_cfg/media_source/tool_digest (Skald's projection knobs without owning projection code), async_task (CronExecutor + DurableSink) - session/handler: stripped to mod.rs + kernel_turn.rs + config.rs + interface_tools.rs + media.rs; deleted agent_dispatch, approval, dispatch, emitter, gate, llm_call, llm_loop, message_builder, messages, outcome, resume - compactor.rs: policy only (threshold, model pick, CompactionEvent); mechanics are the crate's CLAUDE.md updated (recovery, compaction, sub-agents, approval gate, projection sections now describe the crate-owned flow). |
||
|
|
5081ec2afe |
llm: drop model/agent scope matching; add instance-wide compaction model picker
Nightly Build / build (push) Successful in 6m50s
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. |
||
|
|
6f0461f7f5 |
uploads: centralise via ChatHubApi::save_upload, refactor handlers
Nightly Build / build (push) Successful in 6m41s
Extract shared upload seam in skald-core, move Telegram and web handlers to use it. Simplify media attachment routing. Clean up unused deps and dead code. |
||
|
|
e70c4a90f3 |
file viewer, docs, ws: add image/media preview path, projects doc, ws wiring
Nightly Build / build (push) Successful in 6m44s
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. |
||
|
|
3343260bb0 |
token streaming & reasoning display: live SSE tokens frontend to back
Nightly Build / build (push) Successful in 6m59s
- Add StreamDelta(SseDecoder) framing shared by OpenAI/Anthropic - OpenAiClient: stream=true + reasoning_content deltas, index-based tool_calls accumulation, usage from final chunk - AnthropicClient: message_start/content_block_*/message_delta events, thinking_delta->reasoning, input_json_delta->tool input - TokenDelta ServerEvent variant wired through ChatHub + WS broadcast - Frontend throttled flush (~15 Hz), pending bubble mutate-in-place, reasoning as collapsed-by-default <details> - Drop streaming bubble on error/llm_failed/model_fallback - i18n: chat.reasoning key added to en/fr/it |
||
|
|
9224245f6f |
docs overhaul: agent-facing doc bundle, read-only docs mount in containers
Nightly Build / build (push) Successful in 6m38s
- Strip ~55 stale upstream docs (dev docs never meant for the agents)
- Write new slim index.md as an agent-facing guide to the app's features
- Add new plugin docs: comfyui, elevenlabs, kokoro_tts, orpheus_tts_3b,
remote_connectivity, whisper_local (replacing old names)
- Add docs_host to UserFs: docs/… and ~/docs/… resolve to {WD}/docs,
mounted read-only at /root/docs in every user's container
- Instruct assistant/kid/project-coordinator agents to read docs/index.md
when users ask how the software works
|
||
|
|
7e9127dc69 |
remove agent-callable restart tool (blast radius in multi-user model)
Nightly Build / build (push) Successful in 6m37s
|
||
|
|
8e891fbced |
llm retriability via structured status, resolve tools through canonical sandbox path, resume each frame with its own agent config
Nightly Build / build (push) Successful in 6m30s
|
||
|
|
8ff64cbddc |
rename agents/main→assistant, role-based default entry agent
Nightly Build / build (push) Successful in 6m31s
|
||
|
|
c8e4cb4384 |
run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s
|
||
|
|
780524a765 |
install: polish install/uninstall scripts with Docker setup, dep checks, and first-run flow
Nightly Build / build (push) Failing after 2m58s
|
||
|
|
44dc67cda0 |
Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
|