148 Commits
Author SHA1 Message Date
tben 06586a6dc8 Release v0.1.6
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
v0.1.6
2026-10-23 16:05:01 +01:00
tben 55fc5ee97b Follow the thread's bottom, and offer a jump back to it
Sending a message while scrolled up left it out of sight: the thread only
ever anchored its bottom, so nothing brought the view down. The column now
follows new messages while it sits at the bottom, stays put while it's
scrolled up, and always brings a message the user just sent into view.
While the thread sits above its bottom, "Jump to latest" floats over it.

Whether the thread is at its bottom is read from where the content ends
rather than from `scrollTop`, whose sign a reversed column reports
differently across engines. Content that grows after it lands (an image,
a wider window) is followed too, so the newest message never slips under
the composer.
2026-10-23 16:03:37 +01:00
tben d9aea8e9c8 Record what every model call spends, and show it
Nothing anywhere recorded what a model call cost: the harness used a
provider's usage numbers only to decide whether a turn had finished, then
dropped them. So a reply could not say what it took to make, one model
could not be compared with another on price, and there was no way to ask
what a day, a week or a month of the crew's work came to.

- `token_usages` records one row per call: user, bot, workspace, session,
  job and message, the harness, the provider and model that actually
  answered, the prompt and completion counts, and the cost when the
  provider reports one.
- `harness-usage-scope.ts` carries those ids in an AsyncLocalStorage
  scope that `run-bot-job.ts` enters once, so a run's turns, its session
  summary, its transcriptions, images and embeddings are all attributed
  without threading ids through every harness function. Nested runs merge
  rather than replace, and a null genuinely clears a field (a bot's own
  chat has no workspace).
- Work with no run behind it is charged to its owner instead:
  `run-with-fallback-scope.ts` reaches the workspace's creator else the
  bot's, and deliberately does not override a real run, so an ingest a
  bot asked for is not misattributed to the bot's owner.
- All six harnesses record through `withCandidates`'s `on_success`.
  Recording never throws: a model that answered must not lose its answer
  to a failed insert.
- A job's calls land before its message exists, so `complete-bot-job.ts`
  back-fills `message_id` once the message is inserted.
- The message's actions row shows what it cost, beside the timestamp and
  the play button, with the breakdown in its title.
- `/admin/usage` totals the install's spending for 7, 30 or 90 days: the
  total against the previous period, a day-by-day chart, a split per kind
  of call, and a table per model with the price per thousand tokens
  worked out from cost and tokens, which is what makes two models
  comparable. Crew managers only, since it is the whole install's work
  rather than one user's conversation. It sits with Routines and Activity
  in the user popover rather than under Settings, because it is a record
  of what happened, not something to configure.

Days are grouped in local time (`date(created_at / 1000, 'unixepoch',
'localtime')`), matching the local midnights the window builds and the
`YYYY-MM-DD` buckets it looks up. Grouping by the bare UTC day put a late
evening's calls under a name no bucket carried, so they vanished and the
chart's total silently disagreed with the totals card; a test pins it.

`canSeeRailItem` now decides who sees a popover or rail entry. The two
each filtered with their own inline rule and only one understood
`can_view`, so a gated page could be offered in the popover to everyone.
2026-10-23 15:41:41 +01:00
tben 0048324d8a Let a delegated task choose the workspace it runs in
delegate_task had no workspace argument: a task handed over from the
coordinator's own chat always ran in the worker's own chat, on that
worker's memory, files and directly assigned computers alone, even when
the work belonged to a workspace. The coordinator could not say where the
work goes, and the prompt never said the two differ.

- delegate_task takes an optional workspace_id: the worker runs the task
  in that workspace's context, with its own instructions and memory
  there, the project's files and the workspace's computers, and the
  result lands in its conversation in that workspace.
- A worker missing from that workspace is added first (pushBotCreated)
  and the reply says so.
- Handed over from inside a workspace the task always runs there; a
  different workspace_id is refused instead of ignored.
- The coordinator prompt, CoordinatorReplyNote and the tool description
  now spell the distinction out.
2026-10-23 14:25:21 +01:00
tben f7ef218507 Take the Gitea registry out of CI and mirror it separately
CI / check (push) Canceled after 0s
The Gitea registry is on a VPN, which a GitHub runner can never reach, so
the mirror step in the release workflow could only ever fail: its last four
releases reported a step that did nothing. The workflow now builds and
pushes to GHCR alone, which is the registry the installer defaults to, and
nothing in CI mentions Gitea.

The image reaches the Gitea registry through `scripts/mirror-gitea.sh`
(`bun run mirror:gitea`) instead, run in parallel with a release and from the
VPN side: it copies from the Gitea host over ssh, where skopeo can see both
registries, so nothing has to be installed there. `skopeo copy --all` keeps
the manifest list and its signatures, and every destination tag is read back
and compared with GHCR's digest, so a partial copy fails loudly instead of
being assumed to have worked. `latest` and `major.minor` are only moved while
that release still owns them in GHCR, so mirroring an older release never
rolls them back.

Neither direction can fail the other now, which is the point: a registry that
is down, slow or full no longer takes a release with it.
2026-10-23 12:03:23 +01:00
tben aac5d41474 Release v0.1.5
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
v0.1.5
2026-10-23 10:21:06 +01:00
tben fcad4b49d6 Let a user stop a bot, and say the app is still alpha
The thinking row and the header menu both carry Stop while a bot has work
open for the user anywhere: every pending or processing job of that bot in
their conversations is failed as stopped, its task closed, a Stopped step
recorded and one closing message posted per session, so the thread stops
waiting and the bot goes idle. The handler running the job is aborted from
this process through a registry, so a job stuck waiting on a model stops at
once instead of on its next heartbeat, and a late face change after the
abort is dropped.

The release stage is now said in the app too: Settings > General reports
"Cobble v0.1.5 (alpha)" next to development, and the README and RELEASING.md
say what alpha means for installs.
2026-10-23 10:19:41 +01:00
tben 5beb5f3de4 Let a conversation's menu show the routines that run in it
The header menu had Tasks but nothing for the schedules whose results
land in that conversation. It now has Schedules, right under Tasks,
opened in a modal that lists only what runs there: a bot's own chat holds
the routines that run nowhere, a workspace only that bot's routines in
it (`routineScopeSql`, tested against SQLite).

The list loads when the modal opens, through
`api/admin/bots/list-routines`, which checks the user chats in that
conversation first, so the page props stay small. `grabCronJobs` gained
an optional `workspace_id`: left out it still means everywhere.

The routines page's row, form and edit modal moved to
`src/components/general/`, and its subtitle to `src/utils/`, since both
the page and the modal use them.
2026-10-23 07:23:15 +01:00
tben 796c53716f Show a mirror failure, and link the release's own range
CI / check (push) Canceled after 0s
2026-10-23 06:54:06 +01:00
tben 5af2f0362a Fetch the tags in CI so the publish tests can read them 2026-10-23 06:13:14 +01:00
tben b3afda9ed8 Stop the tamper test from tampering with its own prefix
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
v0.1.4
2026-10-23 06:10:41 +01:00
tben eb29e2bca9 Bump the version to 0.1.4 2026-10-23 06:10:05 +01:00
tben 41bb4aaac2 Let a super admin read the database from the app
A new super admin settings page, /admin/settings/database, lists every
table with its record count and size, and one page of the open table's
records newest key first. Values are cut inside SQL (400 characters, with
a flag saying so) and a record opens whole in a modal, so a table of long
texts reads without shipping it all. A search matches every column's
text, word by word or as a phrase, which is the one thing that also works
on a virtual table. Nothing in it writes: the catalog, the records and
the single record are all reads, GET only.
2026-10-23 06:03:42 +01:00
tben 9b26c169d6 Let a text run take 200 steps
A task that works through a folder or a long computer session was
running out of steps before it could report.
2026-10-23 06:01:38 +01:00
tben b5c3b7d126 Share every picture with everyone signed in
A profile photo, a bot's picture and a workspace's picture all come from
one condition now (sharedPictureMediaSql), so a face is never blank just
because its owner isn't in the viewer's rail. Anything that isn't a
picture stays behind its own check.
2026-10-23 06:01:34 +01:00
tben 93e3a4e3b0 Say what media.text_content is for in the schema
The column holds extracted text only; raw binary never goes in it.
2026-10-23 06:01:24 +01:00
tben f2802c1bf3 Let bots set a picture from a file they can see
A coordinator asked to set an uploaded logo as a workspace picture could
not: the picture tools take a media id, and nothing ever gave the bot one.
Attachments reached the model as "[Attached image \"logo.png\"]", every
file tool answers with a [file:ID] ref, and an SVG is deliberately stored
as a document rather than an image.

- copyMediaAsPicture copies an approved image into a new picture row
  (bot-image/workspace-image). The source keeps its row and bytes, so a
  message that attached it still loads and changing a picture can never
  take a file away from a conversation.
- grabPictureSource resolves a picture by [file:ID] (the same scope gate
  every file tool uses) or by the user's own upload; parseFileRef reads
  the [file:12] form.
- The new set_picture tool ships on every run: a bot sets its own face,
  the coordinator can also dress a worker. update_workspace gained
  picture_file_id, and setBotPicture/setProfileImage share the copy and
  the delete.
- removeReplacedPicture deletes a replaced picture only when it really is
  one, never a chat upload.
- uploadAndRecordMedia writes images in their own format: PNG stays PNG
  with its transparency, WebP stays WebP, GIF becomes PNG, everything
  else JPEG, and the row records the real mime type (it used to say jpeg
  for every image).
- Pictures change live: BotUpdatedData carries image_media_id and a new
  server:workspace-updated pushes workspace pictures, relayed between
  processes.
- Attachments are labelled with their media id, and BotFilesNote tells a
  bot that a [file:ID] can become a picture.
- grabRunScopes is now the single source of a run's reachable folders,
  shared by the file tools, imports and the picture tools.
2026-10-23 05:24:55 +01:00
tben d3357cb7df Stop storing raw file bytes in media.text_content
`text_content` is an extracted-text cache, but nothing stopped it from
holding a dump of bytes:

- `fileToText` ended its switch with `default: return buffer.toString("utf-8")`
  and defaulted a missing extension to "txt", so any binary was decoded.
- Both extraction callers gated on `media_type == "document"`, and
  `attachmentFileKind` classes everything non-image/audio/video as a
  document: an SVG, a zip, a spreadsheet, a .bin.
- Nothing capped the length, so a 20 MB attachment could leave 20 MB of
  text in the database.
- `storeChatAttachments` copied the text onto a second `media` row, and
  `SELECT m.*` pulled the column into page props and API responses.

On a real install an SVG held 6852 characters of markup and a
`.designcraft` file held 142 KB of raw bytes.

Changes:

- `file-to-text.ts` lists the text extensions explicitly and returns
  undefined for anything else; no extension is no text.
- `can-extract-text.ts` is the one allowlist (reusing
  `BotTextFileExtensions`), used by both upload routes and by
  `uploadAndRecordMedia`; `extension-of.ts` is shared with the storage
  catalog instead of duplicated.
- `BotAttachmentTextStoreMaxChars` (200k) caps the column via
  `cap-text-content.ts`.
- `store-chat-attachments.ts` no longer copies the text; ingestion
  re-reads the bytes once.
- `media-columns.ts` selects every media column but `text_content`, which
  now has to be asked for (`grab-messages-media`'s reply path and
  ingestion do); the media CRUD routes sanitize rows they write.
- `ingest-bot-file.ts` decides on size alone, since a cached slice made
  an oversized file look already read.
- `bun run media:prune-text` cleans rows that were already stored
  (rerunnable, with a VACUUM hint).

Verified on the dev database: both bad rows cleared (146 KB), rerun
reports nothing to do.
2026-10-23 05:22:04 +01:00
tben 5475241371 Move the notification settings onto the general settings page 2026-10-23 04:11:21 +01:00
tben a5eb2b6a95 Let the coordinator set a workspace's picture
CI / check (push) Canceled after 0s
update_workspace now takes an image media id (or null to remove the
picture). updateWorkspace writes image_media_id through
resolveWorkspacePicture, which only accepts the caller's own image and
deletes the picture it replaced, so a bot can never show someone else's
file or drop a picture it is still using.
v0.1.3
2026-10-22 21:10:06 +01:00
tben 34667581b6 Tighten the coordinator prompt around how a run actually works
The prompt had grown to a policy document: 13 sections, the same rules
repeated across them, and a state vocabulary no tool accepts. It also
never mentioned the reply/task split, so the model would burn a reply's
8 tool steps on work that belongs in a 50-step task.

- Lead with work placement: answer directly, start_task for its own long
  work, delegate_task for a worker, and say work is under way, not done.
- Replace the invented Requested/Queued/Verified list with the real
  TaskStatuses and update_task.
- Name the 19 tools it tells the coordinator to use, and which tool
  writes which instruction layer, instead of "the available tools".
- Give routines their own section; create_schedule was uncovered.
- Say each safety rule once, and keep the interrupt list short so the
  prompt reads as proactive rather than timid.
- Drop the commented-out previous prompt: that is git's job.

The test asserted a heading and a section that no longer exist, so it was
already failing. It now checks the heading, the SiteData name, the
absence of em dashes, the real statuses, and every tool named.
2026-10-22 20:14:08 +01:00
tben 2398314703 Give the coordinator a fuller prompt, and show bot instructions in find_bot
The coordinator's default prompt was a page of job descriptions. It now
states the working rules it kept getting wrong: a queued task is not a
running one, a tool result is the only proof an action happened, an
approved text is written whole, and an explicit constraint such as "no
computer" is binding. The prompt is attached at creation only, so this
reaches new installs and a coordinator whose prompt is still empty.

find_bot now returns each match's instructions, so the coordinator can
judge a worker by what it was told rather than by its name.

(The previous prompt is kept as a commented-out block at the bottom of the
file.)
2026-10-22 20:11:03 +01:00
tben e8e4df6f56 Publish releases from two scripts, one per remote
A release meant running the RELEASING.md checklist by hand: tag, push
twice, create the GitHub release, then remember the Gitea push. The two
scripts do the machine-checkable part of it, with the shared flow in
scripts/lib/ so they cannot drift apart: clean worktree, not behind the
remote, typecheck, tests and the install.sh syntax check, then the tag at
HEAD, the branch push and the release object.

Only the release object differs. GitHub creates it with gh, and publishing
it is what fires the image build. Gitea gets the same notes through its
API, with the token from GITEA_TOKEN or the one tea already stored, since
nothing there builds images.

Both accept --version (defaulting to package.json), --branch, --yes,
--dry-run and --skip-checks, and a rerun finishes a release that failed
half way instead of starting over: an existing tag is reused, a pushed ref
is skipped and an existing release is left alone. A tag sitting on another
commit is refused, since publishing would ship that commit's code.
2026-10-22 20:10:59 +01:00
tben 70d6f9b3d8 Bump the version to 0.1.2
CI / check (push) Canceled after 0s
v0.1.2
2026-10-22 16:43:19 +01:00
tben 05af559adb Keep SELinux mount options out of the mount destination
Appending `,z` to a mount destination (`$DATA_DIR:/data,z`) makes docker
mount a directory literally named `/data,z`. The app then writes its
database into its own container layer instead of the host volume,
`container_matches` never matches so every update recreates the container
without its data, `backup_database` silently skips, and `resolveHostPath`
refuses to provision computers (`/data/computers/1 isn't on a volume`).

Build each spec as `source:destination[:options]` instead, with the `z`
and `ro` options comma separated in the third field and the destination a
plain absolute path. `backup_database` uses the same helper.

`rescue_container_data` runs before a container whose /data is not the
host's directory is replaced: it stops the app, copies `CONTAINER:/data`
out with `docker cp`, moves a non-empty host directory aside and writes
the copy in, so repairing an install from the broken form does not lose
the database, the media or the computers' home directories.

`install.test.ts` lifts the marked `selinux-detection` and `mount-specs`
blocks out of install.sh, runs them in bash and parses the specs the way
docker does, so the two forms can't drift back.
2026-10-22 16:43:10 +01:00
tben 524f0dde36 Show the running version on Settings > General
The General page ends with a Version block naming the release this
server runs, so an update can be confirmed from the app.

The version is COBBLE_VERSION, the release tag the image was built with
(Dockerfile ARG, passed by the release workflow, which falls back to
package.json's version on a workflow-dispatch build), else package.json's
version, which a development server reports too, with (development)
after it. runningVersion (src/utils/running-version.ts) drops an
unusable version string rather than showing it.

Also includes the working tree's pending signup guard in
src/pages/__root.server.ts: a user who still holds a session while the
install has no users is sent to /auth/logout (and may load it).
2026-10-22 16:03:56 +01:00
tben 89e57cc467 Pad env rows and reflow the envs group
CI / check (push) Canceled after 0s
Give each env row its own horizontal padding so the key lines up with the
group's other content, and reflow the props and description literals in
env-row and envs-group.
v0.1.1
2026-10-22 15:24:58 +01:00
tben e1faa2141e Limit the app container to 1 CPU and 2 GB, and stop stale caches
CI / check (push) Canceled after 0s
The app container is now created with --cpus 1 --memory 2048m: the heavy
work happens in the computers' own containers, so the orchestrator does
not need more. Both are installer flags (--cpus, --memory), kept in
install.conf, and part of the idempotency check, so an existing install
picks them up on its next update.

The env and app-setting caches lived in module variables, and bunext
bundles pages and API routes separately. A save cleared only its own
copy, so a saved provider key never reached the page that lists models
until the process restarted. They now share one slot per name on
globalThis, behind a symbol from the global registry, with the pattern
extracted into a tested helper.
2026-10-22 15:16:51 +01:00
tben 06210f00a5 Build the release image once and mirror it to Gitea
CI / check (push) Canceled after 0s
A job-level continue-on-error rejects an expression, and a second registry
must not decide whether a release ships.
v0.1.0
2026-10-22 14:02:21 +01:00
tben 38221c4baa Keep secrets out of the release matrix, where Actions rejects them
CI / check (push) Canceled after 0s
2026-10-22 13:58:59 +01:00
tben e45aa4162a Give each registry its own release leg, so Gitea cannot fail a release
CI / check (push) Canceled after 0s
Cloudflare rate limits git.tben.me's blob uploads (429/1015), which failed
the first v0.1.0 image even though GHCR had already pushed every tag.
2026-10-22 13:57:30 +01:00
tben 63a93a4262 Keep only the bot cookie for the last visited bot
CI / check (push) Canceled after 0s
A workspace conversation is never remembered, so /admin/bots always
reopens the bot's own chat.
2026-10-22 13:39:36 +01:00
tben 133e6b87c9 Put a user message's copy button beside its timestamp
The copy button sat on its own row above the group's time, costing a
line of vertical space for nothing. It now shares that row, left of the
timestamp, the way a bot message's actions already do. A file-only
message leaves no gap: only messages with text get a button, picked by
copyableMessages.
2026-10-22 12:33:48 +01:00
tben 08d02e6033 Give bots skills: runbooks they load when a job matches
A skill is a runbook: a name, a unique slug, a one-line description of when
it applies, and the markdown body. A run only ever sees the slug and
description (one load_skill tool carries them), and reads the whole body
only when it calls the tool, so a long runbook costs nothing until needed.

Skills are the third extension kind beside MCP servers and custom tools, on
the same botExtensionsSql path, so per-bot, per-workspace and all-bots
grants work the same way, and attachDefaultExtensions hands an every-bot
skill to new bots. load_skill is a reserved tool name, so a custom tool
cannot shadow it, and a built-in wins a clash.

Managed on Settings > Skills; ExtensionRow links each kind to its own page
and a skill never reports a failure, since nothing connects or runs.
2026-10-22 12:31:42 +01:00
tben 379ef35e08 Toggle the color scheme from the rail header
Add a moon/sun button beside the rail's Add icon that flips this device
between light and dark, stored under the same theme key the theme picker
and ThemeInitScript read. Following the device stays on Settings > General,
since the toggle is always an explicit choice.
2026-10-22 12:02:04 +01:00
tben 1a6249afd3 Let a bot take any colour
A bot's identity was one of seven hues, and the hue picked both its colour
and its stone shape. Now its colour is free and its stone is its own:

- `bots.color` holds any `#rrggbb`, `bots.stone` the flat-cut shape it's
  drawn on. `bots.hue` stays as the palette column they replaced, read only
  as a fallback; `src/scripts/move-bot-colour-to-owners.ts` backfills an
  install from it (safe to rerun).
- `src/utils/bot-color.ts` is the only place a colour is validated or
  resolved (`resolveBotColor`/`resolveBotStone` carry the old hue forward,
  `botColorNeedsPaperFace` flips the eyes to paper on a very dark body), and
  `src/utils/bot-stone-props.ts` gives every row its props, so a row saved
  before this still renders as it did.
- `BotStone` takes the colour as the body's SVG `fill`, the one documented
  exception to the token rule: an arbitrary hex has no token to point at.
  `UnreadDot` follows it.
- The picker keeps the palette tiles (still shape and colour together) and
  adds a native swatch and a hex field, so a custom colour keeps its stone.
  Same place as before: the header avatar's modal and Settings > Bots, for
  crew managers. The coordinator stays hue-less.
- A recolour reaches other open tabs through `pushBotUpdated`.
2026-10-22 11:55:36 +01:00
tben 2eccfe9e13 Remove workspace scoped bot from last visited bot list 2026-10-22 11:37:43 +01:00
tben 5a9f6d938a Copy a message to the clipboard from the chat
Bot and user messages get a quiet copy button under them: the copy icon becomes a check for a moment after it copies the message's text as stored, markdown and refs included. A message still on its way to the server can be copied too, though reply and reactions wait for its id. The async clipboard API is used where it exists and a hidden textarea where a plain HTTP install has none.
2026-10-22 11:35:07 +01:00
tben 080f8e9abc Edit the prompt appendix as markdown, bump twui
The install-wide appendix now uses the shared CodeEditor (AceEditor,
markdown mode) instead of a plain textarea, discarding by remounting the
editor with the saved text. The storage quota field moves to the twui
0.1.6 number input, so the dependency is bumped with it.
2026-10-22 11:23:36 +01:00
tben 16f1e7904a Open the bot last visited, else the coordinator
`/admin/bots` sent the super admin to the coordinator and everyone else to
their latest bot, which lost the thread each time the tab was reopened. The
conversation pages now record where the browser is in two HttpOnly cookies
(`cobble_last_visited_bot_id`, `..._workspace_id`, a year, `Secure` only over
HTTPS), and the landing page opens that conversation again, in its workspace
when it was one.

A stale cookie can't loop: `grabReachableConversation` re-checks it with
`grabUserConversation`, the gate the conversation pages use, so a revoked
grant, a deleted bot or an archived workspace falls back to the coordinator
(super admin) or the latest bot, first workspace, empty page. Signing out
clears both cookies so the next user on that browser doesn't land in the
previous one's conversation.
2026-10-22 11:18:19 +01:00
tben a8d8b680ad Append install-wide instructions to every bot's prompt
Settings > General gains a prompt appendix the super admin sets once:
markdown every bot runs with, right after its own core instructions.

- The text lives in app_settings (bot_system_prompt_append, read through
  grabSystemPromptAppend, cached like every app setting), so it changes
  without a deploy and applies to every bot on the next run.
- composeBotPrompt takes an appendix and pushes it as
  # Install-wide instructions directly after system_prompt, before the
  workspace block and memory, so the bot's own more specific rules still
  win. grabBotPrompt feeds it in, so chat replies, tasks, scheduled runs
  and the coordinator all get it with no call site touched.
- api/admin/settings/system-prompt-append saves it (super admin only,
  capped at AppData.PromptMaxLength, empty clears it) and is audit
  logged under system.
- The section and its form are hidden from everyone but the super admin,
  save in place with a character count and no reload, and say what an
  empty text does.
2026-10-22 11:12:38 +01:00
tben a88898d169 Archive the whole data volume in Backups, not just the database
Settings > Backups gains a whole-data archive beside the database
snapshots, and both lists now fully work from the page.

- Data archives: `write-data-backup-archive.ts` tars all of DATA_DIR into
  BACKUP_ARCHIVE_DIR as cobble-data-<epoch-ms>.tar.gz, every entry under one
  root folder named after the archive, writing a .part name and renaming on
  success so a cut-off run is never listed. The newest
  AppData.DataBackupsKept are kept; db/.backups is left as it is.
- Restore: `restore-data-backup.ts` extracts to a temp dir, requires one
  root folder holding a db folder, takes a safety archive first, then
  replaces DATA_DIR (the database snapshots included) and `restart-app.ts`
  restarts the process through the docker socket, since the open SQLite
  handle points at the replaced file.
- Upload and restore: a modal over each list (database file or data
  archive), replacing the old /admin/settings/backups/restore page.
- Delete for both kinds, icon-only row actions shared through
  RowIconButton, and restore on every row.
- One backup or restore of either kind at a time (with-backup-run-lock).
- BACKUP_ARCHIVE_DIR is a start-up path (ProcessOnlyEnvKeys); the installer
  mounts $COBBLE_HOME/backups-archives at /backups-archives and recreates a
  container that lacks it. The image installs tar and gzip.
2026-10-22 11:00:37 +01:00
tben fd75111bb1 Open a folder chip on its folder, in the folder that really holds it
A bot's folder chip went to bare /admin/files instead of the folder it
named, and even once there the computer volume pointed at the wrong
directory, so the folder a bot linked to looked empty.

The chip lost its folder because folderRefUrl only accepted a ref's
volume name (computer) while the chip hands it the browser's
(computers), so storageVolumeFromRef found nothing and the whole query
was dropped. Both spellings resolve now, through parseStorageSpec, so a
bad owner falls back cleanly instead of emitting owner=0.

The deeper bug: a computer's home was assumed to be
DATA_DIR/computers/<id>, but on a bind mount it can be anywhere on the
docker host, and a computer the app didn't provision (the main one on a
server reached at MAIN_COMPUTER_URL, i.e. a dev server outside docker)
records nothing at all. resolveComputerHomeDir holds the precedence:
the directory provisionComputer actually bound in
(containers.metadata.data_dir), else the new MAIN_COMPUTER_DATA_DIR app
setting for the main computer, else the conventional folder.

A path that isn't on disk also no longer reads as an empty folder:
listStorageEntries reports it as missing and the page says so, so a
stale folder link can't look like a folder nothing has written yet.
2026-10-22 10:57:14 +01:00
tben 841149921b Refresh the session cookie after a profile save 2026-10-22 10:21:58 +01:00
tben 28053e690f Narrow the files browser's folders column to 180px 2026-10-22 10:21:58 +01:00
tben eacbe4ab87 Lead the files browser's folders with a tinted type icon
Each folder row in /admin/files now opens with its type as an icon in a
box tinted with its own hue (Bots ember, Workspaces teal, Computers
cobalt, Media violet, MCP servers moss, Database amber), so the picker
reads at a glance and the row's width goes to the name. The box carries
the type as its title, so hovering still names it.
2026-10-22 10:18:35 +01:00
tben aebd6b8ccc Serve every media file privately at /media/<id>
There is no public media any more. `/media/public/<id>` served a media
row with no session at all, so anyone with the id could read a file.
The public/private split is gone: the path is `/media/<id>` (and
`/media/<id>/thumbnail`), every request is signed in first, and
`grabUserMedia` decides whether the user may see it: their own upload,
a bot's or workspace's picture or file they reach, or a file on a task
they reach. Anything else is a 404 before a path is built.

The `media.is_private` column and the two per-user directories went
with it: uploads live in `media/<user_id>/` and there is nothing to
pick between. The files browser's media volume is now the viewer's
own folder whatever their role.

Bots also say which of the two things they mean. A `[file:ID]` ref is a
saved file and its chip opens the bytes; a folder is a new
`[folder:volume:owner:path]` ref (`src/utils/folder-ref.ts`) whose chip
opens the files browser there and downloads nothing. The old `[path:...]`
ref and the extension sniffing that guessed file from folder are gone,
so `import_file` names the folder holding a file it copied rather than
the file itself.

The prompts say what the Assistant got wrong: list before importing or
saving, one copy per artifact, never split a file into parts unless
asked, never claim the store is empty without a fresh list, and never
invent a size cap. A re-import now says the path already existed
instead of silently overwriting and reporting a fresh copy.
2026-10-22 10:10:53 +01:00
tben ffd32c8b27 Paste another site's text without replaying it forever
view.pasteText(text, event) re-asks every paste handler with the event it was given, so the composer's own handler replayed the paste until the stack overflowed (any rich paste from VS Code, Slack or a browser page). It now passes no event, and a ref keeps it from re-entering either way.

The plain-text decision moves to shouldPastePlainText with its own tests.
2026-10-22 08:35:39 +01:00
tben 9af5323530 Square off the rail header and add a back button
The rail header now sits in the standard 64px row with a hairline under
it, matching the pane headers, and shows the full logo off the chat
pages instead of the bare stone. A back button joins the conversation
and settings headers (workspace pages only for the conversation). The
rail's own back link and the dividers around it are switched off for now.

Also renames the bot and workspace Delete group to Danger Zone, lifts the
step budget to 50, and lets Logo render without its home link.
2026-10-22 08:30:51 +01:00
tben 96735de48f Let a bot and a workspace set their own envs
Envs had three scopes, general, workspace and computer. A bot had no
scope of its own, so the only way to give one worker a key was to set it
on a computer it happened to share.

The envs table gets a bot_id, and resolution now layers bot over
computer over workspace over general, so a bot's own key wins wherever it
runs. Every computer call site passes the run's bot_id, and grabRunEnvs
does the same for the {{env.KEY}} a custom tool or an MCP server's headers
read.

A bot's and a workspace's settings page can now edit their own envs, in
place, through the same save-env and delete-env routes: sending `scoped`
makes them answer with just that scope instead of the whole install's
overview. Super admins only, like the Environment page.
2026-10-22 08:30:40 +01:00