Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2646842c32 | ||
|
|
b6d6de7e53 | ||
|
|
75d5a795a2 | ||
|
|
1600a57b66 | ||
|
|
2a0f688e10 | ||
|
|
24f3b4bdef | ||
|
|
3adc88963b | ||
|
|
6d06acc111 | ||
|
|
ba64bcd2e6 | ||
|
|
27d85f934c | ||
|
|
54f9f24726 | ||
|
|
7fc25ae53f | ||
|
|
8770f98dcd | ||
|
|
6572fa90f0 | ||
|
|
99149443b7 | ||
|
|
7370a422d1 | ||
|
|
0fc43ef6d0 | ||
|
|
5c6aea4ffc | ||
|
|
c38f02afa3 | ||
|
|
a6509f6456 | ||
|
|
9cd9d10d50 | ||
|
|
6cd99ddbc3 |
@@ -38,10 +38,15 @@ anything under `execution`.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
||||
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
||||
deliberate boundary.
|
||||
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||
|
||||
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||
the counts stay right and the sentences rot.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -372,6 +377,12 @@ another session's roles work, swept up mid-`git add`. The other session was righ
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
@@ -526,6 +537,18 @@ explaining why it was safe.
|
||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||
anything it already rules out
|
||||
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
a namespace makes provenance readable, separate directories make it structural
|
||||
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
|
||||
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
|
||||
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
|
||||
get wrong — a team folder that is not `setgid` charges nothing to the team
|
||||
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
||||
- `src/apps/CLAUDE.md` — shared frontend patterns
|
||||
- `src/databases/CLAUDE.md` — database patterns
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# 2026.08.23.05 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
|
||||
rollout) and `edge-pertento-bridge` (in the *Deliberately skipped* section of `docs/servers.md`).
|
||||
|
||||
Every target ended at `e15ab2303a17ed0e1d1cf59b35f34aec68875907` = `deploy/2026.08.23.05`, merged
|
||||
`--ff-only` onto the tag itself. No host was left on the branch, and no host is a commit past the tag.
|
||||
|
||||
Schema evidence is per host, by query, below. `--force` was never used and was never needed.
|
||||
|
||||
---
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout (from PM2 `pm_cwd`) | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphan users `0`, orphan grants `0`.
|
||||
|
||||
SQL applied, exit 0. Verification query:
|
||||
|
||||
```
|
||||
conname | pg_get_constraintdef
|
||||
-------------------------------+-------------------------------------------------------------
|
||||
ck_role_permissions_not_owner | CHECK ((role <> 'Super Admin'::text))
|
||||
fk_role_permissions_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||
fk_users_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||
```
|
||||
|
||||
Both `fk_` present, `ON UPDATE CASCADE`, no `ON DELETE`. `ck_role_permissions_role` absent.
|
||||
`ck_role_permissions_not_owner` present. Re-queried after `db:push` — unchanged.
|
||||
|
||||
`bun db:push` ran clean. **It is not a no-op on this host** — see "The one thing that is not clean" below.
|
||||
|
||||
`bun run plugins:update`: `12/12 updated in 47s`, exit 0. That step restarted twelve plugin sidecars —
|
||||
atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek,
|
||||
transmission, vault.
|
||||
|
||||
`pm2 restart officer`: restart_time 70 → 71, `online`. All 19 `officer*` processes `online` afterwards.
|
||||
|
||||
### `fofos` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | `scripts/chk.ts` — left alone, not a reason to skip |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query returned the same
|
||||
three rows as `alpha`: both `fk_` present with `ON UPDATE CASCADE`, `ck_role_permissions_role` absent,
|
||||
`ck_role_permissions_not_owner` present.
|
||||
|
||||
`bun db:push` clean; a second `push --verbose` said **`No changes detected`** — a genuine no-op.
|
||||
|
||||
`bun run plugins:update`: `1/1 updated in 7s`, exit 0. Restarted `officer-gitea`.
|
||||
|
||||
`pm2 restart officer`: 24 → 25, `online`. All 8 processes `online`.
|
||||
|
||||
### `production-pertento-bridge` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||
same verdict.
|
||||
|
||||
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||
|
||||
`bun run plugins:update`: `1/1 updated in 5s`, exit 0. Restarted `officer-gitea`.
|
||||
|
||||
`pm2 restart officer`: 3 → 4, `online`. All 15 processes `online` (this host also runs eight non-Officer
|
||||
PM2 entries — Crons/Dashboard/Tracking/Uptime — untouched and still `online`).
|
||||
|
||||
Cosmetic, pre-existing, not caused by this release: every `$SHELL -ic` command on this host prints an
|
||||
nvm warning about a `globalconfig`/`prefix` in `~/.npmrc`. Nothing failed because of it.
|
||||
|
||||
### `deadpool-rezio-bridge` — succeeded
|
||||
|
||||
Login `br`, per `docs/servers.md`.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/br/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||
same verdict.
|
||||
|
||||
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||
|
||||
`bun run plugins:update`: `2/2 updated in 7s`, exit 0. Restarted `officer-email` and `officer-offscale`.
|
||||
|
||||
`pm2 restart officer`: 9 → 10, `online`. All 9 processes `online`.
|
||||
|
||||
### `macbookpro` — succeeded, and the host the plan flagged is now verified
|
||||
|
||||
Awake and reachable. macOS, so the PATH and `psql` notes in `docs/servers.md` all applied.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/Users/pastilhas/projects/officer-suite/platform` |
|
||||
| HEAD before | `6e092c32` (`deploy/2026.08.23.04-48-g6e092c32`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
It started 48 commits *past* the previous tag rather than on it — the tag was still an ancestor, so the
|
||||
fast-forward was legitimate and no reset was needed.
|
||||
|
||||
**The roles table the plan could not verify before the tag is now verified:** `roles = 4`, orphan users
|
||||
`0`, orphan grants `0`. The plan was right that there is no `psql` here — there is also no Homebrew
|
||||
postgres and no postgres container. The query went through **Bun's own `SQL` client** (`bun 1.3.14`)
|
||||
against the same `OFFICERDEV_POSTGRES_URL` the platform's `drizzle.config.ts` reads, so it is that host's
|
||||
own database and not an inference from another one.
|
||||
|
||||
SQL applied through the same client, no exception raised. Verification query:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "conname": "ck_role_permissions_not_owner",
|
||||
"pg_get_constraintdef": "CHECK ((role <> 'Super Admin'::text))" },
|
||||
{ "conname": "fk_role_permissions_role",
|
||||
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" },
|
||||
{ "conname": "fk_users_role",
|
||||
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" }
|
||||
]
|
||||
```
|
||||
|
||||
Both `fk_` present with `ON UPDATE CASCADE` and no `ON DELETE`; `ck_role_permissions_role` absent;
|
||||
`ck_role_permissions_not_owner` present. Same verdict as the other four.
|
||||
|
||||
`bun db:push` ran clean. It is not a no-op here either — see below.
|
||||
|
||||
`bun run plugins:update`: exit 0, and its whole output was **`no plugins installed — nothing to update`**.
|
||||
No sidecar restarted. That is worth a second look and is covered below.
|
||||
|
||||
`pm2 restart officer`: 11 → 12, `online`. All 6 processes `online`.
|
||||
|
||||
---
|
||||
|
||||
## The one thing that is not clean
|
||||
|
||||
The plan says `bun db:push` should be a no-op after the SQL, and that anything it still wants to alter
|
||||
means the SQL was incomplete. On two hosts it still wants to alter something. **It is not the release's
|
||||
schema change, and the SQL was not incomplete** — the verification query above proves the constraints
|
||||
this release introduced are exactly right on all five hosts, and re-querying after `push` on `alpha`
|
||||
showed them unchanged.
|
||||
|
||||
What `push --verbose` still wants, after the SQL:
|
||||
|
||||
- **`alpha`** — drops and immediately re-adds `pk_music_now_playing` on `music_now_playing`, with an
|
||||
identical definition.
|
||||
- **`macbookpro`** — the same `music_now_playing` churn, plus two `atelier_collection_items` foreign
|
||||
keys dropped and re-added under names one character longer (`…_id_f` → `…_id_fk`).
|
||||
|
||||
Both are plugin tables, both are the documented drizzle-kit mis-diff on named composite constraints and
|
||||
on foreign keys whose generated name Postgres truncates at 63 characters. The `atelier` case is
|
||||
precisely the churn this release's notes claim to end "across the fleet's plugins" — and on `alpha`,
|
||||
where the plugins *did* update, the atelier churn is indeed gone and only the `music` one remains. On
|
||||
`macbookpro` the plugins did not update at all, so it still carries both.
|
||||
|
||||
`fofos`, `production-pertento-bridge` and `deadpool-rezio-bridge` all reported `No changes detected` —
|
||||
a true no-op, which is the outcome the plan expected.
|
||||
|
||||
I judged this not to be a stop condition and continued. The reasoning, so it can be overruled: the plan's
|
||||
"stop and report" is aimed at the roles/FK change being half-applied, that change is provably whole on
|
||||
every host, and the residue is a pre-existing self-cancelling churn on plugin tables that this release
|
||||
does not touch. If that call was wrong, the thing to re-examine is `music_now_playing`'s composite
|
||||
primary key — it is the one still churning on the host where plugins are current.
|
||||
|
||||
---
|
||||
|
||||
## What I did not do, and could not verify
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It is at `6cd99ddb`, one commit
|
||||
*past* the tag; that commit landed on `master` after the tag was cut and is a docs commit. Its schema
|
||||
change was already applied and verified before this rollout, per the plan. It is the only host not at
|
||||
the tag exactly, and restarting it is the owner's deliberate last step.
|
||||
- **`edge-pertento-bridge` — skipped, not a failure.** Listed in *Deliberately skipped* since
|
||||
2026-08-23. Nothing was spent on it: no SSH attempt, no retry.
|
||||
- **The five-language picker was NOT checked in a browser.** The plan's "what to check" asks for the
|
||||
profile settings picker to offer five languages and for a *panel* to be inspected in a non-English
|
||||
locale. That is a UI check and I did not perform it. What I can say is narrower: every host serves the
|
||||
tag's bundle and every host restarted `officer` afterwards, so the fix is present in the code each
|
||||
host is running. Whether it renders is unconfirmed.
|
||||
- **Minesweeper's absence was not checked.** `/minesweeper` was not requested on any host, and the dock
|
||||
was not inspected.
|
||||
- **`macbookpro` reports no plugins installed** while `gen-plugin-schemas` finds three schema files on
|
||||
disk (`atelier`, `kiosk`, `music`). So that host has plugin *schemas* without registered plugins. This
|
||||
predates the release and nothing in the plan covers it, so I left it alone — but it explains why the
|
||||
atelier constraint churn cleared on `alpha` and not here, and it means `macbookpro`'s plugins are not
|
||||
being updated by the deploy at all.
|
||||
- **`plugins:update` restarted sidecars on four hosts before the plan's own restart step**, as
|
||||
`docs/servers.md` warns. Counted per host above rather than left as "restarted: officer".
|
||||
|
||||
## One note for `docs/servers.md`
|
||||
|
||||
The database URL key is **not the same on every host.** `alpha` has only `POSTGRES_URL`; `fofos`,
|
||||
`production-pertento-bridge`, `deadpool-rezio-bridge` and `macbookpro` have only
|
||||
`OFFICERDEV_POSTGRES_URL`. `drizzle.config.ts` reads `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`, so both
|
||||
work for the app — but a rollout that queries the database directly must follow the same precedence or
|
||||
it verifies the wrong database and says "schema ✓" about it. This cost one failed step on `fofos` before
|
||||
being caught, and I re-checked `alpha` afterwards to confirm it holds only the one key and that its
|
||||
verification was therefore sound.
|
||||
|
||||
`macbookpro` also has no `timeout(1)`. A remote command wrapping anything in `timeout` fails there with
|
||||
`command not found` — and it fails *silently* in the sense that what you see is the next command's error,
|
||||
not the missing binary's.
|
||||
@@ -0,0 +1,146 @@
|
||||
# 2026.08.23.06
|
||||
|
||||
Swedish, a plugin's capabilities read where they live instead of being copied, dev tools as a property of a
|
||||
role, a commander as a directory rather than a session, and uploads that land atomically.
|
||||
|
||||
**There are TWO schema changes and `bun db:push` can apply NEITHER of them.** Both stop and ask a question
|
||||
no terminal is there to answer, and then exit 0. Read "The schema changes" before running anything.
|
||||
|
||||
**There is also a one-off cleanup on three hosts**, and skipping it means the agent reads one affordance
|
||||
twice. See "The orphan copies".
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Swedish.** `sv-SE`, complete across all three catalogues — six shipped locales now.
|
||||
- **Parity checks itself.** The three parity tests listed their locales by hand, so adding a sixth added a
|
||||
file none of them looked at; they read the directory now. They also check both directions — the old check
|
||||
only asked whether every English key was present, so a rename reported half of itself.
|
||||
- **A plugin's capabilities are LOADED, not copied.** Affordances, notifications and skills are read from
|
||||
each installed and enabled plugin's own directory. `capabilities/` is meant to be forked and was
|
||||
accumulating files nobody authored. A copy also went stale on update and survived a disable.
|
||||
- **Roles phase 3.** `rolePermitsDevTools()` was `role === 'Developer'` gating a Postgres role and a
|
||||
rootless Docker daemon. Two boolean columns carry it now, so a role created at runtime can hold dev tools.
|
||||
- **Installing a plugin grants it to every role.** Deny-by-default meant every install needed a second
|
||||
decision on another screen, which mostly got forgotten. The owner revokes instead. Rows, not a rule
|
||||
change — absence still denies.
|
||||
- **A commander is a directory, not a session.** Its own `$DATA_PATH/<email>/commanders/<id>/` with a
|
||||
`CLAUDE.md` written at creation. `commander-block.ts` is gone.
|
||||
- **Uploads land atomically** — written aside and renamed, so a watcher never sees a half-written file.
|
||||
Same for SendOff.
|
||||
- **Whisper priming prompts for 23 more languages.** Inert until an STT server is configured; none is.
|
||||
|
||||
## The schema changes
|
||||
|
||||
Two tables. **Run this SQL first**, then `bun db:push` to confirm it took.
|
||||
|
||||
**Why not `db:push`:** `roles` drops a column while adding two, and `command_hq_sessions` does the same
|
||||
shape — drizzle-kit cannot tell a drop-plus-add from a rename, so it asks. With no terminal it bails, the
|
||||
wrapper still exits 0, and the `postdb:push` seed then fails against an unchanged schema. Verified on
|
||||
`officer-dev` twice. See `src/databases/CLAUDE.md`.
|
||||
|
||||
Both blocks are idempotent and safe to run twice.
|
||||
|
||||
```sql
|
||||
-- ── roles: dev tools become a property of the role ──────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE used int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO used FROM roles WHERE provisioning_script IS NOT NULL;
|
||||
IF used > 0 THEN
|
||||
RAISE EXCEPTION 'STOP: % role(s) have a provisioning_script. Do not continue; report this.', used;
|
||||
END IF;
|
||||
EXCEPTION WHEN undefined_column THEN
|
||||
NULL; -- already dropped by a previous run
|
||||
END $$;
|
||||
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
|
||||
-- ── command_hq_sessions: the row owns its identity, the transcript id becomes a pointer ──────────
|
||||
-- Every existing commander is DELETED rather than migrated. This is deliberate and the owner's call:
|
||||
-- a commander's identity, directory and CLAUDE.md are all new, so migrating means writing a path that
|
||||
-- runs once and never again. Re-arm afterwards; it also exercises the flow this release exists to fix.
|
||||
DELETE FROM command_hq_sessions;
|
||||
|
||||
ALTER TABLE command_hq_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS dir_name text NOT NULL DEFAULT '';
|
||||
ALTER TABLE command_hq_sessions ALTER COLUMN dir_name DROP DEFAULT;
|
||||
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS reference_paths text[] NOT NULL DEFAULT '{}';
|
||||
```
|
||||
|
||||
**Tell the owner what you deleted** before running it, and put it in the report — a commander is somebody's
|
||||
standing job, and "we removed yours" is not a footnote. On `officer-dev` it was one, `Platform Deployer`,
|
||||
already deleted there.
|
||||
|
||||
Then verify **by query**, not by an exit code:
|
||||
|
||||
```sql
|
||||
SELECT name, is_system, grants_postgres, grants_docker FROM roles ORDER BY name;
|
||||
|
||||
SELECT column_name, is_nullable FROM information_schema.columns
|
||||
WHERE table_name = 'command_hq_sessions' ORDER BY column_name;
|
||||
```
|
||||
|
||||
**`Developer` must have both flags true and the other three both false.** If Developer comes back false the
|
||||
seed did not run — fix it before restarting, or Developer accounts lose their Postgres role and Docker
|
||||
daemon at their next provisioning. `provisioning_script` must be absent.
|
||||
|
||||
`command_hq_sessions` must show `dir_name` and `reference_paths`, with `session_id` nullable.
|
||||
|
||||
## The orphan copies
|
||||
|
||||
**`alpha`, `fofos` and `production-pertento-bridge` each have
|
||||
`capabilities/affordances/officerdev/gitea/`** — published by the mechanism this release removes. The gitea
|
||||
plugin still ships that affordance in its own directory, and affordances are deliberately NOT
|
||||
de-duplicated, so after this release both load and the agent reads the same guidance twice.
|
||||
|
||||
```bash
|
||||
cd <the capabilities directory beside the checkout>
|
||||
git status --porcelain # expect only ?? extensions/ afterwards
|
||||
rm -rf affordances/officerdev
|
||||
```
|
||||
|
||||
Safe because it is a copy: the source is `plugins/gitea/affordances/`, which is what now gets read.
|
||||
`deadpool-rezio-bridge` has none; check anyway.
|
||||
|
||||
**`extensions/` stays.** The platform's own bootstrap writes it every boot. This release gitignores it, and
|
||||
the ignore arrives with the capabilities pull in step 4.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.06`
|
||||
2. **The SQL above**, both blocks, then the two verification queries. Stop on the `RAISE EXCEPTION`.
|
||||
3. `bun db:push` — expect **no changes and no prompt**. If it asks anything, the SQL did not take.
|
||||
4. `bun run plugins:update` — this pulls the capabilities repository too, which is how the new
|
||||
`.gitignore` arrives.
|
||||
5. **The orphan removal**, on the three hosts that have one.
|
||||
6. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 6. Step 4 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It has
|
||||
`psql` now and a `.zshenv` that puts it on a non-interactive PATH, so the SQL can be run there directly.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**Open a chat and ask the agent what skills it has.** The preamble is built from a new code path —
|
||||
`capability-sources.ts` resolving roots per session — and the failure mode is an agent that quietly knows
|
||||
less rather than an error. It should name the commander, deploy and platform skills, plus anything a plugin
|
||||
ships on that host. On a host with the gitea plugin, its affordance should appear **once**.
|
||||
|
||||
**The language picker should offer six.** Switch to Swedish and look at a *panel*, not just the shell.
|
||||
|
||||
**Creating a commander should produce a directory.** `$DATA_PATH/<email>/commanders/<n>/` holding
|
||||
`CLAUDE.md` and `state/`. The setup pane asks for a *reference path* now, not a working directory.
|
||||
|
||||
No direct SQL beyond the block above is needed, and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped. Down, per `docs/servers.md`.
|
||||
- **`officer-dev`** — skip it. Both schema changes are already applied and verified by query there, its
|
||||
capabilities repository is clean, and restarting `officer` on the host running the rollout ends the
|
||||
rollout. Name it as skipped and say it is current.
|
||||
@@ -3,6 +3,17 @@
|
||||
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through
|
||||
it, kept current as answers landed.
|
||||
|
||||
**Superseded in part, 2026-08-24, by `docs/commander-directory.md`.** A commander's identity moved from
|
||||
Claude's transcript id to its own `command_hq_sessions` row, and it gained its own directory
|
||||
(`$DATA_PATH/<email>/commanders/<dirName>/`, `CLAUDE.md` + `state/`) created BEFORE any turn spawns. That
|
||||
makes several sections below describe the PREVIOUS mechanism rather than the current one — notably
|
||||
"How a commander is created" (creation is no longer session-first), "Working directory" (a commander no
|
||||
longer shares one directory per user, nor chooses an arbitrary one — it has its own), and the `POST
|
||||
/commanders` / `POST /commanders/new` rows in "API reference" (both now require `name` and `referencePath`
|
||||
and no longer accept an arbitrary `cwd`). This file was not rewritten to match — read
|
||||
`docs/commander-directory.md` first for anything those sections describe, and treat the code
|
||||
(`src/servers/api/command-hq/`) as the source of truth where the two disagree.
|
||||
|
||||
**Status:** data model, the full REST API including both creation paths (web and `POST
|
||||
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
|
||||
failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# A commander is a directory, not a session
|
||||
|
||||
**Status: phase 1 built 2026-08-24** — schema, directory creation, `CLAUDE.md` at creation, the inverted
|
||||
`POST /commanders` → start-turn → point-at-transcript order, `resolveChatCwd` resolving server-side, and the
|
||||
frontend composer split (`NewCommanderSetup` → `ActiveNewCommander`) in `src/servers/api/command-hq/` and
|
||||
`src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx`. `bunx tsgo`/`./node_modules/.bin/tsgo`
|
||||
clean, suite green. **`bun db:push` has not been run** — the owner reviews and runs the database work
|
||||
separately; see the schema section below for what changed and why nothing here is a migration. Phase 2
|
||||
(a commander becomes a git repository) is still not built.
|
||||
|
||||
## What is wrong now
|
||||
|
||||
A commander's identity is borrowed. `command_hq_sessions.session_id` is Claude's **transcript id**, minted
|
||||
by the CLI as the first turn begins — verified: the id on the live row has a transcript file under
|
||||
`~/.claude/projects/`. Everything about a commander hangs off that one borrowed value, and three problems
|
||||
fall out of it.
|
||||
|
||||
**An ordering problem.** `POST /command-hq/commanders` takes a `sessionId` the caller already holds and
|
||||
validates the transcript exists, so a chat session is created first and filed as a commander second. But
|
||||
the working directory is fixed when the session spawns — before the id exists. There is no point in the
|
||||
current flow where a commander can be started *in a directory named after itself*.
|
||||
|
||||
**A first-turn interlock that is a side effect.** `commanderBlock` compiles the paths a commander must
|
||||
record itself at into every turn, and on the first turn they cannot be resolved, so the block is empty.
|
||||
The commander skill turns that into a rule: *"if you have not been given paths, you are on your first
|
||||
turn."* It works, and it is a signal derived from a failure rather than something anybody said. That
|
||||
block's own comment explains the awkwardness — it rides `effectivePrompt` rather than the session
|
||||
preamble, because the preamble is built once and the first turn has no id to build it from.
|
||||
|
||||
**Losing the conversation loses the commander.** State lives in the transcript and in a second tree,
|
||||
`commander-state/<transcript id>/`. A session that dies takes its commander with it.
|
||||
|
||||
## The design
|
||||
|
||||
**The directory IS the commander. The session is how you talk to it.**
|
||||
|
||||
```
|
||||
$DATA_PATH/<email>/commanders/<commander id>/
|
||||
CLAUDE.md written at creation, extended by the commander as it learns the job
|
||||
spawned session ids of the agents its loop started
|
||||
<whatever it writes> scripts, procedures, its record of what it has handled
|
||||
```
|
||||
|
||||
The id is one the **platform** owns, generated when the commander is created. Nothing waits on the CLI.
|
||||
|
||||
The flow becomes linear:
|
||||
|
||||
1. The user gives a name and a reference path, and clicks Start.
|
||||
2. The platform creates the row and the directory.
|
||||
3. It writes `CLAUDE.md` into the directory: the name, the reference path, and the standing guidance
|
||||
`commanderBlock` currently injects.
|
||||
4. It spawns the session with `cwd` set to that directory.
|
||||
5. The transcript id arrives later and is recorded **on the row**, as a pointer to the current
|
||||
conversation.
|
||||
|
||||
**The API does not change.** Same endpoints, same shape from the client's side. What changes is the server
|
||||
being able to do things in an order that works.
|
||||
|
||||
## What this buys
|
||||
|
||||
**`CLAUDE.md` needs no mechanism.** Claude Code reads one from the working directory at session start. So
|
||||
the living document is free: the commander edits its own file, and the next turn has it. No injection, no
|
||||
per-turn cost, no block riding `effectivePrompt`.
|
||||
|
||||
**A commander survives its conversation.** Everything it knows is on disk. A session that dies can be
|
||||
replaced by a fresh one in the same directory, and the commander carries on with its scripts, its
|
||||
procedures and its record of what it has already handled. Today that is a total loss.
|
||||
|
||||
**One directory instead of two.** `commander-state/<id>/` collapses in. Deleting a commander becomes
|
||||
deleting a directory, which is also the only way to be sure nothing is left behind.
|
||||
|
||||
**The first turn can be stated rather than inferred.** With the file written before the session exists,
|
||||
the platform can simply say which turn it is on. The interlock stops being a trick.
|
||||
|
||||
## The reference path
|
||||
|
||||
**What was the working directory becomes a reference** — what the commander operates ON, not where it
|
||||
lives. One is collected in the start dialog, because a person can name one thing and asking for a list up
|
||||
front gets a worse answer than asking later.
|
||||
|
||||
**More can be added in conversation.** A voice-memos commander watches one folder; a life manager might
|
||||
watch memos, scans and a mailbox. So this is a list on the row, seeded with one, and the commander writes
|
||||
additions into its own `CLAUDE.md` as they are agreed.
|
||||
|
||||
## The two tiers, and why a commander should get cheaper
|
||||
|
||||
This is the point of the whole design, and it generalises past any particular commander.
|
||||
|
||||
**The script gathers. The agent judges.**
|
||||
|
||||
A commander has two kinds of work and they have opposite economics. A script runs per event — every email,
|
||||
every voice memo — and costs nothing, so there is no ceiling on volume. An agent runs per batch, on
|
||||
material a script already assembled, and costs a turn every time it wakes. So the rule for building any
|
||||
commander is: push as much as possible down into the script, and let the agent see only what actually
|
||||
needs judgement.
|
||||
|
||||
Detecting a one-time code in an email is a script. Reading fifty messages and deciding which of them the
|
||||
owner would want to know about is an agent. A watcher that transcribes a recording is a script; deciding
|
||||
what a week of transcripts means is an agent.
|
||||
|
||||
**And the tiers are not fixed, which is the part that matters.**
|
||||
|
||||
Because the commander owns its own scripts — they are files in its directory, and it can edit them — every
|
||||
correction moves work downward. The owner says "this one was missed, it had eight digits rather than six",
|
||||
and a decision that required judgement becomes a rule that does not. The agent tier gets thinner; the free
|
||||
tier gets better.
|
||||
|
||||
**A commander that runs for six months should cost less per event than it did in week one, not more.**
|
||||
|
||||
That is the opposite of how automation usually ages, and it is a direct consequence of two decisions made
|
||||
here rather than a hope: the agent owns its scripts, and the corrections arrive through dialogue rather
|
||||
than through a ticket to somebody else. Nothing improves a system it cannot edit.
|
||||
|
||||
Two things this requires, and both are cheap if done from the start:
|
||||
|
||||
- **The script's verdict must be reproducible.** Not logged — RE-RUN. The input is still in the mailbox or
|
||||
the folder, and the script is deterministic, so diagnosing a miss is running it again and watching. A
|
||||
log would be a second copy of something already on disk.
|
||||
- **A change is checked against what came before.** The corpus already exists — the last few hundred
|
||||
messages, the transcripts so far. Re-run the old script and the new one over it and compare; anything
|
||||
whose verdict changed is either the bug just fixed or one just introduced. Without that, the fix for
|
||||
eight-digit codes quietly starts flagging every order confirmation, unattended, at three in the morning.
|
||||
|
||||
`capabilities/skills/commander/SKILL.md` should teach this. Somebody building the fourth commander needs
|
||||
the rule more than they need the worked example.
|
||||
|
||||
## What the directory holds: definition versus state
|
||||
|
||||
Two kinds of thing live in there and they must not mix, because one of them travels and the other must
|
||||
never.
|
||||
|
||||
```
|
||||
$DATA_PATH/<email>/commanders/<commander id>/
|
||||
CLAUDE.md DEFINITION — the living document, extended as the job is agreed
|
||||
parameters.* DEFINITION — the things a fork has to change. Paths, cadences, thresholds
|
||||
procedures/ DEFINITION — what a spawned agent reads at fire time
|
||||
scripts/ DEFINITION — the loops
|
||||
state/ INSTANCE — what it has handled, what it spawned, its loop's pid
|
||||
```
|
||||
|
||||
**Separate from the first commit, not sorted out later.** A shareable bundle is "everything except
|
||||
`state/`" only if that boundary is structural; mixed together, extracting one means picking files apart by
|
||||
hand and getting it wrong once. Same reasoning as plugin capabilities being read in place rather than
|
||||
copied — a directory boundary beats a convention, because a convention is what a tired person violates.
|
||||
|
||||
The failure it prevents is specific: a recipient's commander starting up believing it has already handled
|
||||
your recordings, and silently doing nothing.
|
||||
|
||||
**Parameters are separate from procedure, or forking means editing code.** With `~/Documents/VoiceMemos`
|
||||
baked into a script, a fork is a debugging session. Declared in one place, a fork is a conversation — the
|
||||
new owner is asked where their memos land and the commander rewrites its own file. That difference is the
|
||||
whole point of the thing being an agent rather than a template.
|
||||
|
||||
## Phase 2 — a commander becomes a git repository
|
||||
|
||||
**Not now.** Recorded because the phase 1 layout has to leave room for it, and it costs nothing today to
|
||||
leave that room.
|
||||
|
||||
**There is no button.** The owner asks the commander, in conversation, to make itself shareable, and it
|
||||
does — `git init`, a `.gitignore` that excludes `state/`, a first commit. That is the same shape as
|
||||
everything else here: the commander maintains its own directory, and the platform does not reach in.
|
||||
|
||||
**What it needs to know goes in the commander SKILL, not an affordance.** The layout, what is tracked,
|
||||
what is ignored and how credentials are handled is reference material — read on the one day somebody asks
|
||||
for it. An affordance's body is charged to every turn of every commander forever, and this would earn that
|
||||
cost approximately never. `capabilities/skills/commander/SKILL.md` gains a section.
|
||||
|
||||
**A commander never holds a service's connection details, and that is a boundary rather than a
|
||||
precaution.** The Whisper URL is not the commander's business: it knows the platform can transcribe and
|
||||
asks the platform, which holds the connection. So there is usually nothing sensitive in a commander to
|
||||
leak, and the sharing story is simple for a reason rather than by luck.
|
||||
|
||||
Where a commander genuinely does need a secret of its own, the same rule applies for the obvious reason: a
|
||||
credential is definition-shaped — it looks like configuration — so excluding `state/` does not protect it,
|
||||
and a shared repository is exactly the artefact where one gets published and stays published. Name what
|
||||
must be configured; let the platform hold the value.
|
||||
|
||||
**Whether the platform is actually configured is not this system's problem.** A commander depending on
|
||||
transcription can be armed on a server where nobody set up a Whisper endpoint, and it would produce
|
||||
nothing. That is a system administration mistake, and the answer is already in the commander skill rather
|
||||
than in new machinery: **do the first run by hand, then arm.** A first run against something unconfigured
|
||||
fails in front of the person doing it. The same rule catches the shared case — somebody forking this onto
|
||||
their own machine finds out on their first run instead of at 3am.
|
||||
|
||||
The forking story then reads: clone, open a conversation, answer where your memos land and what you want
|
||||
done with them, and the commander rewrites its own parameters and arms.
|
||||
|
||||
## The consequence for the skill
|
||||
|
||||
`capabilities/skills/commander/SKILL.md` says turn one is orientation — *"read where you actually are
|
||||
before you ask anything"* — and that assumes `cwd` is the thing worth exploring. Under this design the
|
||||
commander's own directory is nearly empty on turn one and the interesting thing is somewhere else.
|
||||
|
||||
That guidance has to change with the code, or every new commander will orient on an empty folder and ask
|
||||
a worse question than it does today.
|
||||
|
||||
## The schema change, and why there is no migration
|
||||
|
||||
`session_id` stops being the commander's identity and becomes a nullable pointer to its current
|
||||
conversation. The row gains its own id, its directory, and its reference paths.
|
||||
|
||||
**Nothing is migrated.** The one commander on this machine is deleted and re-armed from scratch on the new
|
||||
system — the owner's call, made on 2026-08-24, and the right one: it is unarmed and has never fired, so
|
||||
migrating it would mean writing and testing a path that runs exactly once and then never again. Re-arming
|
||||
also exercises the flow this design exists to fix, which a migration would route around.
|
||||
|
||||
That decision has a shelf life. It is free while one commander exists and unarmed; it stops being free the
|
||||
moment somebody is relying on one. Build this before that happens, or write the migration.
|
||||
|
||||
## What this does not decide
|
||||
|
||||
**Whether a commander's loops are scripted or agentic.** Both, in practice — a watcher that transcribes
|
||||
does not need judgement and should not spend a turn per file, while a job that reads transcripts and
|
||||
decides what to do with each of them is agentic by nature. The directory holds the procedure either way.
|
||||
|
||||
**How a commander learns what the platform can do.** It reads the generated platform skill and, for a
|
||||
plugin, that plugin's own skill. That is a separate gap: a plugin must ship a skill documenting its routes
|
||||
or a commander can see it is installed and still not know how to call it.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Filesystem quotas — the prerequisite for per-user and per-team storage limits
|
||||
|
||||
**Status: planned, not done. Nothing in the platform reads or writes a quota yet.**
|
||||
|
||||
This is the host-side half. It makes limits *enforceable*; it does not create any. The platform side —
|
||||
the owner declaring defaults and maxima, and provisioning applying them — is `docs/roles-and-teams.md`
|
||||
→ "Quotas", and it is not built either. Do this first, because a policy nothing enforces is worse than
|
||||
no policy: it reads as a limit and is not one.
|
||||
|
||||
## Why the kernel and not the platform
|
||||
|
||||
Members hold `terminal` at `write` and have their own Linux accounts. A quota the platform tracks by
|
||||
counting bytes through the file browser is decorative — a member opens a terminal, or connects over SSH
|
||||
with their own key, and writes past it without the platform seeing the write.
|
||||
|
||||
Only the filesystem can refuse. So this is a provisioning feature, not an accounting one.
|
||||
|
||||
## Where it matters
|
||||
|
||||
**`edge-pertento`.** It is the first real multi-user deployment and the only host that will hold accounts
|
||||
that are not the owner's. It already has one account, which is the argument for doing this soon rather
|
||||
than carefully later: moving a home directory is easy once and tedious per person.
|
||||
|
||||
`officer-dev` is a development machine and will not be multi-user. `alpha` is the owner's own. Neither
|
||||
needs this, and neither can do it without the same downtime, so there is no reason to.
|
||||
|
||||
## What to check BEFORE booting into rescue
|
||||
|
||||
Run these on the live machine. They decide whether the procedure below is the right one.
|
||||
|
||||
```bash
|
||||
findmnt -no FSTYPE,SOURCE,TARGET / # expect ext4, and note the real device
|
||||
findmnt -no OPTIONS / | tr ',' '\n' | grep -i quota # expect nothing — that is why we are here
|
||||
sudo tune2fs -l "$(findmnt -no SOURCE /)" | grep -iE 'features|Inode size'
|
||||
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT
|
||||
df -h /home
|
||||
```
|
||||
|
||||
Three things decide the plan:
|
||||
|
||||
1. **Is `/home` its own filesystem, or part of `/`?** On a stock Hetzner image it is part of `/`, so the
|
||||
root filesystem is what gains the feature.
|
||||
2. **Inode size.** Only matters if you use project quotas — they need 256 bytes and cannot be enabled on a
|
||||
128-byte filesystem without recreating it. The plan below avoids project quotas for exactly this reason.
|
||||
3. **Is the device under LVM?** `alpha` is; `officer-dev` is not. It changes only the device path.
|
||||
|
||||
## The plan: user quotas and group quotas, not project quotas
|
||||
|
||||
Two mount options cover both cases and neither needs a feature the filesystem might lack:
|
||||
|
||||
- **`usrquota`** — a limit per Linux user. This is the per-account home directory limit.
|
||||
- **`grpquota`** — a limit per Linux group. **This is how a team's shared folder gets a limit**, because a
|
||||
team is already a Linux group (see `docs/roles-and-teams.md` — the group is what keeps one team out of
|
||||
another's folder, not the permission grant).
|
||||
|
||||
Project quotas (`prjquota`) would let a *directory* carry a limit independent of ownership. That is more
|
||||
flexible and it is not needed: every case here is "this user" or "this team", both of which are already
|
||||
identities the kernel knows. Skipping it also skips the inode-size problem entirely.
|
||||
|
||||
**For the group quota to work, the team folder must be `setgid`** (`chmod g+s`), so files created inside it
|
||||
inherit the team's group and are charged to it. Without that, a member's files are charged to the member's
|
||||
own primary group and the team limit counts nothing. This is the single easiest thing to get wrong here.
|
||||
|
||||
## Procedure
|
||||
|
||||
Downtime is roughly ten minutes, most of it reboots. The owner has said downtime is acceptable.
|
||||
|
||||
**1. Install the tools, on the live machine.**
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y quota
|
||||
```
|
||||
|
||||
**2. Take note of the root device**, from the pre-flight above — `/dev/sda1` on a stock image,
|
||||
`/dev/mapper/<vg>-<lv>` under LVM. Every command below uses it.
|
||||
|
||||
**3. Boot into Hetzner rescue** from the Cloud console, and do not mount the filesystem.
|
||||
|
||||
**4. Check it, then add the feature.** `tune2fs` refuses on a filesystem that is not clean, and the check
|
||||
is worth running anyway before changing metadata:
|
||||
|
||||
```bash
|
||||
e2fsck -f /dev/sda1
|
||||
tune2fs -O quota /dev/sda1
|
||||
tune2fs -l /dev/sda1 | grep -i features # 'quota' must now appear
|
||||
```
|
||||
|
||||
`quota` here is the ext4 *journaled quota* feature. It is what lets quotas come up with the filesystem
|
||||
rather than needing a `quotacheck` scan at every boot, which on 320 GB is not quick.
|
||||
|
||||
**5. Add the mount options.** Back on the normal system — or by mounting the root filesystem from rescue
|
||||
and editing it there — the `/` line in `/etc/fstab` gains `usrquota,grpquota`:
|
||||
|
||||
```
|
||||
UUID=<unchanged> / ext4 defaults,usrquota,grpquota 0 1
|
||||
```
|
||||
|
||||
**6. Reboot into the normal system and confirm.**
|
||||
|
||||
```bash
|
||||
findmnt -no OPTIONS / | tr ',' '\n' | grep quota # usrquota, grpquota
|
||||
sudo quotaon -p / # both should report "is on"
|
||||
sudo repquota -s / # a table, not an error
|
||||
```
|
||||
|
||||
## Verify it actually refuses
|
||||
|
||||
Enabling quotas and setting none is the failure that looks like success. Prove enforcement once, on a
|
||||
throwaway account, before trusting it:
|
||||
|
||||
```bash
|
||||
sudo useradd -m quotaprobe
|
||||
sudo setquota -u quotaprobe 10240 10240 0 0 / # 10 MB soft and hard
|
||||
sudo -u quotaprobe dd if=/dev/zero of=/home/quotaprobe/fill bs=1M count=50
|
||||
# expect: "Disk quota exceeded" and a file of about 10 MB, NOT 50
|
||||
sudo userdel -r quotaprobe
|
||||
```
|
||||
|
||||
If that writes 50 MB, quotas are not on, whatever the mount options say.
|
||||
|
||||
## What this does not do
|
||||
|
||||
- **It sets no limits.** Every existing account is unlimited until something assigns a quota. That is the
|
||||
correct intermediate state: the machine gains the ability, nobody's behaviour changes.
|
||||
- **It does not touch the platform.** `provision-os.ts` does not call `setquota` yet, and there is nowhere
|
||||
for the owner to declare a default. That is the other half.
|
||||
- **It does not apply to the team folders**, because there are no team folders yet. When they arrive they
|
||||
must be `setgid` or their group quota counts nothing.
|
||||
|
||||
## When the platform half is built
|
||||
|
||||
`POST /users/:id/provision-linux` already exists as a retry path and is the natural home for this: it
|
||||
becomes "re-apply current policy to this account", covering both a new account and a changed limit.
|
||||
|
||||
**On a host where quotas are not enabled, the platform must say so** — "policy recorded, not enforceable on
|
||||
this host" — rather than storing numbers that do nothing. Same rule as the plugin dependency check: refuse
|
||||
or report, never silently no-op. `quotaon -p` is the check, and it is cheap enough to run per request.
|
||||
@@ -0,0 +1,92 @@
|
||||
# A plugin's capabilities are LOADED, never copied
|
||||
|
||||
**Status: designed, not built.** What exists today is the copying this replaces.
|
||||
|
||||
## The problem, as it is on disk right now
|
||||
|
||||
`capabilities/` is a git repository. The intent is that anyone can fork it, keep their own tasks, skills
|
||||
and affordances in it, and pull core's on top. That only works if the directory contains exactly two
|
||||
things: what core ships, and what the owner wrote.
|
||||
|
||||
It does not. Four publishers copy a plugin's files into it at install time — assets, affordances,
|
||||
notification catalogues and skills — so a fork accumulates files it did not author. On this machine today
|
||||
`skills/email-db/` and `extensions/` sit untracked in that repository, published there by plugins. Commit
|
||||
them and the fork carries another plugin's work; leave them and `git status` is permanently noisy. There
|
||||
is no third answer, because nothing on disk says which files came from where.
|
||||
|
||||
Copying is also wrong in a second way that has nothing to do with git. **A copy goes stale.**
|
||||
`plugins:update` pulls new plugin code and the copied capabilities keep describing the old version until
|
||||
something republishes them. And a copy survives `plugins:disable`, so a disabled plugin's skill stays in
|
||||
the agent's index — the plugin is off and the agent still believes it is there.
|
||||
|
||||
## The design
|
||||
|
||||
**A plugin keeps its capabilities in its own directory, and the platform reads them where they are.**
|
||||
|
||||
```
|
||||
capabilities/ the owner's fork: core's, plus their own. Nothing else, ever.
|
||||
tasks/ skills/ affordances/ notifications/
|
||||
|
||||
plugins/<name>/ the plugin's own repository, cloned on install
|
||||
tasks/ skills/ affordances/ notifications/
|
||||
```
|
||||
|
||||
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
|
||||
which is the whole reason this beats scoping the paths. A namespace makes provenance *readable*; separate
|
||||
directories make it *structural*.
|
||||
|
||||
Four consequences, and each of them is a bug that stops existing:
|
||||
|
||||
- **A fork stays clean.** `capabilities/` contains only what its owner is responsible for, so pulling from
|
||||
upstream merges without touching anything a plugin put there.
|
||||
- **Capabilities update with the plugin.** `plugins:update` is a `git pull` in the plugin's directory, and
|
||||
the capabilities are in that directory. There is nothing left to go stale.
|
||||
- **Disable actually disables.** The load list is built from plugins that are installed AND enabled, so a
|
||||
disabled plugin contributes nothing. Today its copies remain.
|
||||
- **Uninstall needs no cleanup step.** The directory goes and its capabilities go with it. No list of
|
||||
filenames to be careful with, and no way to delete the owner's own work by getting that list wrong.
|
||||
|
||||
## What has to change
|
||||
|
||||
**The loaders take a list of roots.** `loadSkills(root)` and `loadAffordances(root)` already take one with
|
||||
a default, so this is a small change rather than a rewrite. Notifications the same.
|
||||
|
||||
**The list is resolved per session, not at module load.** `affordanceBlock()` and `skillsBlock()` are
|
||||
called once per session in `sidecar/claude/claude-manager.ts` — deliberately, so a capability written an
|
||||
hour ago is offered on the next session. That is also why they can afford to ask the database which
|
||||
plugins are installed: once per session, not once per turn.
|
||||
|
||||
**Tasks are the awkward one, because that store is read-write.** `api/tasks/task-files.ts` creates, edits
|
||||
and deletes as well as reads. Reading must span every source; writing must only ever touch the owner's
|
||||
directory. A plugin's task is read-only by nature and the UI has to know, or it will offer an Edit that
|
||||
silently writes into a directory the next `plugins:update` overwrites.
|
||||
|
||||
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
|
||||
search every source rather than assuming one root.
|
||||
|
||||
**The four publishers are deleted**, along with their install and uninstall wiring:
|
||||
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
|
||||
last was written on 2026-08-23 and is superseded before it ever shipped.
|
||||
|
||||
`app-store/assets` is a different case and stays. Those are images served over HTTP from a
|
||||
platform-managed path, not capabilities read from disk, and the copy is what makes them reachable.
|
||||
|
||||
## The migration
|
||||
|
||||
One-off, and it is deletion rather than movement. The copies already in `capabilities/` have nothing to
|
||||
remove them once the publishers are gone — `skills/email-db/` and `extensions/` on this machine, and
|
||||
whatever the equivalent is per host. They are copies of files that still exist in each plugin's own
|
||||
directory, so deleting them loses nothing.
|
||||
|
||||
Check each host rather than assuming this one is representative.
|
||||
|
||||
## What this does not decide
|
||||
|
||||
**Where the owner's own capabilities live inside their fork.** Flat at the top of each type directory is
|
||||
what happens today and needs no change. Scoping them is a separate question and this design does not
|
||||
force it either way.
|
||||
|
||||
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
|
||||
task with the same directory name, and the loader has to break that tie — first-come, as the items store
|
||||
has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin
|
||||
ships a task; not before.
|
||||
+76
-13
@@ -179,14 +179,21 @@ D8's warning in front of whoever writes the first script.
|
||||
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
|
||||
4. Teams UI, and the account screen showing which teams a user is in and as what.
|
||||
|
||||
### Phase 5 — the thing that actually unblocks all of it
|
||||
### Phase 5 — account creation — **DONE, and it was done before this phase was written**
|
||||
|
||||
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
|
||||
for accounts that cannot yet exist.
|
||||
`POST /api/users` is owner-gated and live, with a form under Settings → User Management: same password
|
||||
validator as bootstrap, a role checked against the `roles` table, `Super Admin` refused outright, an
|
||||
optional inbound SSH key, and Linux provisioning with `POST /users/:id/provision-linux` to retry it.
|
||||
Alongside it: role change, password reset, delete.
|
||||
|
||||
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
|
||||
find nothing to test it with. **It may be worth doing first.**
|
||||
It shipped in `69a31051`. This section spent days telling readers it did not exist and sequencing work
|
||||
around building it — and on 2026-08-23 it produced a concrete cost, when a recommendation to "do phase 5
|
||||
first" was made from this text and withdrawn only because someone opened `api/users/` to write the spec.
|
||||
|
||||
The general lesson, since it is the second time this exact shape has bitten: `bun run docs:check` compares
|
||||
numbers, so counts stay honest while prose rots quietly. **A phase marked as not built is worth verifying
|
||||
against the tree before it is planned around**, not because the plan was careless but because a plan is
|
||||
written once and the code moves every day.
|
||||
|
||||
---
|
||||
|
||||
@@ -207,13 +214,69 @@ find nothing to test it with. **It may be worth doing first.**
|
||||
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
|
||||
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
|
||||
|
||||
## Teams — decided 2026-08-23
|
||||
|
||||
The four open questions were answered by the owner. Recorded here so phase 4 is implementation rather
|
||||
than another round of design.
|
||||
|
||||
**A team owns a shared folder.** Not only an authorization grouping — storage is the part that was
|
||||
actually wanted. Team *dashboards* are a probable later addition and were explicitly deferred, because
|
||||
they need a permission shape nothing here has: people who can edit a dashboard's STRUCTURE versus people
|
||||
who only see what it renders. Every grant today is read-or-write over a whole capability, so that is a new
|
||||
model rather than a new level. Build the folder; leave room for the dashboards.
|
||||
|
||||
**Any Admin may create a team**, not the owner alone. Teams are self-service for people already trusted to
|
||||
administer the platform. This is what makes the quota section below necessary rather than optional: an
|
||||
Admin creating a team is an Admin allocating disk.
|
||||
|
||||
**A team may grant a capability the member's own role lacks** — the union, per D6 — with one rule on top:
|
||||
**whoever creates the team can only attach capabilities they hold themselves.** Escalation is capped at the
|
||||
creator's own reach rather than the whole registry. `execution` and `admin` stay ungrantable regardless,
|
||||
and `confined` still resolves to nothing for an account with no Linux user.
|
||||
|
||||
**Deleting a role is refused while anyone holds it, in a team or globally.** The same rule D4 already
|
||||
applies to global holders, extended rather than special-cased; the refusal should name the teams.
|
||||
|
||||
### Two things phase 4 must not get wrong
|
||||
|
||||
**The shared folder is enforced by the kernel, not by the grant.** Every role already holds `files` at
|
||||
`write` — they are the seeded `confined` defaults — so the capability is not what keeps one team out of
|
||||
another's folder. A Linux group per team is. The grant says whether someone can use the files surface at
|
||||
all; group membership says which folders they reach. Both are required and they are not substitutes.
|
||||
|
||||
**The grant cache is keyed on ROLE today**, deliberately: "at most one entry per role, and a new account
|
||||
needs no warm-up" (`permissions/authorize.ts`). Teams end that — effective permissions become role plus
|
||||
memberships, which is per-user. That property is easy to lose without noticing and awkward to recover, so
|
||||
it should be a decision in the implementation rather than a side effect.
|
||||
|
||||
## Quotas — the model, and the prerequisite nobody can skip
|
||||
|
||||
The owner declares platform-level defaults and maxima: per user, and per team. Admins allocate inside
|
||||
them. A change re-applies at the Linux level through `POST /users/:id/provision-linux`, which already
|
||||
exists as a retry path and becomes "re-apply current policy to this account".
|
||||
|
||||
**Enforcement is the kernel's job, not the platform's.** Members hold `terminal` at `write` and have their
|
||||
own Linux accounts, so a quota the platform tracks by counting bytes through the file browser is
|
||||
decorative — a member opens a terminal or SSHes in and writes past it. Only filesystem quotas bind.
|
||||
|
||||
**Which means a filesystem that can carry them.** Checked on 2026-08-23: neither `officer-dev` nor `alpha`
|
||||
can. Both are ext4 with homes on the root filesystem, no `usrquota`/`grpquota`/`prjquota` mount options,
|
||||
and neither has the ext4 `quota` or `project` feature. Enabling `project` needs `tune2fs -O project` on an
|
||||
UNMOUNTED filesystem, and `/` cannot be unmounted on a running server — so it is a rescue boot, not a
|
||||
config change.
|
||||
|
||||
The cheap moment is when a host is built, by giving the Linux homes their own filesystem with quota
|
||||
features from the start. Retrofitting a live host is expensive and gets more so with every account on it.
|
||||
|
||||
**Where this actually matters is `edge-pertento`** — the first real multi-user deployment, and the only
|
||||
one that will have accounts that are not the owner's. `officer-dev` is a development machine and will not
|
||||
be multi-user; alpha is the owner's own. Check edge-pertento's disk before it gains more accounts.
|
||||
|
||||
**On a host that cannot enforce, the platform says so** — "policy recorded, not enforceable here" — rather
|
||||
than storing numbers that do nothing. A feature that silently does nothing is worse than one that admits
|
||||
it cannot.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
|
||||
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
|
||||
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
|
||||
or a self-service one.
|
||||
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
|
||||
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
|
||||
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
|
||||
None outstanding for phase 4. The four that were here are answered above.
|
||||
holds globally; the same question exists per team and is not answered here.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.05",
|
||||
"version": "2026.08.23.06",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
+31
-17
@@ -7,31 +7,45 @@
|
||||
*
|
||||
* ── Why these four are `isSystem` ──
|
||||
*
|
||||
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
|
||||
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
|
||||
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
|
||||
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
|
||||
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
|
||||
* Each is named by name somewhere in the code: this file matches `'Developer'`, `'Admin'` and `'Member'`
|
||||
* by name on every run, and `bootstrap.ts` writes `'Super Admin'`. Renaming or deleting one of those is not
|
||||
* a data change, it is a code change that has not happened — so the flag exists for the delete and rename
|
||||
* paths to refuse on, rather than for the failure to turn up later as a missing database role or an
|
||||
* account that cannot log in.
|
||||
*
|
||||
* ── `description` versus `grantsPostgres`/`grantsDocker` ──
|
||||
*
|
||||
* `seedRole` treats these two kinds of column differently, and it matters here specifically because this
|
||||
* is the one caller that seeds `isSystem: true` rows. `description` is written once, on insert, and never
|
||||
* touched again — an edit made on one server must not be reverted by the next deploy. `grantsPostgres` and
|
||||
* `grantsDocker` ARE re-asserted every run for a system role, because they are not the operator's data,
|
||||
* they are what "Developer" MEANS — the same standing as the name itself. `rolePermitsDevTools()` used to
|
||||
* be that declaration, hardcoded as `role === 'Developer'`; this table is where it lives now, and this
|
||||
* SEED array is the only place either flag is set for these four roles. See `seedRole`'s own comment in
|
||||
* `officer_db/src/roles/queries.ts` for the mechanics.
|
||||
*
|
||||
* ── What this deliberately does NOT do ──
|
||||
*
|
||||
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
|
||||
* and adds the constraint in the same run, so every existing account violates it — verified against a
|
||||
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
|
||||
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
|
||||
*
|
||||
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
|
||||
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
|
||||
* unattended.
|
||||
*
|
||||
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
|
||||
* Never overwrite `description`, as above. And never touch a CUSTOM role's grants — those are set through
|
||||
* `PATCH /api/roles/:name/grants` (`setRoleGrants`), which itself refuses to touch a system role for the
|
||||
* mirror-image reason: writing there would look like it worked and then silently revert on the next run of
|
||||
* this script.
|
||||
*/
|
||||
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||
|
||||
const SEED: { name: string; description: string }[] = [
|
||||
type SeedRoleEntry = { name: string; description: string; grantsPostgres?: boolean; grantsDocker?: boolean };
|
||||
|
||||
// Match today's behaviour exactly: only Developer passed `rolePermitsDevTools` before this table existed,
|
||||
// so Developer is the only role seeded with either flag true.
|
||||
const SEED: SeedRoleEntry[] = [
|
||||
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
|
||||
{
|
||||
name: 'Developer',
|
||||
description: 'A member who is also trusted with developer tooling on the machine',
|
||||
grantsPostgres: true,
|
||||
grantsDocker: true,
|
||||
},
|
||||
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||
];
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import appEnGB from '../locales/en-GB.json';
|
||||
import appEsES from '../locales/es-ES.json';
|
||||
import appFrFR from '../locales/fr-FR.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
import appSvSE from '../locales/sv-SE.json';
|
||||
|
||||
/**
|
||||
* The three per-workspace catalogues, merged into one map per shipped locale (D6): `app.*` from
|
||||
@@ -33,6 +34,7 @@ const appCatalogs: TranslationMap = {
|
||||
'es-ES': appEsES,
|
||||
'de-DE': appDeDE,
|
||||
'fr-FR': appFrFR,
|
||||
'sv-SE': appSvSE,
|
||||
};
|
||||
|
||||
export const catalogues: TranslationMap = Object.fromEntries(
|
||||
|
||||
@@ -19,7 +19,7 @@ const autonym = (code: string): string => {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT', 'fr-FR', 'de-DE', 'es-ES'].map((code) => ({
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT', 'fr-FR', 'de-DE', 'es-ES', 'sv-SE'].map((code) => ({
|
||||
code,
|
||||
label: autonym(code),
|
||||
}));
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('officer-web (app.*) locale catalogs', () => {
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Spara",
|
||||
"saving": "Sparar...",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "E-post",
|
||||
"username": "Användarnamn",
|
||||
"name": "Namn",
|
||||
"namePlaceholder": "Ditt namn",
|
||||
"updated": "Profilen uppdaterad",
|
||||
"updateFailed": "Det gick inte att uppdatera profilen",
|
||||
"selectImage": "Välj en bildfil",
|
||||
"imageTooLarge": "Bilden måste vara mindre än 384 kB"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "En API-nyckel loggar in som dig, utan lösenord och utan att gå ut. En nyckel fungerar överallt — en nyckel är inte knuten till en app. Att ge varje enhet sin egen är bara så att du kan stänga av en utan att röra resten. Skicka den som",
|
||||
"loading": "Läser in nycklar…",
|
||||
"mintedTitle": "Nyckel för ”{{name}}” — visas en gång",
|
||||
"savedIt": "Jag har sparat den",
|
||||
"copyKey": "Kopiera nyckel",
|
||||
"copied": "Nyckel kopierad",
|
||||
"copyFailed": "Det gick inte att kopiera — markera och kopiera manuellt",
|
||||
"newKey": "Ny nyckel",
|
||||
"namePlaceholder": "iPhone, Musik-appen, laptop-CLI…",
|
||||
"create": "Skapa",
|
||||
"createFailed": "Det gick inte att skapa nyckeln",
|
||||
"noneYet": "Inga nycklar än.",
|
||||
"noneInUse": "Inga nycklar används.",
|
||||
"revoked": "återkallad",
|
||||
"expired": "utgången",
|
||||
"lastUsed": "senast använd {{date}}",
|
||||
"neverUsed": "aldrig använd",
|
||||
"forget": "Ta bort denna post",
|
||||
"forgetFailed": "Det gick inte att ta bort den",
|
||||
"revoke": "Återkalla",
|
||||
"revokeDone": "Återkallad — allt som använder den nyckeln loggas ut",
|
||||
"revokeFailed": "Det gick inte att återkalla",
|
||||
"hideDead": "Dölj återkallade och utgångna",
|
||||
"showDead": {
|
||||
"one": "Visa {{count}} återkallad eller utgången nyckel",
|
||||
"other": "Visa {{count}} återkallade eller utgångna nycklar"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Röst",
|
||||
"refresh": "Uppdatera röster",
|
||||
"serverDefault": "Serverstandard",
|
||||
"hint": "Välj en röst för text-till-tal. Behåll serverstandard för att använda rösten administratören har konfigurerat.",
|
||||
"stop": "Stoppa",
|
||||
"listen": "Lyssna"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Synlig",
|
||||
"hidden": "Dold",
|
||||
"dragToShow": "Dra objekt hit för att visa i dockan",
|
||||
"allVisible": "Alla objekt synliga",
|
||||
"reset": "Återställ till standard"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Språk du talar",
|
||||
"addLanguage": "Lägg till ett språk...",
|
||||
"defaultLabel": "Standardspråk",
|
||||
"defaultHint": "Används för lokalisering av gränssnittet.",
|
||||
"translateToLabel": "Översätt till",
|
||||
"translateToHint": "Målspråk vid översättning av innehåll du inte talar."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Nuvarande lösenord",
|
||||
"currentPasswordPlaceholder": "Nuvarande lösenord",
|
||||
"newPasswordLabel": "Nytt lösenord",
|
||||
"newPasswordPlaceholder": "Nytt lösenord",
|
||||
"confirmPasswordLabel": "Bekräfta nytt lösenord",
|
||||
"confirmPasswordPlaceholder": "Bekräfta nytt lösenord",
|
||||
"changeButton": "Byt lösenord",
|
||||
"changingButton": "Byter...",
|
||||
"passwordChanged": "Lösenordet ändrat",
|
||||
"passwordChangeFailed": "Det gick inte att byta lösenord"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Standardmodell",
|
||||
"sameAsChat": "Samma som chattstandard",
|
||||
"taskDefaultsSaved": "Standardvärden för uppgifter sparade",
|
||||
"saveFailed": "Det gick inte att spara inställningarna",
|
||||
"defaultChat": "Standardmodell för chatt",
|
||||
"defaultChatHint": "Används när en ny chatt startas från hemskärmen",
|
||||
"defaultProject": "Standardmodell för projekt",
|
||||
"defaultProjectHint": "Används när en ny chatt startas inuti en projektdashboard",
|
||||
"defaultTask": "Standardmodell för uppgifter",
|
||||
"defaultTaskHint": "Används när uppgifter körs från filhanteraren",
|
||||
"aiDefaultsSaved": "Standardvärden för AI-modell sparade"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profil",
|
||||
"hint": "avatar, namn, e-post",
|
||||
"description": "Avatar, namn och e-post"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Byt lösenord",
|
||||
"description": "Uppdatera ditt lösenord"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "API-nycklar",
|
||||
"hint": "nycklar som loggar in som dig",
|
||||
"description": "Nycklar som loggar in som dig, kan återkallas en i taget"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Språk",
|
||||
"hint": "talade och översättning",
|
||||
"description": "Talade, standard och översättning"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Röst",
|
||||
"hint": "text-till-tal",
|
||||
"description": "Inställning för text-till-tal-röst"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Docka",
|
||||
"hint": "välj och ordna om",
|
||||
"description": "Välj och ordna om objekt i dockan"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Mina modeller",
|
||||
"description": "Visa eller dölj modeller för dig själv"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Kanalmodeller",
|
||||
"description": "Modeller som andra konton än ägaren kan nå"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Standardmodeller",
|
||||
"description": "Standardmodeller för chatt, projekt och uppgifter"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Leverantörer",
|
||||
"hint": "körmiljöer",
|
||||
"description": "Fjärran och lokala AI-leverantörer"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Text till tal",
|
||||
"description": "TTS-leverantör och röst"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Tal till text",
|
||||
"description": "Whisper-serverns URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Synmodell för textextrahering"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Bygg om och starta om",
|
||||
"hint": "underhåll",
|
||||
"description": "Läs in ändringar utan en terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "utgående e-post",
|
||||
"description": "Leverantör för e-postleverans"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Klient-ID och hemlighet för Google-API:er"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "API-token för webbskrapningsaktörer"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Kalender- och kontaktsynkronisering",
|
||||
"hint": "applösenord",
|
||||
"description": "Lösenord per enhet för CalDAV-/CardDAV-klienter"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Konton",
|
||||
"hint": "användare",
|
||||
"description": "Vem som har åtkomst, och som vad"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Behörigheter",
|
||||
"description": "Vad varje roll kan nå"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Sök inställningar…",
|
||||
"selectSection": "Välj en sektion",
|
||||
"groups": {
|
||||
"models": "Modeller",
|
||||
"defaults": "Standardvärden",
|
||||
"providers": "Leverantörer",
|
||||
"ai": "AI",
|
||||
"maintenance": "Underhåll",
|
||||
"email": "E-post"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profil",
|
||||
"ai": "AI",
|
||||
"system": "System",
|
||||
"integrations": "Integrationer",
|
||||
"users": "Användare"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Inga modeller tillgängliga. Konfigurera API-nycklar under Leverantörer.",
|
||||
"dragToShow": "Dra modeller hit för att visa",
|
||||
"allVisible": "Alla modeller synliga",
|
||||
"dragToEnable": "Dra modeller hit för att aktivera",
|
||||
"allEnabled": "Alla modeller aktiverade",
|
||||
"visible": "Synlig",
|
||||
"hidden": "Dold",
|
||||
"enabled": "Aktiverad",
|
||||
"disabled": "Inaktiverad",
|
||||
"refresh": "Uppdatera modeller från leverantörer",
|
||||
"refreshed": "Modeller uppdaterade",
|
||||
"refreshFailed": "Det gick inte att uppdatera modellerna"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Företag",
|
||||
"personal": "Personligt"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Läser in…",
|
||||
"provider": "Leverantör",
|
||||
"model": "Modell",
|
||||
"voice": "Röst",
|
||||
"ocr": {
|
||||
"serverUrl": "URL till synmodellsserver",
|
||||
"refreshModels": "Uppdatera modeller",
|
||||
"selectModel": "Välj en modell",
|
||||
"saved": "OCR-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara OCR-inställningarna",
|
||||
"reachable": "Synmodellsservern är nåbar"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL till Whisper-server",
|
||||
"saved": "STT-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara STT-inställningarna",
|
||||
"reachable": "Whisper-servern är nåbar"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI, m.fl.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Uppdatera röster",
|
||||
"selectVoice": "Välj en röst",
|
||||
"saved": "TTS-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara TTS-inställningarna",
|
||||
"playing": "Spelar upp testljud",
|
||||
"testFailed": "Testet misslyckades — det gick inte att nå TTS-servern"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (lokal testning)",
|
||||
"resend": "Resend",
|
||||
"custom": "Anpassad SMTP",
|
||||
"apiKey": "API-nyckel",
|
||||
"host": "Värd",
|
||||
"port": "Port",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"tls": "TLS/SSL",
|
||||
"fromName": "Avsändarnamn",
|
||||
"fromEmail": "Avsändaradress",
|
||||
"sendTest": "Skicka testmejl",
|
||||
"userExample": "user@example.com",
|
||||
"recipientExample": "recipient@example.com",
|
||||
"saved": "SMTP-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara SMTP-inställningarna",
|
||||
"connected": "Anslutningen lyckades",
|
||||
"testSent": "Testmejl skickat till {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Uppdatera plattformen",
|
||||
"updateHint": "Ändrar ingenting som körs. Allt uppdateringen behöver listas här, och det är alltid ditt beslut.",
|
||||
"rebuild": "Bygg om gränssnittet",
|
||||
"restart": "Starta om servern",
|
||||
"upToDate": "Redan uppdaterad",
|
||||
"rebuilt": "Gränssnittet ombyggt — uppdatera sidan för att se det",
|
||||
"restarting": "Startar om — sidan återansluter av sig själv",
|
||||
"pulled": {
|
||||
"one": "Hämtade {{count}} commit",
|
||||
"other": "Hämtade {{count}} commits"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Kontrollerar…",
|
||||
"localProviders": "Lokala leverantörer",
|
||||
"remoteProviders": "Fjärrleverantörer",
|
||||
"connect": "Anslut",
|
||||
"authenticate": "Autentisera",
|
||||
"connectLocal": "Anslut lokal leverantör",
|
||||
"connectProvider": "Anslut leverantör",
|
||||
"none": "Inga leverantörer hittades.",
|
||||
"updatePiMono": "Uppdatera Pi Mono",
|
||||
"removeProvider": "Ta bort leverantör",
|
||||
"namePlaceholder": "Namn (t.ex. Min Ollama)",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"apiKey": "API-nyckel",
|
||||
"editKey": "Redigera nyckel",
|
||||
"disconnect": "Koppla från leverantör",
|
||||
"saveKey": "Spara nyckel",
|
||||
"enterKey": "Ange API-nyckel",
|
||||
"search": "Sök leverantörer…",
|
||||
"connectFailed": "Det gick inte att ansluta",
|
||||
"authFailed": "Autentiseringen misslyckades",
|
||||
"saveFailed": "Det gick inte att spara leverantören",
|
||||
"connectedTo": "Ansluten till {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Läser in behörigheter…",
|
||||
"permissionsFailed": "Det gick inte att läsa in behörigheterna.",
|
||||
"deniedUnlessGranted": "Allt nekas om det inte beviljas här.",
|
||||
"read": "Läs",
|
||||
"full": "Fullständig",
|
||||
"noAccess": "Ingen åtkomst",
|
||||
"role": "Roll",
|
||||
"permissionsSaved": "Sparade vad {{role}} kan nå",
|
||||
"copyBeforeClosing": "Kopiera det du behöver innan du stänger — inget av det kan visas igen.",
|
||||
"password": "Lösenord",
|
||||
"linuxAccount": "Linuxkonto",
|
||||
"sshKey": "Deras publika SSH-nyckel",
|
||||
"addToGitea": "Lägg till detta i deras Gitea-konto",
|
||||
"linuxIncomplete": "Kontot fungerar, men dess Linux-del blev inte klar",
|
||||
"done": "Klar",
|
||||
"addAccount": "Lägg till konto",
|
||||
"newAccount": "Nytt konto",
|
||||
"email": "E-post",
|
||||
"name": "Namn",
|
||||
"username": "Användarnamn",
|
||||
"usernameHint": "Bokstäver, siffror, punkter, bindestreck och understreck.",
|
||||
"generatePassword": "Generera ett lösenord",
|
||||
"copyPassword": "Kopiera lösenordet",
|
||||
"passwordHint": "Minst 12 tecken, med stora och små bokstäver, en siffra och ett specialtecken.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
|
||||
"passwordCopied": "Lösenordet kopierat",
|
||||
"loadingAccounts": "Läser in konton…",
|
||||
"accountsFailed": "Det gick inte att läsa in kontona.",
|
||||
"removeAccount": "Ta bort konto",
|
||||
"generateNewPassword": "Generera ett nytt lösenord",
|
||||
"serverOwner": "Serverägare",
|
||||
"copySshTitle": "Kopiera deras publika SSH-nyckel (lägg till den i deras Gitea-konto)",
|
||||
"resetPasswordTitle": "Generera ett nytt lösenord — visas en gång, och loggar ut dem överallt",
|
||||
"copyNewPassword": "Kopiera det nya lösenordet",
|
||||
"publicKeyCopied": "Publik nyckel kopierad",
|
||||
"copyFailed": "Det gick inte att kopiera — markera den och kopiera för hand",
|
||||
"repairFor": "Reparera {{email}}s Linuxkonto",
|
||||
"linuxAccountFor": "Linuxkonto: {{osUser}} — klicka för att reparera eller ersätta deras SSH-nyckel",
|
||||
"copySshFor": "Kopiera {{email}}s publika SSH-nyckel",
|
||||
"resetPasswordFor": "Återställ {{email}}s lösenord",
|
||||
"removeFor": "Ta bort {{email}}",
|
||||
"provisionPrompt": "Linuxkonto för {{email}}.\n\nKlistra in en publik SSH-nyckel för att tillåta dem att SSH:a in, eller lämna tomt för att behålla den nuvarande.",
|
||||
"createLinux": "Skapa ett Linuxkonto",
|
||||
"noLinux": "Inget Linuxkonto — klicka för att skapa ett",
|
||||
"removeTitle": "Ta bort {{email}}?",
|
||||
"removeBody": "Detta tar bort kontot och allt som tillhör det — passkeys, dashboards, sparade layouter och allt en installerad plugin sparar åt dem. Det kan inte ångras.",
|
||||
"resetTitle": "Återställa lösenordet för {{email}}?",
|
||||
"resetBody": "Ett nytt lösenord genereras och visas för dig en gång — det sparas ingenstans och kan inte hämtas i efterhand. Deras befintliga lösenord slutar fungera omedelbart, och de loggas ut överallt.",
|
||||
"resetLinuxNote": "Deras Linuxkonto ({{osUser}}) påverkas inte: det har inget lösenord, och SSH-nycklarna är oförändrade."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Ny enhet",
|
||||
"create": "Skapa",
|
||||
"none": "Inga enheter än.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Återkalla",
|
||||
"delete": "Ta bort",
|
||||
"copyFailed": "Det gick inte att kopiera — markera och kopiera manuellt",
|
||||
"createFailed": "Det gick inte att skapa applösenordet",
|
||||
"revoked": "Återkallat — den enheten slutar synkronisera",
|
||||
"revokeFailed": "Det gick inte att återkalla",
|
||||
"deleteFailed": "Det gick inte att ta bort",
|
||||
"copyField": "Kopiera {{field}}",
|
||||
"copied": "{{what}} kopierat"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Steg-för-steg-guide",
|
||||
"step1": {
|
||||
"title": "Skapa ett Google Cloud-projekt",
|
||||
"body": "Öppna sidan för att skapa projekt, ge det ett namn (till exempel ”Officer”), och klicka på:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Aktivera API:erna",
|
||||
"body": "Öppna API-biblioteket och sök efter var och en av dessa, klicka sedan på:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Konfigurera OAuth-samtyckesskärmen",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Ange din organisations namn, eller ”Officer”, som:",
|
||||
"supportEmail": "Ange din administratörs e-post som:",
|
||||
"devContact": "Lägg till din administratörs e-post under:",
|
||||
"save": "Klicka sedan på:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Ställ in målgruppen",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Om ditt team använder Google Workspace, välj — ingen verifiering behövs:",
|
||||
"external": "Välj annars, och lägg till ditt teams e-postadresser under Test users (krävs medan appen är overifierad; gräns på 100 testanvändare):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Lägg till scopes",
|
||||
"link": "Data Access",
|
||||
"body": "Öppna dataåtkomst, sök efter och lägg till scopen nedan, klicka sedan på:",
|
||||
"note": "Google klassar kalenderscopet som känsligt och Gmail-scopet som begränsat. Det är okej för Interna appar (Google Workspace) och för Externa appar i testläge; publicering i produktion med begränsade scopes kräver Google-verifiering."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Skapa OAuth-inloggningsuppgifter",
|
||||
"link": "Clients",
|
||||
"body": "Öppna klientsidan och klicka på:",
|
||||
"appType": "Application type:",
|
||||
"nameAnything": "Namn: vad som helst (till exempel ”Officer”)",
|
||||
"redirectUris": "Authorized redirect URIs — lägg till:",
|
||||
"thenClick": "Klicka sedan på:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Kopiera inloggningsuppgifterna",
|
||||
"body": "En dialog visar ditt klient-ID och din klienthemlighet. Kopiera båda och klistra in dem i fälten nedan."
|
||||
},
|
||||
"scopeGmail": "Läs e-post",
|
||||
"scopeCalendar": "Läs kalenderhändelser",
|
||||
"verifying": "Verifierar inloggningsuppgifter…",
|
||||
"valid": "Inloggningsuppgifterna är giltiga",
|
||||
"invalid": "Ogiltiga inloggningsuppgifter",
|
||||
"verifyFailed": "Verifieringsförfrågan misslyckades",
|
||||
"clientId": "Klient-ID",
|
||||
"clientSecret": "Klienthemlighet",
|
||||
"redirectUri": "Omdirigerings-URI",
|
||||
"redirectUriHint": "Lägg till denna URI i din Google OAuth-klients auktoriserade omdirigerings-URI:er",
|
||||
"saved": "Google OAuth-konfigurationen sparad",
|
||||
"saveFailed": "Det gick inte att spara Google OAuth-konfigurationen"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Googlekonto",
|
||||
"hint": "Anslut ditt Googlekonto för Kalender, Kontakter och andra Google-tjänster.",
|
||||
"connected": "Anslutet",
|
||||
"disconnect": "Koppla från",
|
||||
"connect": "Anslut Googlekonto",
|
||||
"connectedToast": "Googlekontot anslutet",
|
||||
"connectFailed": "Det gick inte att ansluta Googlekontot",
|
||||
"disconnectedToast": "Googlekontot frånkopplat",
|
||||
"disconnectFailed": "Det gick inte att koppla från Googlekontot"
|
||||
},
|
||||
"apify": {
|
||||
"token": "API-token",
|
||||
"saved": "Apify API-token sparad",
|
||||
"saveFailed": "Det gick inte att spara Apify API-token"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profil",
|
||||
"systemSettings": "Systeminställningar",
|
||||
"signOut": "Logga ut",
|
||||
"ai": "AI",
|
||||
"integrations": "Integrationer",
|
||||
"userManagement": "Användarhantering"
|
||||
},
|
||||
"tabName": "Flikens namn",
|
||||
"tabNameHint": "Klicka för att döpa om denna flik — rensa den för att gå tillbaka till sidans namn",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigering",
|
||||
"jobsRunning": "Körande jobb",
|
||||
"jobsNothingRunning": "Inget körs",
|
||||
"jobsQueued": "Köade jobb"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hem",
|
||||
"files": "Filer",
|
||||
"chat": "Chatt",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobb",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Webbläsare",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Aktivitet",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Kalender",
|
||||
"contacts": "Kontakter",
|
||||
"appStore": "Appbutik",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Kod-editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ bläddra · ctrl+shift+space bläddra i galleri · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "stjärnmärk",
|
||||
"unstar": "avmarkera stjärna",
|
||||
"cycleHint": "ctrl+shift+←/→ bläddra · ctrl+shift+space galleri",
|
||||
"everyShortcutHint": "ctrl+? för alla kortkommandon",
|
||||
"favouritesOnly": "Endast favoriter ({{count}})",
|
||||
"favourites": "Favoriter ({{count}})",
|
||||
"noFavourites": "Inga favoriter än — stjärnmärk en med knappen på dess rad.",
|
||||
"starNamed": "Stjärnmärk {{name}}",
|
||||
"unstarNamed": "Avmarkera stjärna för {{name}}",
|
||||
"addToFavourites": "Lägg till i favoriter",
|
||||
"removeFromFavourites": "Ta bort från favoriter",
|
||||
"addToFavouritesKey": "Lägg till i favoriter (f)",
|
||||
"removeFromFavouritesKey": "Ta bort från favoriter (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k eller ↑/↓ flytta · l eller → förhandsgranska · f stjärna · h eller ← återställ · enter behåll · esc avbryt"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gå till…",
|
||||
"noResults": "Inget matchar ”{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Kortkommandon",
|
||||
"hint": "Ctrl+? · Esc för att stänga"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Den här skärmen kunde inte renderas",
|
||||
"description": "Inget gick förlorat — detta är ett visningsfel, inte ett datafel. Agenttranskript, jobb och filer är opåverkade.",
|
||||
"unknownError": "Okänt fel",
|
||||
"tryAgain": "Försök igen",
|
||||
"reload": "Läs om",
|
||||
"goHome": "Gå hem",
|
||||
"resetHint": "Om det misslyckas igen på samma ställe är en sparad panellayout den vanliga orsaken. Återställning sätter tillbaka varje skärms paneler till standardläget.",
|
||||
"dashboardsSafe": "Dina dashboards påverkas inte.",
|
||||
"resetting": "Återställer…",
|
||||
"resetConfirm": "Ja — återställ alla skärmlayouter",
|
||||
"reset": "Återställ skärmlayouter"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Skanna om objekt",
|
||||
"title": "Skanna om officer-items (tillämpa ändringar utan omstart)",
|
||||
"failed": "Det gick inte att skanna om objekten",
|
||||
"done": "Skannade om objekt — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} uppgift",
|
||||
"other": "{{count}} uppgifter"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agent",
|
||||
"other": "{{count}} agenter"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} verktyg",
|
||||
"other": "{{count}} verktyg"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} process",
|
||||
"other": "{{count}} processer"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Gäller nya chattar — en öppen chatt behåller det den startade med.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill saknar beskrivning, så ingen agent erbjuds den.",
|
||||
"other": "{{count}} skills saknar beskrivning, så ingen agent erbjuds dem."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Rapportera ett fel",
|
||||
"title": "Rapportera ett fel",
|
||||
"describePlaceholder": "Beskriv vad som gick fel...",
|
||||
"screenshot": "Skärmbild",
|
||||
"noScreenshot": "Ingen skärmbild togs",
|
||||
"collectedNote": "Konsolloggar, webbläsarinfo och nyliga API-fel samlas in automatiskt.",
|
||||
"submitted": "Felrapport skickad",
|
||||
"failed": "Det gick inte att skicka felrapporten",
|
||||
"submit": "Skicka"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Välkommen tillbaka",
|
||||
"signInSubtitle": "Logga in på ditt konto",
|
||||
"emailOrUsername": "E-post eller användarnamn",
|
||||
"password": "Lösenord",
|
||||
"forgotPassword": "Glömt lösenordet?",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"passwordPlaceholder": "Ditt lösenord",
|
||||
"forgotTitle": "Glömt lösenord",
|
||||
"forgotSubtitle": "Ange din e-post för att få en återställningslänk",
|
||||
"email": "E-post",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"checkEmail": "Kolla din e-post",
|
||||
"checkEmailHint": "Klicka på den för att återställa ditt lösenord.",
|
||||
"resetOk": "Lösenordet återställdes. Logga in.",
|
||||
"verifying": "Verifierar…",
|
||||
"invalidLink": "Ogiltig länk",
|
||||
"invalidLinkHint": "Den här återställningslänken är ogiltig eller har gått ut.",
|
||||
"resetTitle": "Återställ lösenord",
|
||||
"resetSubtitle": "Ange ditt nya lösenord",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"minChars": "Minst 12 tecken",
|
||||
"confirmPlaceholder": "Bekräfta ditt lösenord",
|
||||
"bootstrapTitle": "Välkommen, administratör",
|
||||
"bootstrapSubtitle": "Skapa ditt administratörskonto",
|
||||
"name": "Namn",
|
||||
"username": "Användarnamn",
|
||||
"namePlaceholder": "Ditt namn",
|
||||
"adminEmailPlaceholder": "admin@example.com",
|
||||
"bootstrapOk": "Administratörskontot skapades. Logga in.",
|
||||
"debug": {
|
||||
"controls": "Kontroller:",
|
||||
"camera": "Kamera:",
|
||||
"model": "Modell:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigering",
|
||||
"spotlight": "Spotlight — gå till vilken skärm, plugin eller inställning som helst med namn",
|
||||
"thisList": "Denna lista",
|
||||
"themes": "Teman",
|
||||
"themesNote": "Webbläsaren tillämpar temat medan du flyttar dig, så du ser appen förändras i stället för att läsa namn.",
|
||||
"openBrowser": "Öppna temabläddraren",
|
||||
"prevNextTheme": "Föregående/nästa tema, utan att öppna något",
|
||||
"starCurrent": "Stjärnmärk eller avmarkera det aktuella temat, varifrån som helst",
|
||||
"inBrowser": "I temabläddraren",
|
||||
"moveList": "Flytta genom listan",
|
||||
"applyKeepBrowsing": "Tillämpa det markerade temat och fortsätt bläddra",
|
||||
"restoreStart": "Sätt tillbaka temat du började med",
|
||||
"starFocused": "Stjärnmärk eller avmarkera det markerade temat",
|
||||
"keepLooking": "Behåll det du tittar på",
|
||||
"cancelRestore": "Avbryt och återställ",
|
||||
"inSpotlight": "I Spotlight",
|
||||
"moveResults": "Flytta genom resultaten",
|
||||
"goThere": "Gå dit",
|
||||
"close": "Stäng",
|
||||
"chat": "Chatt",
|
||||
"chatNote": "Återkallning tar bara piltangenten från ett TOMT skrivfält, och varje annan tangent avslutar återkallningen — så ett utkast skrivs aldrig över.",
|
||||
"prevNextPrompt": "Föregående/nästa prompt du skickade, i skalstil",
|
||||
"send": "Skicka · på ett tomt skrivfält med något köat, skicka det nu",
|
||||
"newline": "Ny rad",
|
||||
"stopTurn": "Stoppa turen som genereras"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "Körs och köade",
|
||||
"loading": "Läser in…",
|
||||
"nothingRunning": "Inget körs",
|
||||
"history": "Historik",
|
||||
"clearAll": "Rensa alla",
|
||||
"selectJob": "Välj ett jobb",
|
||||
"notFound": "Jobbet hittades inte.",
|
||||
"backToJobs": "Tillbaka till jobb",
|
||||
"stop": "Stoppa",
|
||||
"testNotification": "Skicka en testnotis till varje konfigurerad kanal",
|
||||
"search": "Sök…",
|
||||
"deleteFinished": "Ta bort alla avslutade jobb",
|
||||
"noChannels": "Inga kanaler konfigurerade",
|
||||
"notifyFailed": "Det gick inte att skicka notisen",
|
||||
"stopRequested": "Stopp begärt",
|
||||
"stopFailed": "Det gick inte att stoppa",
|
||||
"titles": "Titlar",
|
||||
"download": "Ladda ner",
|
||||
"input": "Indata",
|
||||
"output": "Utdata",
|
||||
"steps": "Steg",
|
||||
"selectStep": "Välj ett steg eller en deluppgift för att visa utdata",
|
||||
"noOutput": "Ingen utdata än",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Kostnad:",
|
||||
"status": {
|
||||
"running": "Körs",
|
||||
"queued": "Köad",
|
||||
"completed": "Klar",
|
||||
"failed": "Misslyckades",
|
||||
"stopped": "Stoppad",
|
||||
"interrupted": "Avbruten"
|
||||
},
|
||||
"failedWithCode": "Misslyckades (avslut {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"backgroundTasks": "Bakgrundsuppgifter",
|
||||
"detached": "Frikopplad",
|
||||
"backToList": "Tillbaka till listan",
|
||||
"selectTask": "Välj en uppgift för att följa dess utdata live",
|
||||
"noneRunning": "inget körs"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Ställ in din passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Det gick inte att läsa in den här konversationen: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Inga träffar",
|
||||
"emptyFile": "Tom fil",
|
||||
"createFailed": "Det gick inte att skapa {{kind}}",
|
||||
"deleteFailed": "Det gick inte att ta bort {{kind}}",
|
||||
"searchPlaceholder": "Sök {{kind}}…",
|
||||
"selectOne": "Välj en {{kind}}",
|
||||
"selectToView": "Välj en {{kind}} för att visa dess innehåll",
|
||||
"delete": "Ta bort",
|
||||
"createPrompt": "Hjälp mig skapa innehållet för den här nya {{kind}}-filen",
|
||||
"improvePrompt": "Hjälp mig förstå och förbättra den här {{kind}}-filen"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,34 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
|
||||
@@ -3,7 +3,9 @@ export {
|
||||
createCommandHqCategory,
|
||||
renameCommandHqCategory,
|
||||
deleteCommandHqCategory,
|
||||
createCommandHqSession,
|
||||
createCommander,
|
||||
setCommanderSessionId,
|
||||
getCommandHqSessionById,
|
||||
listCommandHqSessions,
|
||||
listCommandHqSessionIds,
|
||||
getCommandHqSession,
|
||||
@@ -31,6 +33,7 @@ export {
|
||||
export type {
|
||||
CommandHqCategory,
|
||||
CommandHqSessionRow,
|
||||
NewCommander,
|
||||
CommandHqTag,
|
||||
CommandHqWatcherFire,
|
||||
CommandHqTurnCost,
|
||||
|
||||
@@ -54,7 +54,12 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
|
||||
|
||||
export type CommandHqSessionRow = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
/** Claude's transcript uuid — null until the first turn reports one. See the schema file's docstring. */
|
||||
sessionId: string | null;
|
||||
/** `command_hq_sessions.dir_name` — the directory under `getCommandersDir(email)` that IS this commander. */
|
||||
dirName: string;
|
||||
/** What this commander operates on. Seeded with one path at creation; grown in conversation. */
|
||||
referencePaths: string[];
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
@@ -69,6 +74,8 @@ export type CommandHqSessionRow = {
|
||||
const SESSION_ROW_COLUMNS = {
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
dirName: commandHqSessions.dirName,
|
||||
referencePaths: commandHqSessions.referencePaths,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
@@ -78,30 +85,67 @@ const SESSION_ROW_COLUMNS = {
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
};
|
||||
|
||||
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
|
||||
|
||||
/**
|
||||
* File a session as a commander. Upsert on `(user_id, session_id)` — a second call (a retried request, a
|
||||
* client that raced itself) is a no-op rather than a duplicate-key error, and does NOT move an
|
||||
* already-categorised commander back to uncategorised.
|
||||
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
|
||||
* (`api/command-hq/directory.ts`): this file is Postgres only, and `dirName` has to come back from HERE
|
||||
* before anything can be created on disk, since it is derived from the row's own id.
|
||||
*
|
||||
* Two statements in one transaction rather than a single `INSERT ... RETURNING`: `dirName` is derived FROM
|
||||
* the id Postgres assigns on insert, so the row has to exist — with some not-null placeholder — before its
|
||||
* own directory name is knowable. Nothing else here is a candidate: a name can collide (two commanders
|
||||
* called "Backups"), a random string is one more thing to keep unique, and the id already IS unique and
|
||||
* stable the moment it exists.
|
||||
*/
|
||||
export async function createCommandHqSession(
|
||||
userId: number,
|
||||
sessionId: string,
|
||||
categoryId: number | null = null,
|
||||
name: string | null = null,
|
||||
): Promise<CommandHqSessionRow> {
|
||||
export async function createCommander(input: NewCommander): Promise<CommandHqSessionRow> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [inserted] = await tx
|
||||
.insert(commandHqSessions)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
sessionId: null,
|
||||
dirName: '', // fixed up below, once the id it is derived from exists — never visible outside this transaction
|
||||
referencePaths: [input.referencePath],
|
||||
name: input.name,
|
||||
categoryId: input.categoryId ?? null,
|
||||
})
|
||||
.returning({ id: commandHqSessions.id });
|
||||
const [row] = await tx
|
||||
.update(commandHqSessions)
|
||||
.set({ dirName: String(inserted!.id) })
|
||||
.where(eq(commandHqSessions.id, inserted!.id))
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a commander at its current conversation — the transcript id, once the harness reports one. Called
|
||||
* from `fileAsCommander` (`api/chat/websocket.ts`) the moment `session:claude` arrives, correlated by the
|
||||
* commander's own row id (`msg.commanderId`), not by upserting on the transcript id the way this table used
|
||||
* to be filed: the row already exists by the time any turn spawns, per `docs/commander-directory.md`, so
|
||||
* there is nothing left to upsert.
|
||||
*
|
||||
* A plain UPDATE, not gated on the column currently being null: a dead session replaced by a fresh one in
|
||||
* the same directory repoints the same row, which is the whole reason this is a pointer and not an identity.
|
||||
*/
|
||||
export async function setCommanderSessionId(userId: number, id: number, sessionId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({ sessionId })
|
||||
.where(and(eq(commandHqSessions.id, id), eq(commandHqSessions.userId, userId)))
|
||||
.returning({ id: commandHqSessions.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** One commander's row, by its OWN id — for a caller (the composer, mid-creation) that has no transcript yet. */
|
||||
export async function getCommandHqSessionById(userId: number, id: number): Promise<CommandHqSessionRow | null> {
|
||||
const [row] = await db
|
||||
.insert(commandHqSessions)
|
||||
.values({ userId, sessionId, categoryId, name })
|
||||
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
|
||||
// the caller still needs an id back (to attach tags, say) even when this call raced an earlier one.
|
||||
if (row) return row;
|
||||
const [existing] = await db
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
.from(commandHqSessions)
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
|
||||
return existing!;
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.id, id)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,15 +173,22 @@ const isCategoryNull = () => isNull(commandHqSessions.categoryId);
|
||||
* Just the session ids this user has filed — the whole shape `/chat`'s list needs to exclude them, and
|
||||
* `/command-hq`'s grid needs to include only them. One query, no join against transcripts (they are not in
|
||||
* Postgres).
|
||||
*
|
||||
* A commander between `POST /commanders` and its first turn has no transcript yet — `sessionId` is null —
|
||||
* and is filtered out here rather than returned as a literal `null` in the set: there is no transcript id
|
||||
* to exclude from `/chat`'s list, and a null in a `Set<string>` would only ever fail to match anything.
|
||||
*/
|
||||
export async function listCommandHqSessionIds(userId: number): Promise<Set<string>> {
|
||||
const rows = await db
|
||||
.select({ sessionId: commandHqSessions.sessionId })
|
||||
.from(commandHqSessions)
|
||||
.where(eq(commandHqSessions.userId, userId));
|
||||
return new Set(rows.map((r) => r.sessionId));
|
||||
.where(and(eq(commandHqSessions.userId, userId), isNotNullSessionId()));
|
||||
return new Set(rows.map((r) => r.sessionId!));
|
||||
}
|
||||
|
||||
// drizzle's `eq` cannot express `IS NOT NULL` either.
|
||||
const isNotNullSessionId = () => sql`${commandHqSessions.sessionId} is not null`;
|
||||
|
||||
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
|
||||
const [row] = await db
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
@@ -352,7 +403,13 @@ export async function deleteCommandHqSession(userId: number, sessionId: string):
|
||||
*/
|
||||
export async function purgeCommandHqSessions(userId: number, liveSessionIds: Set<string>): Promise<number> {
|
||||
const all = await listCommandHqSessions(userId);
|
||||
const dead = all.filter((row) => !liveSessionIds.has(row.sessionId)).map((row) => row.sessionId);
|
||||
// A row with `sessionId: null` has no transcript YET — it is mid-creation, not dead — so it is never a
|
||||
// purge candidate regardless of what `liveSessionIds` holds. Only a row that HAD a transcript and lost it
|
||||
// is "dead" in the sense this sweep exists to clean up.
|
||||
const dead = all
|
||||
.filter((row): row is CommandHqSessionRow & { sessionId: string } => row.sessionId !== null)
|
||||
.filter((row) => !liveSessionIds.has(row.sessionId))
|
||||
.map((row) => row.sessionId);
|
||||
if (!dead.length) return 0;
|
||||
const rows = await db
|
||||
.delete(commandHqSessions)
|
||||
@@ -508,7 +565,11 @@ export async function parentCommanderOf(spawnedSessionId: string): Promise<Spawn
|
||||
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
|
||||
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
// `row.sessionId` is null for a commander mid-creation — no live loop exists to have spawned anything yet
|
||||
// at that point, so a row in this state here would mean the query above found something it should not
|
||||
// have been able to. Treated as "no parent" rather than asserted away, for the same reason every other
|
||||
// null here is: an unexpected fact is not this function's to paper over.
|
||||
if (!row || row.sessionId === null) return null;
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
|
||||
@@ -65,13 +65,17 @@ export const commandHqCategories = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per commander — the mapping from a Claude transcript to Command HQ's own metadata about it.
|
||||
* One row per commander. The ROW is the commander now — `docs/commander-directory.md` — with its own
|
||||
* directory on disk (`dirName`) and its own id, not a mapping keyed on a Claude transcript. `sessionId` is
|
||||
* a nullable POINTER to whichever conversation is current, set once the harness reports one and free to be
|
||||
* repointed if that conversation dies and a fresh one replaces it in the same directory.
|
||||
*
|
||||
* `/chat` and `/command-hq` read the SAME underlying session list (transcripts on disk) and this table is
|
||||
* the filter between them: `/chat` excludes any session with a row here, `/command-hq` includes only
|
||||
* sessions that have one. The table is never itself the list of what exists — a row whose transcript is
|
||||
* gone is simply absent from both lists, on purpose. See `docs/command-hq.md` → "The /chat ↔ /command-hq
|
||||
* relationship".
|
||||
* `/chat` and `/command-hq` still read the SAME underlying session list (transcripts on disk) once a row
|
||||
* has a `sessionId`, and this table is still the filter between them: `/chat` excludes any session with a
|
||||
* row here, `/command-hq` includes rows that have a live transcript. A row whose transcript is gone is
|
||||
* simply absent from both lists, on purpose. See `docs/command-hq.md` → "The /chat ↔ /command-hq
|
||||
* relationship" — written before the identity change, still correct for a row that already has a
|
||||
* transcript.
|
||||
*/
|
||||
export const commandHqSessions = pgTable(
|
||||
'command_hq_sessions',
|
||||
@@ -85,8 +89,38 @@ export const commandHqSessions = pgTable(
|
||||
* for the same reason: everything that joins back to a session on disk (models, events, this table)
|
||||
* uses the id the transcript itself carries, because that is the one Officer's `sessionId` and Claude's
|
||||
* id spaces both agree on only sometimes.
|
||||
*
|
||||
* **Nullable, since 2026-08-24 (`docs/commander-directory.md`).** This used to be the commander's
|
||||
* IDENTITY — the row was created only once a transcript existed, which is what forced a commander to be
|
||||
* created after its own working directory rather than before. The row's own `id`/`dirName` is the
|
||||
* identity now; this is a POINTER to whichever conversation is current, null from the moment the row is
|
||||
* created until the first turn reports a real transcript id, and free to be repointed later if a dead
|
||||
* session is replaced by a fresh one in the same directory.
|
||||
*/
|
||||
sessionId: text('session_id').notNull(),
|
||||
sessionId: text('session_id'),
|
||||
/**
|
||||
* The directory under `getCommandersDir(email)` that IS this commander —
|
||||
* `$DATA_PATH/<email>/commanders/<dirName>/`, holding `CLAUDE.md` and `state/`. See
|
||||
* `docs/commander-directory.md`.
|
||||
*
|
||||
* Derived from this row's own serial `id` (`String(id)`) the moment the row is inserted — nothing else
|
||||
* is guaranteed unique and stable before a name is even chosen. Not the transcript id: that does not
|
||||
* exist yet at creation, which is the entire point of this table gaining its own identity.
|
||||
*/
|
||||
dirName: text('dir_name').notNull(),
|
||||
/**
|
||||
* What the commander operates ON — not where it lives. Seeded with one path at creation (the start
|
||||
* dialog collects exactly one; asking for a list up front gets a worse answer than asking later) and
|
||||
* grown as the commander and the owner agree on more in conversation.
|
||||
*
|
||||
* A Postgres `text[]` rather than `jsonb`: every element is a plain path string with no structure of
|
||||
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
||||
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
||||
*/
|
||||
referencePaths: text('reference_paths')
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
@@ -130,6 +164,14 @@ export const commandHqSessions = pgTable(
|
||||
watcherStoppedAt: timestamp('watcher_stopped_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// `sessionId` is nullable now, and this is left as a PLAIN unique index rather than a partial one
|
||||
// (`.where(isNotNull(...))`) deliberately, not by default: Postgres already treats every NULL as
|
||||
// distinct from every other NULL in a unique index, so any number of a user's commanders can sit with
|
||||
// `session_id: null` at once — which is exactly the window between `POST /commanders` creating the row
|
||||
// and the first turn reporting a real transcript id, for however many commanders are mid-creation at
|
||||
// the same time. The moment `session_id` is set, uniqueness still applies: one commander per transcript,
|
||||
// per user, same guarantee this index always gave. A partial index would enforce the identical rule with
|
||||
// an extra clause to keep in sync — nothing here needs it.
|
||||
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ export {
|
||||
createRole,
|
||||
renameRole,
|
||||
deleteRole,
|
||||
setRoleGrants,
|
||||
type RoleRow,
|
||||
type CreateRoleResult,
|
||||
type RenameRoleResult,
|
||||
type DeleteRoleResult,
|
||||
type SetRoleGrantsResult,
|
||||
} from './queries';
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { eq, like } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { roles } from './schema';
|
||||
import { createRole, getRole, seedRole, setRoleGrants } from './queries';
|
||||
|
||||
/*
|
||||
Phase 3 of docs/roles-and-teams.md: `grantsPostgres`/`grantsDocker` replace `rolePermitsDevTools()`'s
|
||||
hardcoded `role === 'Developer'`. These tests hit the real database, like chat-session-cleanup.test.ts —
|
||||
the thing being pinned is column plumbing (does the value survive a write and a read back), which a fake
|
||||
store would pass regardless of whether the SQL is right.
|
||||
|
||||
Every role name here is prefixed `zz-test-role-`, cleaned up in `afterEach` by prefix rather than by an
|
||||
exact list, so a test that fails before reaching its own cleanup does not leave a row for the next run to
|
||||
trip over.
|
||||
*/
|
||||
|
||||
const PREFIX = 'zz-test-role-';
|
||||
const name = (suffix: string) => `${PREFIX}${suffix}`;
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(roles).where(like(roles.name, `${PREFIX}%`));
|
||||
});
|
||||
|
||||
describe('grants round trip through create and read', () => {
|
||||
test('a role created with both flags true reads back with both true', async () => {
|
||||
const created = await createRole({ name: name('both'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(true);
|
||||
expect(created.role.grantsDocker).toBe(true);
|
||||
|
||||
const read = await getRole(name('both'));
|
||||
expect(read?.grantsPostgres).toBe(true);
|
||||
expect(read?.grantsDocker).toBe(true);
|
||||
});
|
||||
|
||||
test('omitted flags default to false, matching the column default', async () => {
|
||||
const created = await createRole({ name: name('bare') });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(false);
|
||||
expect(created.role.grantsDocker).toBe(false);
|
||||
});
|
||||
|
||||
test('a role with grantsDocker false does not carry it after a Postgres-only grant', async () => {
|
||||
const created = await createRole({ name: name('pg-only'), grantsPostgres: true });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(true);
|
||||
expect(created.role.grantsDocker).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRoleGrants', () => {
|
||||
test('changes only the flags supplied, leaving the other as it was', async () => {
|
||||
const created = await createRole({ name: name('partial'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
const updated = await setRoleGrants(name('partial'), { grantsDocker: false });
|
||||
expect(updated).toEqual({
|
||||
ok: true,
|
||||
role: expect.objectContaining({ grantsPostgres: true, grantsDocker: false }),
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses a system role, so an edit cannot look like it worked and then revert on the next deploy', async () => {
|
||||
// isSystem is not settable through createRole, so insert the row directly — this is standing in for
|
||||
// one of the four seeded rows, which this function must never touch.
|
||||
await db.insert(roles).values({ name: name('system'), isSystem: true, grantsPostgres: false });
|
||||
|
||||
const result = await setRoleGrants(name('system'), { grantsPostgres: true });
|
||||
expect(result).toEqual({ ok: false, reason: 'system' });
|
||||
|
||||
// And the value is genuinely unchanged, not just the result code.
|
||||
expect((await getRole(name('system')))?.grantsPostgres).toBe(false);
|
||||
});
|
||||
|
||||
test('reports not-found for a role that does not exist', async () => {
|
||||
expect(await setRoleGrants(name('missing'), { grantsPostgres: true })).toEqual({
|
||||
ok: false,
|
||||
reason: 'not-found',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedRole', () => {
|
||||
test('is idempotent: seeding the same system role twice does not duplicate or error', async () => {
|
||||
const first = await seedRole({ name: name('seed'), isSystem: true, grantsPostgres: true });
|
||||
const second = await seedRole({ name: name('seed'), isSystem: true, grantsPostgres: true });
|
||||
expect(first).toBe(true); // created
|
||||
expect(second).toBe(false); // already present
|
||||
|
||||
const rows = await db.select().from(roles).where(eq(roles.name, name('seed')));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('corrects a system role whose grants were wrong, without touching a non-system role the same way', async () => {
|
||||
await seedRole({ name: name('correct-sys'), isSystem: true, grantsPostgres: true, grantsDocker: true });
|
||||
// Simulate the migration case: a `db:push` column-add left the row at the boolean default.
|
||||
await db.update(roles).set({ grantsPostgres: false, grantsDocker: false }).where(eq(roles.name, name('correct-sys')));
|
||||
expect(await getRole(name('correct-sys'))).toMatchObject({ grantsPostgres: false, grantsDocker: false });
|
||||
|
||||
// Re-running the same seed call is the fix — no separate migration script.
|
||||
await seedRole({ name: name('correct-sys'), isSystem: true, grantsPostgres: true, grantsDocker: true });
|
||||
expect(await getRole(name('correct-sys'))).toMatchObject({ grantsPostgres: true, grantsDocker: true });
|
||||
});
|
||||
|
||||
test('never rewrites description once a row exists, only grants', async () => {
|
||||
await seedRole({ name: name('desc'), description: 'original', isSystem: true, grantsPostgres: false });
|
||||
// An operator's edit, which the next deploy's seed call must not revert.
|
||||
await db.update(roles).set({ description: 'edited by an operator' }).where(eq(roles.name, name('desc')));
|
||||
|
||||
await seedRole({ name: name('desc'), description: 'original', isSystem: true, grantsPostgres: true });
|
||||
|
||||
const row = await getRole(name('desc'));
|
||||
expect(row?.description).toBe('edited by an operator');
|
||||
expect(row?.grantsPostgres).toBe(true);
|
||||
});
|
||||
|
||||
test('does not touch a non-system role at all, even if seeded once as one', async () => {
|
||||
// A non-system row's grants belong to whoever created it; seedRole's correction pass only applies
|
||||
// when `isSystem` is true in the call.
|
||||
const created = await createRole({ name: name('custom'), grantsPostgres: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
await seedRole({ name: name('custom'), isSystem: false, grantsPostgres: false });
|
||||
|
||||
expect((await getRole(name('custom')))?.grantsPostgres).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,8 @@ export type RoleRow = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
provisioningScript: string | null;
|
||||
grantsPostgres: boolean;
|
||||
grantsDocker: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@@ -24,13 +25,14 @@ export async function listRoles(): Promise<(RoleRow & { userCount: number })[]>
|
||||
name: roles.name,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
provisioningScript: roles.provisioningScript,
|
||||
grantsPostgres: roles.grantsPostgres,
|
||||
grantsDocker: roles.grantsDocker,
|
||||
createdAt: roles.createdAt,
|
||||
userCount: sql<number>`count(${users.id})::int`,
|
||||
})
|
||||
.from(roles)
|
||||
.leftJoin(users, eq(users.role, roles.name))
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.grantsPostgres, roles.grantsDocker, roles.createdAt)
|
||||
.orderBy(asc(roles.name));
|
||||
return rows;
|
||||
}
|
||||
@@ -41,22 +43,48 @@ export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||
* Seed a role if it is not there. Idempotent, but NOT symmetric across columns.
|
||||
*
|
||||
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
|
||||
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
|
||||
* deploy — so the seed establishes a row and never touches it again.
|
||||
* platform's life. `description` is never rewritten once a row exists: an upsert there would mean a
|
||||
* description edited on one server is silently reverted by the next deploy — so that column is set on
|
||||
* insert and never touched again.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` are the opposite, and only for a role seeded with `isSystem: true`: they
|
||||
* ARE re-asserted on every run, because for a system role they are not an operator's data, they are the
|
||||
* platform's own declaration of what the role means — the same standing as its name. Two things need that:
|
||||
*
|
||||
* - A row can predate these columns. `db:push` adds them with `default false`, so an existing `Developer`
|
||||
* row gets `grantsPostgres: false, grantsDocker: false` the moment the column lands — an insert-only
|
||||
* seed would leave it there forever and silently take away dev tooling nobody asked to revoke.
|
||||
* - It is also what makes the guard in `setRoleGrants` (below) honest. That function refuses to touch a
|
||||
* system role's grants specifically so an edit cannot LOOK like it worked and then vanish on the next
|
||||
* deploy; this is the other half of that promise — the seed is the only writer, and it always writes
|
||||
* the same thing.
|
||||
*
|
||||
* A non-system role's grants are never touched here — those belong to whoever created the role, through
|
||||
* `setRoleGrants`, and this function has no opinion about a row it did not seed.
|
||||
*/
|
||||
export async function seedRole(row: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isSystem?: boolean;
|
||||
grantsPostgres?: boolean;
|
||||
grantsDocker?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const isSystem = row.isSystem ?? false;
|
||||
const grantsPostgres = row.grantsPostgres ?? false;
|
||||
const grantsDocker = row.grantsDocker ?? false;
|
||||
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem, grantsPostgres, grantsDocker })
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning({ name: roles.name });
|
||||
|
||||
if (inserted.length === 0 && isSystem) {
|
||||
await db.update(roles).set({ grantsPostgres, grantsDocker }).where(eq(roles.name, row.name));
|
||||
}
|
||||
return inserted.length > 0;
|
||||
}
|
||||
|
||||
@@ -83,14 +111,30 @@ export type CreateRoleResult = { ok: true; role: RoleRow } | { ok: false; reason
|
||||
* Create a role at runtime. `isSystem` is always false here — a role the platform's own code names by
|
||||
* string (Developer, Super Admin, Member; see the schema comment) is one seeded at startup, never one
|
||||
* typed into a form, and nothing reachable through this function may claim otherwise.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` default to false, same as the column defaults — a role created through a
|
||||
* form should not hand out a Postgres login and a Docker daemon because nobody unchecked a box. There is
|
||||
* no `isSystem` guard needed on the write itself: a role created here is never system, so nothing later
|
||||
* pins these two values the way `seedRole` pins Developer's.
|
||||
*/
|
||||
export async function createRole(input: { name: string; description?: string | null }): Promise<CreateRoleResult> {
|
||||
export async function createRole(input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
grantsPostgres?: boolean;
|
||||
grantsDocker?: boolean;
|
||||
}): Promise<CreateRoleResult> {
|
||||
const name = input.name.trim();
|
||||
if (!name) return { ok: false, reason: 'invalid-name' };
|
||||
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name, description: input.description?.trim() || null, isSystem: false })
|
||||
.values({
|
||||
name,
|
||||
description: input.description?.trim() || null,
|
||||
isSystem: false,
|
||||
grantsPostgres: input.grantsPostgres ?? false,
|
||||
grantsDocker: input.grantsDocker ?? false,
|
||||
})
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning();
|
||||
const role = inserted[0];
|
||||
@@ -98,15 +142,52 @@ export async function createRole(input: { name: string; description?: string | n
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type SetRoleGrantsResult = { ok: true; role: RoleRow } | { ok: false; reason: 'not-found' | 'system' };
|
||||
|
||||
/**
|
||||
* Change a role's provisioning flags after it has been created.
|
||||
*
|
||||
* Refuses on `isSystem`, and for a different reason than `renameRole`'s refusal: renaming protects the
|
||||
* NAME code depends on, this protects a PROMISE. `seedRole` re-asserts a system role's grants on every
|
||||
* `postdb:push`, so writing here would look like it worked and then silently revert on the next deploy —
|
||||
* exactly the "says something untrue" trap `manage-users.ts` calls out for the Postgres-follows-role case.
|
||||
* A custom role has no seed pinning it, so this is the only place its grants are ever set after creation.
|
||||
*
|
||||
* Only the two flags supplied are changed; the other keeps its current value, so a caller adjusting just
|
||||
* `grantsDocker` cannot accidentally reset `grantsPostgres` to false.
|
||||
*/
|
||||
export async function setRoleGrants(
|
||||
name: string,
|
||||
grants: { grantsPostgres?: boolean; grantsDocker?: boolean },
|
||||
): Promise<SetRoleGrantsResult> {
|
||||
const existing = await getRole(name);
|
||||
if (!existing) return { ok: false, reason: 'not-found' };
|
||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||
|
||||
// Same re-check as renameRole/deleteRole, same reason: the row must not have become system between the
|
||||
// read above and this write.
|
||||
const [role] = await db
|
||||
.update(roles)
|
||||
.set({
|
||||
grantsPostgres: grants.grantsPostgres ?? existing.grantsPostgres,
|
||||
grantsDocker: grants.grantsDocker ?? existing.grantsDocker,
|
||||
})
|
||||
.where(and(eq(roles.name, name), eq(roles.isSystem, false)))
|
||||
.returning();
|
||||
if (!role) return { ok: false, reason: 'system' };
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type RenameRoleResult =
|
||||
| { ok: true; role: RoleRow }
|
||||
| { ok: false; reason: 'not-found' | 'system' | 'invalid-name' | 'duplicate' };
|
||||
|
||||
/**
|
||||
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `rolePermitsDevTools()`
|
||||
* tests for `'Developer'` by string, `bootstrap.ts` writes `'Super Admin'`, and `users.role` defaults to
|
||||
* `'Member'`. Renaming one of those four is a code change across three call sites that has not happened,
|
||||
* not a data edit — so this refuses rather than quietly breaking them.
|
||||
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `scripts/seed-roles.ts`
|
||||
* matches its four rows by name on every deploy, `bootstrap.ts` writes `'Super Admin'`, and `users.role`
|
||||
* defaults to `'Member'`. Renaming one of those four is a code change across those call sites that has not
|
||||
* happened, not a data edit — a rename would leave the seed re-creating the old name from under it rather
|
||||
* than correcting the row that moved.
|
||||
*
|
||||
* `users.role` and `role_permissions.role` are foreign keys onto this table with `ON UPDATE CASCADE`
|
||||
* (phase 2 part 2), so the `UPDATE` below moves every holder and every grant automatically, in the same
|
||||
|
||||
@@ -13,7 +13,7 @@ import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
* changes.
|
||||
*
|
||||
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
|
||||
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
|
||||
* appears in `scripts/seed-roles.ts`'s seed table, in report text and in whatever a plugin has written down.
|
||||
*
|
||||
* ── What is NOT here ──
|
||||
*
|
||||
@@ -33,20 +33,30 @@ export const roles = pgTable('roles', {
|
||||
/**
|
||||
* A role the platform's own code names, so it cannot be deleted or renamed.
|
||||
*
|
||||
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
|
||||
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
|
||||
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
|
||||
* discovering it as a runtime failure somewhere else.
|
||||
* `scripts/seed-roles.ts` matches `'Developer'`, `'Admin'` and `'Member'` by name on every deploy,
|
||||
* `bootstrap.ts` sets `'Super Admin'`, and the users table defaults to `'Member'`. Deleting or renaming
|
||||
* one of those is not a data change, it is a code change that has not happened yet — so the delete and
|
||||
* rename paths refuse on this flag rather than discovering it as a runtime failure somewhere else.
|
||||
*/
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
/**
|
||||
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
|
||||
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
|
||||
* becomes a row. Null today, and nothing reads it.
|
||||
* Per-role provisioning, as a closed vocabulary rather than a script.
|
||||
*
|
||||
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
|
||||
* column that arrives with the feature that uses it is a second migration for no gain.
|
||||
* D8 in docs/roles-and-teams.md proposed a `provisioningScript` column: a shell script stored on the row
|
||||
* and run when an account is given the role. That was rejected before it was built — it is arbitrary
|
||||
* root-level code living in a database row, and D8's own warning explains why that is worse than it
|
||||
* looks: `usermod -aG docker` is root by a longer route
|
||||
* (`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet
|
||||
* seed). A role editable at runtime must not be a way to grant root by writing a string into a form.
|
||||
*
|
||||
* So the row CHOOSES from options the platform code already knows how to carry out safely, the same
|
||||
* shape as `multiarch` on a plugin's `osDependencies` (`src/servers/plugins/manifest.ts`): a fixed list
|
||||
* instead of a free string, so the manifest picks rather than supplies. `os-user-postgres.ts` and
|
||||
* `os-user-docker.ts` still own how each is provisioned; these columns only say whether a given role's
|
||||
* accounts get it.
|
||||
*/
|
||||
provisioningScript: text('provisioning_script'),
|
||||
grantsPostgres: boolean('grants_postgres').notNull().default(false),
|
||||
/** A rootless Docker daemon. See `grantsPostgres` above and `os-user-docker.ts` for what it costs per member. */
|
||||
grantsDocker: boolean('grants_docker').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ITEMS_DIR } from '../data-path';
|
||||
import { capabilityRoots } from '../capability-sources';
|
||||
|
||||
// What the platform tells an agent about the surface it is speaking through.
|
||||
//
|
||||
@@ -107,6 +108,21 @@ export function compileAffordances(affordances: Affordance[]): string | null {
|
||||
}
|
||||
|
||||
/** Read and compile in one step, for callers that only want the string. */
|
||||
export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null {
|
||||
return compileAffordances(loadAffordances(root));
|
||||
/**
|
||||
* Every affordance in force: the owner's own, plus each installed and enabled plugin's.
|
||||
*
|
||||
* Read in place rather than copied in — see `capability-sources.ts`. Unlike skills these are NOT
|
||||
* de-duplicated by name: an affordance is a paragraph of guidance, several can be true at once, and two
|
||||
* plugins describing the same surface differently is information rather than a collision.
|
||||
*/
|
||||
export function loadAffordancesAcross(roots: string[]): Affordance[] {
|
||||
return roots.flatMap((root) => loadAffordances(root));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and compile in one step. Async because resolving which plugins are enabled reads the database;
|
||||
* called once per session, which is what makes that affordable.
|
||||
*/
|
||||
export async function affordanceBlock(): Promise<string | null> {
|
||||
return compileAffordances(loadAffordancesAcross(await capabilityRoots('affordances')));
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test, afterEach } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { affordanceDirFor, affordanceSourceFor, publishAffordances, unpublishAffordances } from './publish';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// The failure that matters is not a copy going wrong — it is uninstall taking somebody else's files with
|
||||
// it. Every first-party plugin publishes under one `officerdev/` directory, so removing the wrong level
|
||||
// would strip them all and nobody would notice until an agent stopped knowing something.
|
||||
|
||||
const root = join(tmpdir(), `aff-pub-${process.pid}`);
|
||||
|
||||
const plugin = (appName: string, publisher = 'officerdev'): DiscoveredPlugin =>
|
||||
({ appName, dir: join(root, appName), manifest: { publisher } }) as DiscoveredPlugin;
|
||||
|
||||
const withAffordance = (appName: string, publisher = 'officerdev') => {
|
||||
const p = plugin(appName, publisher);
|
||||
mkdirSync(affordanceSourceFor(p), { recursive: true });
|
||||
writeFileSync(join(affordanceSourceFor(p), `${appName}.md`), `---\nname: ${appName}\n---\n\nbody\n`);
|
||||
return p;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
for (const pub of ['officerdev', 'alice']) rmSync(join(AFFORDANCES_DIR, pub), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('publishing', () => {
|
||||
test('lands under <publisher>/<appName>, mirroring mountPrefix', () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(affordanceDirFor(p)).toBe(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea'));
|
||||
});
|
||||
|
||||
test('copies what the plugin ships and counts it', async () => {
|
||||
expect(await publishAffordances(withAffordance('test-gitea'))).toBe(1);
|
||||
});
|
||||
|
||||
test('a plugin shipping none is not a failure — most ship none', async () => {
|
||||
const bare = plugin('test-bare');
|
||||
mkdirSync(bare.dir, { recursive: true });
|
||||
expect(await publishAffordances(bare)).toBe(0);
|
||||
});
|
||||
|
||||
test('re-publishing overwrites rather than erroring, so re-install works', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
});
|
||||
|
||||
test('two publishers with the same plugin name do not collide', async () => {
|
||||
await publishAffordances(withAffordance('test-gitea', 'officerdev'));
|
||||
await publishAffordances(withAffordance('test-gitea', 'alice'));
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'alice', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishing', () => {
|
||||
test('removes the plugin', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
await publishAffordances(p);
|
||||
await unpublishAffordances(p);
|
||||
expect(existsSync(affordanceDirFor(p))).toBe(false);
|
||||
});
|
||||
|
||||
test('leaves the PUBLISHER alone — the thing that would silently strip every sibling', async () => {
|
||||
const a = withAffordance('test-gitea');
|
||||
const b = withAffordance('test-music');
|
||||
await publishAffordances(a);
|
||||
await publishAffordances(b);
|
||||
await unpublishAffordances(a);
|
||||
expect(existsSync(affordanceDirFor(b))).toBe(true);
|
||||
});
|
||||
|
||||
test('is safe when nothing was ever published', async () => {
|
||||
await unpublishAffordances(plugin('test-never'));
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Copying a plugin's affordances into the store the agent reads.
|
||||
//
|
||||
// A plugin is a new SURFACE arriving at runtime, which is the case hand-written prose could never cover:
|
||||
// install gitea and no agent learns that `tea` is authenticated, or that `git` is the better tool for
|
||||
// what git already does. The plugin knows; nothing was carrying that across.
|
||||
//
|
||||
// Same shape as `publishAssets` — a directory in the plugin repository, copied to a platform-managed
|
||||
// place on install, removed on uninstall. Deliberately so: two mechanisms for "the plugin ships some
|
||||
// files" would drift, and this one is already understood.
|
||||
//
|
||||
// ── Why the destination is namespaced ──
|
||||
//
|
||||
// `<publisher>/<appName>/`, mirroring `mountPrefix`. Two plugins called `gitea` from different publishers
|
||||
// must not overwrite each other's affordances, and an affordance in the tree should say whose it is
|
||||
// without opening it. `core` occupies the appName slot for the platform's own.
|
||||
|
||||
/** Where a plugin's affordances land. `officerdev/gitea/`, beside `officer/core/`. */
|
||||
export const affordanceDirFor = (plugin: DiscoveredPlugin): string =>
|
||||
join(AFFORDANCES_DIR, plugin.manifest.publisher, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const affordanceSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'affordances');
|
||||
|
||||
/**
|
||||
* Publish a plugin's affordances. Returns the number of files, or 0 when it ships none — which is the
|
||||
* ordinary case and not a failure.
|
||||
*
|
||||
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT
|
||||
* remove files the plugin has since dropped; that is what uninstall is for, and deleting on every
|
||||
* install would make a half-finished copy destructive.
|
||||
*/
|
||||
export async function publishAffordances(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = affordanceSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
const dest = affordanceDirFor(plugin);
|
||||
await cp(source, dest, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => f.endsWith('.md')).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's affordances.
|
||||
*
|
||||
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
||||
* plugin's, and taking it out because one was uninstalled would silently strip the rest.
|
||||
*
|
||||
* Safe to call when nothing was ever published.
|
||||
*/
|
||||
export async function unpublishAffordances(plugin: DiscoveredPlugin): Promise<void> {
|
||||
await rm(affordanceDirFor(plugin), { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { DATA_PATH, getCommandersDir } from '../../data-path';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import {
|
||||
appendTextAs,
|
||||
findPathAs,
|
||||
@@ -120,10 +120,13 @@ export type ClaudeSessionSummary = {
|
||||
* it has to be able to filter commanders out of that gesture itself. A flag only the web knew about
|
||||
* would fix one surface and leave the other wrong.
|
||||
*
|
||||
* Derived from the CWD rather than from a database join. Every commander a user has shares one
|
||||
* directory — `getCommandersDir(email)`, never per-commander and never client-chosen — and this list
|
||||
* already reads each session's `cwd`. So it costs a string comparison, works for a commander whose
|
||||
* `command_hq_sessions` row was lost, and adds no query.
|
||||
* Optional and never set by this file — every commander now has its OWN directory
|
||||
* (`docs/commander-directory.md`), so there is no single path this list could compare a `cwd` against
|
||||
* the way it once did when every commander shared one. The real answer is a `command_hq_sessions` row
|
||||
* lookup, which needs a database call this module does not make; `chat.ts` computes it that way and
|
||||
* spreads it onto the rows it returns — see its own comment on why `isCommander` is deliberately absent
|
||||
* from THIS module's output. The field stays on the type because other callers read it optionally off
|
||||
* whatever `ClaudeSessionSummary` they were handed, undefined being the honest answer when nobody set it.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
};
|
||||
@@ -1019,26 +1022,6 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this the default commanders directory?
|
||||
*
|
||||
* **No longer the test for whether something is a commander** — that is the `command_hq_sessions` row, since
|
||||
* a commander now chooses its own working directory. This remains only for the places that legitimately care
|
||||
* about that specific path: creating it for a new OS account, and as the historical location of commanders
|
||||
* created before the cwd was choosable.
|
||||
*
|
||||
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
|
||||
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
|
||||
* whatever the process was actually in.
|
||||
*/
|
||||
export function isCommandersDir(email: string, cwd: string): boolean {
|
||||
try {
|
||||
return resolve(cwd) === resolve(getCommandersDir(email));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-shaped summary, found by id wherever it lives.
|
||||
*
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { resolve } from 'node:path';
|
||||
import { isCommandersDir } from './claude-sessions';
|
||||
import { getCommandersDir } from '../../data-path';
|
||||
|
||||
const EMAIL = 'pastilhas@officer.dev';
|
||||
|
||||
// The live payload's `isCommander` is derived from a cwd read out of a transcript, so it is whatever the
|
||||
// process was actually in — not a path this code composed. These are the shapes that arrive.
|
||||
describe('isCommandersDir', () => {
|
||||
const dir = getCommandersDir(EMAIL);
|
||||
|
||||
it('recognises the commanders directory', () => {
|
||||
expect(isCommandersDir(EMAIL, dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('survives a trailing slash and a .. hop', () => {
|
||||
expect(isCommandersDir(EMAIL, `${dir}/`)).toBe(true);
|
||||
expect(isCommandersDir(EMAIL, `${dir}/sub/..`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a sibling and a child', () => {
|
||||
expect(isCommandersDir(EMAIL, resolve(dir, '..'))).toBe(false);
|
||||
expect(isCommandersDir(EMAIL, resolve(dir, 'nested'))).toBe(false);
|
||||
});
|
||||
|
||||
// A commander's cwd belongs to the account that owns it. Reading one user's directory as another's
|
||||
// would file their commanders under the wrong person and, in the iPhone swipe order, show them.
|
||||
it('is per account', () => {
|
||||
expect(isCommandersDir('someone-else@officer.dev', dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('never throws on rubbish', () => {
|
||||
expect(isCommandersDir(EMAIL, '')).toBe(false);
|
||||
expect(isCommandersDir(EMAIL, '\0')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -77,19 +77,14 @@ export type ClientMessage =
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* The name the owner chose for a COMMANDER before its first message.
|
||||
*
|
||||
* Claude Code will not accept a name for a new session — the harness names its own from the first
|
||||
* prompt — so the platform renames the transcript the moment it exists. Carried on the turn rather
|
||||
* than sent afterwards because it must not depend on a browser still being open: the name was chosen
|
||||
* before anything was said, and losing it to a closed tab would leave a commander called after
|
||||
* whatever it happened to say first.
|
||||
*
|
||||
* Applied once, by `fileAsCommander`, via `renameClaudeSession` — which writes a `summary` record and
|
||||
* therefore an EXPLICIT title, and an explicit title is never overwritten by `ai-title` or by the
|
||||
* first-message fallback.
|
||||
* The COMMANDER row this turn belongs to — `command_hq_sessions.id`. Set by `POST /command-hq/
|
||||
* commanders`, which creates the row (with its name, directory and `CLAUDE.md`) BEFORE this turn ever
|
||||
* starts — see `docs/commander-directory.md`. Carried on the turn so the server can point that row at
|
||||
* the transcript id the moment one exists (`fileAsCommander`, `websocket.ts`) and so `resolveChatCwd`
|
||||
* can resolve this turn's `cwd` to the commander's own directory without trusting a client-chosen
|
||||
* `cwd` for it.
|
||||
*/
|
||||
commanderName?: string;
|
||||
commanderId?: number;
|
||||
/**
|
||||
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
|
||||
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
|
||||
|
||||
@@ -15,7 +15,12 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from 'path';
|
||||
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
|
||||
import {
|
||||
getOwnerHomeDir,
|
||||
getEmailAccountsDir,
|
||||
findEmailAccountName,
|
||||
getCommanderDir,
|
||||
} from '../../../servers/data-path';
|
||||
import { attachedFilePaths } from './attached-files';
|
||||
import {
|
||||
getUserSettings,
|
||||
@@ -24,16 +29,15 @@ import {
|
||||
getUserById,
|
||||
setChatSessionModel,
|
||||
setChatSessionPlanMode,
|
||||
createCommandHqSession,
|
||||
setCommanderSessionId,
|
||||
getCommandHqSession,
|
||||
getCommandHqSessionById,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
recordCommandHqTurn,
|
||||
} from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
|
||||
import { chatIdentity } from './chat';
|
||||
import { commanderBlock } from '../command-hq/commander-block';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { logger } from './logger';
|
||||
@@ -146,30 +150,44 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi
|
||||
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
||||
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
|
||||
async function resolveChatCwd(
|
||||
msg: { context?: string; contextId?: string; cwd?: string },
|
||||
msg: { context?: string; contextId?: string; cwd?: string; resumeSessionId?: string; commanderId?: number },
|
||||
email: string,
|
||||
userId: number,
|
||||
home: string,
|
||||
): Promise<string> {
|
||||
/*
|
||||
A commander runs where the owner chose, and the choice is required.
|
||||
A commander runs in its OWN directory — resolved here, server-side, never trusted from `msg.cwd`.
|
||||
|
||||
This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
|
||||
empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
|
||||
Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
|
||||
works on the platform can live in the platform checkout, where its relative paths mean something and its
|
||||
transcripts sit beside the work they are about.
|
||||
This honoured a client-chosen `msg.cwd` from 2026-08-22 until `docs/commander-directory.md` reversed
|
||||
that call two days later, and the reversal is not just "a crafted message could misdirect a commander" —
|
||||
the CONTAINMENT reasoning that made a client-chosen path safe (`resolveCwd(home, …)`, so "client-chosen"
|
||||
never meant "anywhere") still holds, it just no longer applies, because the client has nothing left to
|
||||
choose. `POST /command-hq/commanders` creates the row, ITS directory and `CLAUDE.md` before any turn
|
||||
spawns, so this only ever has to look the directory back up: by `commanderId` on the turn that just
|
||||
created the row (no transcript id yet to look up by), or by the transcript id (`resumeSessionId`, or the
|
||||
officer session's own resume id on a later turn of the same conversation) once one exists. Either lookup
|
||||
lands on the same row, and `getCommanderDir(email, row.dirName)` is the one true answer either way.
|
||||
|
||||
The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch
|
||||
below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside
|
||||
the account's own tree by a crafted message.
|
||||
|
||||
No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the
|
||||
first message can be sent, and a silent default would be a commander running somewhere nobody picked.
|
||||
Falling back to the account's home is the honest last resort for a caller that predates the requirement.
|
||||
Falls back to the account's home only when NEITHER is given, which should not happen once every path
|
||||
pre-creates the row — the honest last resort for a caller that predates it, not a real branch anybody
|
||||
expects to take.
|
||||
*/
|
||||
if (msg.context === 'command-hq') {
|
||||
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
|
||||
const row =
|
||||
msg.commanderId !== undefined
|
||||
? await getCommandHqSessionById(userId, msg.commanderId)
|
||||
: msg.resumeSessionId
|
||||
? await getCommandHqSession(userId, msg.resumeSessionId)
|
||||
: null;
|
||||
if (!row) {
|
||||
// Loud, because the degradation is otherwise invisible: a commander running in the account's home has
|
||||
// no CLAUDE.md, so it knows nothing about itself and asks the owner what its job is on every turn —
|
||||
// which reads as the agent being forgetful rather than as a lookup that missed.
|
||||
console.warn(
|
||||
`[command-hq] no commander row for ${msg.commanderId !== undefined ? `id ${msg.commanderId}` : `session ${msg.resumeSessionId ?? '(none given)'}`} — falling back to the account home`,
|
||||
);
|
||||
}
|
||||
const dir = row ? getCommanderDir(email, row.dirName) : home;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
@@ -358,14 +376,12 @@ function createMessageHandler(
|
||||
planMode?: boolean,
|
||||
commandHq?: boolean,
|
||||
/**
|
||||
* The name the owner chose before the conversation started, for a commander.
|
||||
*
|
||||
* Claude Code will not accept a name for a NEW session — the harness names its own — so the platform
|
||||
* renames it the moment the transcript exists. Done here rather than by the client because it must not
|
||||
* depend on a browser still being open: the name was chosen before the first message, and losing it to a
|
||||
* closed tab would leave a commander called after whatever it happened to say first.
|
||||
* The commander row THIS turn belongs to — `command_hq_sessions.id`, created by `POST /commanders`
|
||||
* before this turn ever started (`docs/commander-directory.md`). Used to point that row at the transcript
|
||||
* id the moment one exists, so a commander's own row and directory (name, `CLAUDE.md`, `dirName`) are
|
||||
* never something this handler creates — only something it finishes wiring up.
|
||||
*/
|
||||
commanderName?: string,
|
||||
commanderId?: number,
|
||||
) {
|
||||
/*
|
||||
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
|
||||
@@ -451,68 +467,33 @@ function createMessageHandler(
|
||||
};
|
||||
|
||||
/*
|
||||
File this session as a commander, once the transcript id is known — the same wait `record` above is
|
||||
built around, and for the same reason: there is nothing to key the row on before the harness announces
|
||||
it. Gated on `commandHq`, which the caller sets only when `msg.context === 'command-hq'`, so an
|
||||
ordinary `/chat` turn never touches this table.
|
||||
Point this commander's row at its transcript id, once the transcript id is known — the same wait
|
||||
`record` above is built around, and for the same reason: there is nothing to key the write on before the
|
||||
harness announces it. Gated on `commandHq && commanderId`, so an ordinary `/chat` turn never touches
|
||||
this table, and a commander turn with no id (should not happen once every caller pre-creates the row —
|
||||
see `docs/commander-directory.md`) is a no-op rather than a guess at creating one.
|
||||
|
||||
Idempotent by construction (`createCommandHqSession` upserts on `(user_id, session_id)`), so calling it
|
||||
again on every later turn of the same commander — which happens, because the client keeps sending
|
||||
`context: 'command-hq'` for `resolveChatCwd` to keep resolving the right directory — costs one no-op
|
||||
upsert rather than a duplicate row. That repetition is also a cheap self-heal: if the row were ever lost,
|
||||
the next turn puts it back.
|
||||
`setCommanderSessionId` is a plain UPDATE, not an upsert: the row, its directory and its name all exist
|
||||
already, written by `POST /commanders` before this turn started. There is nothing left to create here —
|
||||
only a pointer to set, which is why this is so much smaller than the `fileAsCommander` it replaces used
|
||||
to be. Idempotent by construction (an UPDATE by id), so calling it again on every later turn of the same
|
||||
commander costs one no-op write, and is a cheap self-heal if the pointer were ever lost.
|
||||
|
||||
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
|
||||
reason a chat turn fails.
|
||||
*/
|
||||
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
|
||||
// the retry note inside `fileAsCommander`.
|
||||
let commanderNamed = false;
|
||||
|
||||
const fileAsCommander = (): void => {
|
||||
if (!commandHq || !transcriptId) return;
|
||||
if (!commandHq || !transcriptId || commanderId === undefined) return;
|
||||
const userId = sessionManager.getSession(sessionId)?.userId;
|
||||
if (!userId) return;
|
||||
const filedId = transcriptId;
|
||||
// The name goes in with the row, not only to the transcript: the list reads it from here and is
|
||||
// right immediately, rather than waiting on a summary record the harness cannot write until the
|
||||
// first turn has finished.
|
||||
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
|
||||
.then(async () => {
|
||||
/*
|
||||
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
|
||||
|
||||
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
|
||||
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
|
||||
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
|
||||
kept the harness's own `ai-title` — which is exactly what the first live test showed.
|
||||
|
||||
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
|
||||
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
|
||||
|
||||
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
|
||||
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
|
||||
*/
|
||||
if (!commanderName?.trim() || commanderNamed) return;
|
||||
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
|
||||
// decides the home and the OS user, and a rename writes a file as that user.
|
||||
const user = await getUserById(userId).catch(() => null);
|
||||
if (!user?.email) return;
|
||||
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
|
||||
if (!who) return;
|
||||
// `who.home` as the hint, not the old shared commanders directory: a commander chooses its own
|
||||
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
|
||||
// and locates the transcript wherever it actually is — so this works either way, and passing a
|
||||
// path we know is probably wrong would be the kind of thing that reads as intent later.
|
||||
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.warn('Could not file this session as a commander', {
|
||||
sessionId,
|
||||
transcriptId: filedId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
void setCommanderSessionId(userId, commanderId, transcriptId).catch((err: unknown) => {
|
||||
logger.warn('Could not point this commander at its transcript', {
|
||||
sessionId,
|
||||
transcriptId,
|
||||
commanderId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (msg: TurnMessage, seq?: number): void => {
|
||||
@@ -573,9 +554,6 @@ function createMessageHandler(
|
||||
record();
|
||||
fileAsCommander();
|
||||
}
|
||||
// And retried on every result until the name lands. The first attempt runs before the transcript file
|
||||
// exists, so the end of the first turn is the earliest a rename can succeed.
|
||||
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
|
||||
|
||||
/*
|
||||
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
|
||||
@@ -791,11 +769,6 @@ async function handleChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
@@ -931,14 +904,6 @@ export function sessionKeyForTranscript(transcriptId: string): string | undefine
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions already told what they are and where to record it.
|
||||
*
|
||||
* In memory, keyed on officer's session id: a restart re-briefs, which costs a few hundred tokens on one
|
||||
* turn and is the safe direction — a commander that was never told is one that cannot be stopped.
|
||||
*/
|
||||
const briefedSessions = new Set<string>();
|
||||
|
||||
export async function startClaudeTurn(params: {
|
||||
email: string;
|
||||
username: string;
|
||||
@@ -949,11 +914,6 @@ export async function startClaudeTurn(params: {
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -962,8 +922,8 @@ export async function startClaudeTurn(params: {
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
|
||||
commanderName?: string;
|
||||
/** See `commanderId` on the wire type — the row `POST /commanders` already created for this turn. */
|
||||
commanderId?: number;
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
effectivePrompt: string;
|
||||
@@ -972,34 +932,14 @@ export async function startClaudeTurn(params: {
|
||||
const { email, username, userId, ws, sessionId, model, msg, onOutcome } = params;
|
||||
|
||||
/*
|
||||
A commander is told its record paths ON A TURN, not in the system preamble.
|
||||
|
||||
The preamble is built inside `createSession`, which runs ONCE — on a conversation's first turn. For a
|
||||
commander that is precisely the turn where the block cannot exist: it has no `command_hq_sessions` row
|
||||
and no transcript id yet, so `commanderBlock` returns null, and every later turn reuses the same session
|
||||
and never rebuilds the preamble. So a commander never received the block at all, and its loop had no
|
||||
paths to record itself at. Its own test caught it: it armed a perfectly good watcher and wrote no
|
||||
`loop-pid`.
|
||||
|
||||
Riding `effectivePrompt` instead. That is the string the model sees; `displayText` is what the transcript
|
||||
shows — the same seam the background-job dialog already uses to send a hidden preamble — so this reaches
|
||||
the agent without appearing in the conversation.
|
||||
|
||||
Once per session, on the first turn where the block resolves, which is the first turn after the commander
|
||||
is filed. It stays available afterwards because it is in the conversation's own history from then on.
|
||||
Unlike before `docs/commander-directory.md`, nothing is injected into the prompt here for a commander.
|
||||
A commander's own `CLAUDE.md` — written by `POST /commanders`, before this turn ever started — covers
|
||||
what a per-turn block used to have to compile on the fly: its name, its reference path, and the paths
|
||||
it must record itself at. Claude Code reads a `CLAUDE.md` from the working directory at session start,
|
||||
so a commander told about itself in the FILE needs no per-turn injection, no `effectivePrompt` splice,
|
||||
and no first-turn special case — see the deleted `commander-block.ts` for what this replaced and why.
|
||||
*/
|
||||
let effectivePrompt = params.effectivePrompt;
|
||||
if (!briefedSessions.has(sessionId)) {
|
||||
const block = await commanderBlock({
|
||||
userId,
|
||||
sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null,
|
||||
selfStarted: msg.selfStarted === true,
|
||||
});
|
||||
if (block) {
|
||||
briefedSessions.add(sessionId);
|
||||
effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`;
|
||||
}
|
||||
}
|
||||
const effectivePrompt = params.effectivePrompt;
|
||||
const fail = (error: string) => {
|
||||
sendToClient(ws, { type: 'error', message: error });
|
||||
onOutcome?.({ ok: false, error });
|
||||
@@ -1083,7 +1023,7 @@ export async function startClaudeTurn(params: {
|
||||
msg.resumeSessionId,
|
||||
msg.planMode,
|
||||
msg.context === 'command-hq',
|
||||
msg.commanderName,
|
||||
msg.commanderId,
|
||||
);
|
||||
// Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the
|
||||
// exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same
|
||||
@@ -1161,11 +1101,6 @@ async function handleClaudeCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -1189,11 +1124,6 @@ async function handleOpenCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { getCommandHqSession, getUserById } from 'officerdb';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
|
||||
/*
|
||||
What a commander is told about ITSELF, once per session.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
|
||||
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
|
||||
background-job dialog composes. Which was fine while the only way to arm a commander was through that
|
||||
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
|
||||
starting its own background job never sees that preamble, so it would record nothing, and delete would go
|
||||
back to leaving orphaned loops without anybody noticing it had.
|
||||
|
||||
── Why the paths come from here and the method comes from the skill ──
|
||||
|
||||
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
|
||||
material that belongs with everything else about commanders, in the skill, where it can be as long as it
|
||||
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
|
||||
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
|
||||
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
|
||||
exactly what an always-loaded block is for.
|
||||
|
||||
Short for the same reason every affordance is short: it is charged to every turn of every commander.
|
||||
*/
|
||||
|
||||
type CommanderBlockParams = {
|
||||
/** The Officer account, for the row lookup and for the paths. */
|
||||
userId: number;
|
||||
/** Claude's transcript id — what a commander is keyed on everywhere. */
|
||||
sessionId: string | null;
|
||||
/**
|
||||
* The owner pressed Start rather than typing an opening message.
|
||||
*
|
||||
* They supplied a name and a working directory and nothing else, so there is no request to act on and
|
||||
* the commander has to run the interview itself. Only ever true on the turn the button sends — a
|
||||
* commander asked something later is an ordinary conversation and must not restart its intake.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
};
|
||||
|
||||
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
|
||||
if (!params.sessionId) return null;
|
||||
|
||||
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
|
||||
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
|
||||
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
|
||||
if (!row) return null;
|
||||
|
||||
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
|
||||
// session spawn, alongside the affordances and the skills index, which are also read here rather than
|
||||
// cached — the same trade for the same reason.
|
||||
const user = await getUserById(params.userId).catch(() => null);
|
||||
if (!user?.email) return null;
|
||||
const email = user.email;
|
||||
|
||||
return [
|
||||
'# You are a commander',
|
||||
'',
|
||||
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
|
||||
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
|
||||
'establish with the owner first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
|
||||
'',
|
||||
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
|
||||
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
|
||||
'',
|
||||
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves your loop running.',
|
||||
'',
|
||||
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
|
||||
'that is what their absence means. Ask, and arm once it is agreed.',
|
||||
/*
|
||||
The button case. A pointer, not the interview itself: the questions live in the skill, which is
|
||||
re-read from disk on every run, so rewording one costs nothing. The same words here would cost a
|
||||
`pm2 restart officer` every time — and this file is the wrong home for doctrine that will be
|
||||
edited far more often than the mechanism around it.
|
||||
*/
|
||||
...(params.selfStarted
|
||||
? [
|
||||
'',
|
||||
'**Nobody has told you what they want yet.** They gave a name and a working directory and',
|
||||
'pressed Start, which is a request to run the interview — not a request to do anything else.',
|
||||
'Read the **Commanders** skill now and follow "When you started yourself": orient in this',
|
||||
'directory first, then open with what you found and ONE question.',
|
||||
]
|
||||
: []),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { getUserByEmail } from 'officerdb';
|
||||
import { lookupOsUser } from '@@/os-user';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderDir, getCommanderStateDir } from '../../data-path';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
|
||||
/*
|
||||
A commander's own directory, created once, up front — `docs/commander-directory.md`.
|
||||
|
||||
$DATA_PATH/<email>/commanders/<dirName>/
|
||||
CLAUDE.md written here, extended by the commander as the job is agreed
|
||||
state/ INSTANCE data: what it spawned, its loop's pid. Empty at creation.
|
||||
|
||||
Both exist before the first turn spawns, which is the whole point: Claude Code reads a CLAUDE.md from the
|
||||
working directory at session start, so a commander told about itself here needs no per-turn injection —
|
||||
see the deleted `commander-block.ts` for what this replaced.
|
||||
*/
|
||||
|
||||
export type CommanderClaudeMdParams = {
|
||||
name: string;
|
||||
referencePath: string;
|
||||
loopPidPath: string;
|
||||
spawnedPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The file itself. Prose, not a config dump — this is a document the commander reads once and then keeps
|
||||
* extending, and the owner may open it directly.
|
||||
*
|
||||
* Deliberately does NOT carry the button-specific framing `commander-block.ts` used to add on a
|
||||
* `selfStarted` turn ("nobody has told you what they want yet — they pressed Start"). That framing is
|
||||
* conditional on how THIS conversation happened to begin, which a file written once and read on every
|
||||
* future session cannot express — and it would be actively wrong the day a fresh conversation replaces a
|
||||
* dead one in this same directory, or if the opening message was typed rather than a button press. The
|
||||
* button case is still covered: `CommandHqDetail.tsx`'s `startBrief` composes that framing into the
|
||||
* opening message itself, which is the right place for something true only of one specific turn.
|
||||
*/
|
||||
export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
return [
|
||||
`# ${params.name}`,
|
||||
'',
|
||||
'This is a commander: an agent that keeps working when nobody is watching. This file is its own —',
|
||||
'extend it as the job gets settled, and again whenever what it watches or what it does about that',
|
||||
'changes. Open the **Commanders** skill before starting or changing a watcher; it carries how the',
|
||||
'pieces fit, what to establish first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'## What this watches',
|
||||
'',
|
||||
`Reference: \`${params.referencePath}\``,
|
||||
'',
|
||||
'That path is what this commander operates ON, not where it lives — this directory is its own, kept',
|
||||
'separate from the thing it watches. Read it before asking what the job is: it was named before',
|
||||
'anything else was said, so treat it as the subject rather than decoration. More references can be',
|
||||
'added here as they come up in conversation.',
|
||||
'',
|
||||
'## Recording itself',
|
||||
'',
|
||||
'Two paths only this commander can be told, and it must write to them or it cannot be stopped:',
|
||||
'',
|
||||
`- its loop's own pid: \`${params.loopPidPath}\``,
|
||||
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\``,
|
||||
'',
|
||||
'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves its loop running.',
|
||||
'',
|
||||
'## Before arming anything',
|
||||
'',
|
||||
'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. Ask, and arm once it is agreed.',
|
||||
'',
|
||||
'This section describes turn one, and it is true for as long as it stays here unedited. Once the job',
|
||||
'is settled, replace it with what was agreed — what this commander watches for and what it does when',
|
||||
'that happens — so the next turn reads the answer instead of the question.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a commander's directory and write its `CLAUDE.md`. Called once, by `POST /command-hq/commanders`,
|
||||
* before any turn spawns — that ordering is the entire fix `docs/commander-directory.md` describes.
|
||||
*
|
||||
* `state/` is created empty rather than left to be created lazily on first write (as `getCommanderStateDir`
|
||||
* callers already do defensively): creating it here means the directory listing a commander's first turn
|
||||
* sees is complete from the start, definition and state both present, rather than state/ appearing later
|
||||
* as an unexplained addition.
|
||||
*/
|
||||
/**
|
||||
* Give the new directory to the account that will work in it.
|
||||
*
|
||||
* `confineUserTree` chowns `commanders/` to the member ONCE, at provisioning. Anything created after that
|
||||
* belongs to the service user — so a member's commander directory would be one their own `claude` cannot
|
||||
* write into, and the failure surfaces as an agent that dies on its first command in a directory it can
|
||||
* see but not use. Exactly the `general_chat_sessions` mistake `resolveChatCwd` already tells the story
|
||||
* about, one level deeper.
|
||||
*
|
||||
* A no-op for the owner, whose account has no `osUser` and who already owns everything under `DATA_PATH`
|
||||
* — which is why this was invisible: it works perfectly for the person most likely to test it.
|
||||
*
|
||||
* Best effort. A commander whose directory could not be handed over still exists and still works for the
|
||||
* owner; refusing to create it would be the worse trade. The failure is returned so the caller can say so.
|
||||
*/
|
||||
async function giveToAccount(email: string, dir: string): Promise<string | null> {
|
||||
const user = await getUserByEmail(email).catch(() => null);
|
||||
if (!user?.osUser) return null;
|
||||
|
||||
const ids = await lookupOsUser(user.osUser);
|
||||
if (!ids) return `no Linux account named ${user.osUser}`;
|
||||
|
||||
const proc = Bun.spawn(['sudo', '-n', 'chown', '-R', `${ids.uid}:${ids.gid}`, dir], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const err = await new Response(proc.stderr).text();
|
||||
return (await proc.exited) === 0 ? null : err.trim() || 'chown failed';
|
||||
}
|
||||
|
||||
export async function createCommanderDirectory(
|
||||
email: string,
|
||||
dirName: string,
|
||||
params: { name: string; referencePath: string },
|
||||
): Promise<{ dir: string; ownershipError: string | null }> {
|
||||
const dir = getCommanderDir(email, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirSync(getCommanderStateDir(email, dirName), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'CLAUDE.md'),
|
||||
commanderClaudeMd({
|
||||
name: params.name,
|
||||
referencePath: params.referencePath,
|
||||
loopPidPath: loopPidPath(email, dirName),
|
||||
spawnedPath: spawnedRecordPath(email, dirName),
|
||||
}),
|
||||
);
|
||||
|
||||
// Last, so it covers CLAUDE.md and state/ as well as the directory itself.
|
||||
const ownershipError = await giveToAccount(email, dir);
|
||||
return { dir, ownershipError };
|
||||
}
|
||||
@@ -1,25 +1,19 @@
|
||||
import type { Context } from 'hono';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { toShellUsername, getCommanderDir } from '@@/data-path';
|
||||
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren, sessionKeyForTranscriptId } from 'officerdb';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||
import {
|
||||
clearSpawnedRecord,
|
||||
listSpawnedSessions,
|
||||
loopPidPath,
|
||||
spawnedRecordPath,
|
||||
stopCommanderProcesses,
|
||||
liveLoopPid,
|
||||
} from './spawned';
|
||||
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
|
||||
import { createCommanderDirectory } from './directory';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
loadClaudeSessionById,
|
||||
loadClaudeSessionSummaryById,
|
||||
renameClaudeSession,
|
||||
@@ -33,7 +27,8 @@ import {
|
||||
createCommandHqCategory,
|
||||
renameCommandHqCategory,
|
||||
deleteCommandHqCategory,
|
||||
createCommandHqSession,
|
||||
createCommander,
|
||||
setCommanderSessionId,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
@@ -67,17 +62,26 @@ import {
|
||||
*
|
||||
* ── Where commanders live ──
|
||||
*
|
||||
* **Wherever the owner chose.** Each commander names its own working directory when it is created, and that
|
||||
* directory is where its relative paths resolve and where its transcripts are grouped.
|
||||
* **Its own directory, created by this router before any turn spawns.** `POST /commanders` creates the row
|
||||
* AND `$DATA_PATH/<email>/commanders/<dirName>/` together, writes `CLAUDE.md`, and only then does the
|
||||
* caller start a turn with `cwd` set to that directory — see `docs/commander-directory.md` for why the
|
||||
* ordering is the entire fix.
|
||||
*
|
||||
* It was one shared directory per user until 2026-08-22, because the directory WAS the identity — the only
|
||||
* way to tell a commander from an ordinary chat. Identity is the `command_hq_sessions` row now, so the
|
||||
* directory became free to be useful, and a commander that works on the platform can live in the platform.
|
||||
* Two earlier designs are worth knowing so this one is not mistaken for a third try at the same idea. One
|
||||
* shared directory per user, until 2026-08-22, because the directory WAS the identity — the only way to
|
||||
* tell a commander from an ordinary chat, which is also what made this router's own listing impossible to
|
||||
* do any other way than a directory scan. Then, briefly, a commander chose an arbitrary directory anywhere
|
||||
* in its owner's tree, once identity moved to the `command_hq_sessions` row — free to be useful, but free
|
||||
* of a directory a commander could depend on existing, or write anything durable into before its first
|
||||
* turn even started. This router owning creation is what fixes both: identity is still the row, and the
|
||||
* directory is still guaranteed to exist and be this commander's own before anything runs in it.
|
||||
*
|
||||
* The consequence for this router is that listing is no longer a directory scan. It reads its rows and loads
|
||||
* each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk. A row
|
||||
* whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in the old scan
|
||||
* was, and `POST /purge` is what tidies those.
|
||||
* The consequence for this router is that listing is not a directory scan either way. It reads its rows and
|
||||
* loads each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk.
|
||||
* A row whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in a scan
|
||||
* would have been, and `POST /purge` is what tidies those. A row with no transcript YET (freshly created,
|
||||
* before its first turn) is treated the same way — absent from the grid until it has one — which is fine:
|
||||
* the composer that just created it already has everything it needs to keep going without a grid entry.
|
||||
*/
|
||||
|
||||
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
@@ -213,29 +217,29 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory.
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
||||
directory holds `CLAUDE.md` and `state/`, never a transcript, so there is nothing to scan there any more
|
||||
either. One `locateTranscript` per row instead. Commanders are few — this is a handful of stats, not a
|
||||
directory walk per row — and a row whose transcript cannot be found anywhere is treated as absent,
|
||||
which is what `purgeCommandHqSessions` exists to tidy.
|
||||
|
||||
It used to be `listClaudeSessions(who, getCommandersDir(...))` — every commander lived in one place, so
|
||||
one scan found them all and a row without a match there was a row whose transcript had been deleted. A
|
||||
commander chooses its own working directory now, so that scan finds only the ones that happen to still
|
||||
live in the old shared folder and would silently hide every other.
|
||||
|
||||
One `locateTranscript` per row instead of one scan for all of them. Commanders are few — this is a
|
||||
handful of stats, not a directory walk per row — and a row whose transcript cannot be found anywhere is
|
||||
still treated exactly as before: absent, which is what `purgeCommandHqSessions` exists to tidy.
|
||||
A row with `sessionId: null` — created but never yet turned — is absent from this grid for the same
|
||||
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
||||
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const summaries = rows
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId))
|
||||
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||
const summaries = withTranscript
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
||||
.filter((s): s is NonNullable<typeof s> => !!s);
|
||||
|
||||
const liveById = new Map(summaries.map((s) => [s.id, s]));
|
||||
const filed = rows.filter((row) => liveById.has(row.sessionId));
|
||||
const filed = withTranscript.filter((row) => liveById.has(row.sessionId!));
|
||||
|
||||
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
|
||||
listCommandHqSessionTags(filed.map((row) => row.id)),
|
||||
getLastFiredAtBySessionRowIds(filed.map((row) => row.id)),
|
||||
Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)).then(
|
||||
Promise.all(filed.map(async (row) => [row.sessionId!, await isRunning(row.sessionId!)] as const)).then(
|
||||
(pairs) => new Map(pairs),
|
||||
),
|
||||
// One aggregate query per commander, the same cost class `isRunning` above already accepts for this
|
||||
@@ -248,18 +252,21 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is filed, while the
|
||||
// summary record cannot exist until the first turn ends — so until then the transcript still reports
|
||||
// the opening brief, and the row is the only source that is right from the start.
|
||||
...liveById.get(row.sessionId!)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is CREATED, before any
|
||||
// turn runs, while the transcript's own summary record cannot exist until the first turn ends — so
|
||||
// until then the transcript still reports the opening brief, and the row is the only source that is
|
||||
// right from the start.
|
||||
...(row.name ? { title: row.name } : {}),
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
running: runningById.get(row.sessionId!) ?? false,
|
||||
// The pid file is the FACT; `watcher_armed` is a claim only one path sets. A commander that started
|
||||
// its loop as a plain background job recorded its pid correctly and still reported unarmed, so the
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible.
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||
// beside the transcript.
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -271,26 +278,31 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders/new { prompt: string, displayText?: string, categoryId?: number | null }
|
||||
* POST /command-hq/commanders/new { prompt: string, name: string, referencePath: string, displayText?:
|
||||
* string, categoryId?: number | null }
|
||||
*
|
||||
* Creates a commander for a client that cannot open a websocket with the right parameters — mobile,
|
||||
* notably. The web composer never calls this: it sends an ordinary `/chat` turn with `context:
|
||||
* 'command-hq'`, and the row is written the moment the harness reports a transcript id (see
|
||||
* `websocket.ts` → `fileAsCommander`). This route drives the SAME path — `startClaudeTurn`, the function
|
||||
* that logic was extracted into — with no live socket, and waits for that same id before answering.
|
||||
* notably. The web composer never calls this: it starts a chat turn directly, in the directory
|
||||
* `POST /commanders` (below) already created for it. This route drives the identical two-step order for a
|
||||
* caller with no live socket — create the row and its directory FIRST, via the same `createCommander` +
|
||||
* `createCommanderDirectory` this router's bare `POST /commanders` uses, THEN start the turn with `cwd` set
|
||||
* to that directory — rather than a second copy that could drift from it. See
|
||||
* `docs/commander-directory.md` for why the ordering is the fix and not an implementation detail.
|
||||
*
|
||||
* **This changed shape 2026-08-24.** `name` and `referencePath` are now required, and `cwd` is no longer
|
||||
* accepted as the actual working directory — a commander runs in its OWN directory now, never one a caller
|
||||
* names. Neither client had been exercised against a running server yet when this changed (`docs/command-
|
||||
* hq.md`), so there is no live caller to keep compatible with the old shape.
|
||||
*
|
||||
* `prompt` is required, deliberately. A commander begins as an ordinary conversation — sometimes a real
|
||||
* back-and-forth before a background job is ever armed, sometimes just "you know what to do" once that
|
||||
* has already happened elsewhere — but it always begins with something, the same as any `/chat`
|
||||
* conversation does. There is no "empty commander" to create and later fill in.
|
||||
*
|
||||
* `displayText`, separately, is what TITLES the commander — found missing here by the mobile side after
|
||||
* this route first shipped without it. A commander created to become a background job sends the full
|
||||
* `buildBackgroundJobPrompt` mechanics as `prompt` (a page of preamble) with the owner's actual sentence
|
||||
* only at the end, and `command_hq_sessions` has no name column — the title comes from
|
||||
* `session.meta.title = (displayText ?? prompt).slice(0, 100)`, same precedence the websocket path
|
||||
* already uses. Without this field every commander created this way titles itself with the mechanics
|
||||
* text, and the grid becomes a column of identical, unreadable cards.
|
||||
* `displayText`, separately, is what the TRANSCRIPT shows for the opening message — the row's own `name`
|
||||
* already titles the commander everywhere Command HQ reads it, but the harness still names its own summary
|
||||
* record from the first prompt, and `prompt` here is often the full `buildBackgroundJobPrompt` mechanics
|
||||
* rather than something a person would want to read back.
|
||||
*
|
||||
* The response's `sessionId` is Claude's own transcript id — not a placeholder, not Officer's — so the
|
||||
* client has something it can immediately resume, attach to, or list among its commanders. Getting it
|
||||
@@ -301,13 +313,31 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
*/
|
||||
commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }>()
|
||||
.json<{
|
||||
prompt?: string;
|
||||
displayText?: string;
|
||||
categoryId?: number | null;
|
||||
name?: string;
|
||||
referencePath?: string;
|
||||
}>()
|
||||
.catch(
|
||||
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
|
||||
() =>
|
||||
({}) as {
|
||||
prompt?: string;
|
||||
displayText?: string;
|
||||
categoryId?: number | null;
|
||||
name?: string;
|
||||
referencePath?: string;
|
||||
},
|
||||
);
|
||||
const prompt = body.prompt?.trim();
|
||||
const name = body.name?.trim();
|
||||
const referencePath = body.referencePath?.trim();
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
// ── Room to run, before starting work nobody is watching ──
|
||||
//
|
||||
@@ -320,6 +350,10 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const budget = await checkBudget();
|
||||
if (!budget.ok) throw errors.TOO_MANY_REQUESTS(budget.reason ?? 'not enough usage headroom to start an agent');
|
||||
|
||||
// Row and directory first — see `POST /commanders` below for the same two calls with the full reasoning.
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const dir = createCommanderDirectory(who.email, row.dirName, { name, referencePath });
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL;
|
||||
|
||||
@@ -345,12 +379,10 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
prompt,
|
||||
displayText: body.displayText?.trim() || undefined,
|
||||
context: 'command-hq',
|
||||
// Where this commander runs, and its name. Both are chosen before the first message — the UI makes
|
||||
// them mandatory — so this programmatic path takes them too rather than inventing a default. An
|
||||
// absent cwd falls back to the account's home in `resolveChatCwd`, which is the honest last resort
|
||||
// for a caller that predates the requirement.
|
||||
...(body.cwd?.trim() ? { cwd: body.cwd.trim() } : {}),
|
||||
...(body.name?.trim() ? { commanderName: body.name.trim() } : {}),
|
||||
// No `cwd` here: `resolveChatCwd` resolves the commander's directory itself from `commanderId`
|
||||
// (`docs/commander-directory.md`), the same as the web composer's turn does. `dir`, computed above,
|
||||
// is used only for the JSON response below.
|
||||
commanderId: row.id,
|
||||
},
|
||||
effectivePrompt: prompt,
|
||||
onOutcome: settle,
|
||||
@@ -360,20 +392,15 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
if (!outcome.ok) throw errors.INTERNAL_SERVER_ERROR(outcome.error);
|
||||
|
||||
/*
|
||||
The row already exists — `fileAsCommander` wrote it, off the same `session:claude` event this promise
|
||||
resolved on. What might not have landed yet is the CATEGORY: that write is fire-and-forget and always
|
||||
files with none, so a race between it and this request is possible in either order. `createCommandHqSession`
|
||||
is an upsert-on-conflict-do-nothing (safe either way), and the `setCommandHqSessionCategory` UPDATE below
|
||||
is unconditional and runs after both, which is what makes the outcome deterministic regardless of which
|
||||
of the two insert attempts actually won: whichever category this request asked for is the one that is
|
||||
left standing.
|
||||
Belt and braces: `fileAsCommander` (`websocket.ts`) already points this row at the transcript id off the
|
||||
same `session:claude` event this promise resolved on, but that write is fire-and-forget so it is not
|
||||
guaranteed to have landed by the time this handler resumes. `setCommanderSessionId` is a plain,
|
||||
idempotent UPDATE — calling it again here costs nothing and guarantees the row is correct before this
|
||||
response goes out, rather than trusting a race this handler has no way to observe.
|
||||
*/
|
||||
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommanderSessionId(user.id, row.id, outcome.transcriptId);
|
||||
|
||||
// The cwd it ACTUALLY ran in, from the spawn outcome — not a guess at the old shared directory, which is
|
||||
// no longer where a commander necessarily lives.
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: body.cwd?.trim() ?? null });
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: dir, id: row.id, dirName: row.dirName });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -402,10 +429,11 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
referencePaths: row.referencePaths,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -446,36 +474,47 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
|
||||
* POST /command-hq/commanders { name: string, referencePath: string, categoryId?: number | null }
|
||||
*
|
||||
* Files an EXISTING session as a commander. Idempotent — filing an already-filed session is a no-op, and
|
||||
* does not move it to a different category (use PATCH for that).
|
||||
* Creates a commander. **This is the creation path now, and it changed shape 2026-08-24** — it used to file
|
||||
* an EXISTING session (`{ sessionId, categoryId? }`), which is the wrong order per
|
||||
* `docs/commander-directory.md`: a chat session existed first and was filed as a commander second, so the
|
||||
* working directory was fixed before any commander id existed and a commander could never start in a
|
||||
* directory named after itself. There is no live caller of the old shape to keep compatible with — see the
|
||||
* design doc's "Order of work".
|
||||
*
|
||||
* This is a low-level primitive, not the normal creation path. The normal path is starting a new commander
|
||||
* through Command HQ's own composer, which writes this row itself the moment the harness reports a real
|
||||
* transcript id — see `docs/command-hq.md` → "How a commander is created". This route exists for that
|
||||
* wiring to call, and for anything else (a purge-and-refile flow, a script) that needs to file a session
|
||||
* without going through a live chat turn.
|
||||
*
|
||||
* The session must actually exist in the commanders directory, or this refuses — filing a `/chat` session
|
||||
* makes no sense given commanders and `/chat` never share a working directory.
|
||||
* Creates, in order: the row (`createCommander`, no transcript — `sessionId` stays null until a turn
|
||||
* reports one), THEN the directory and its `CLAUDE.md` (`createCommanderDirectory`) — the row has to exist
|
||||
* first because `dirName` is derived from its own id. Returns the row, including `cwd`: the caller (the
|
||||
* web composer, `CommandHqDetail.tsx`) starts its chat turn with `cwd` set to exactly this path, and
|
||||
* threads `commanderId: row.id` on that turn so `fileAsCommander` (`websocket.ts`) can point this same row
|
||||
* at the transcript id the moment one exists — the same server-driven, don't-depend-on-a-live-browser
|
||||
* pattern this router already used for the commander's name, now doing the same job for its `sessionId`.
|
||||
* No separate `PATCH … { sessionId }` route exists for this: it would only reintroduce the thing this
|
||||
* design removes — a client that has to remember to follow through, on a value the server already knows
|
||||
* first.
|
||||
*/
|
||||
commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ sessionId?: string; categoryId?: number }>()
|
||||
.catch(() => ({}) as { sessionId?: string; categoryId?: number });
|
||||
const sessionId = body.sessionId?.trim();
|
||||
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
|
||||
.json<{ name?: string; referencePath?: string; categoryId?: number | null }>()
|
||||
.catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null });
|
||||
const name = body.name?.trim();
|
||||
const referencePath = body.referencePath?.trim();
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
// Located by id wherever it lives, not required to be in the old shared directory. A commander chooses
|
||||
// its own working directory, so "is it in the commanders folder" stopped being a meaningful check — and
|
||||
// as a GUARD it only ever established that the transcript exists, which this still does.
|
||||
if (!loadClaudeSessionSummaryById(who, sessionId)) throw errors.NOT_FOUND('no such conversation');
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const { dir, ownershipError } = await createCommanderDirectory(who.email, row.dirName, { name, referencePath });
|
||||
if (ownershipError) {
|
||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||
}
|
||||
|
||||
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
|
||||
return ctx.json(row);
|
||||
// `ownershipError` is returned as well as logged. A member whose commander directory could not be handed
|
||||
// over gets an agent that dies on its first command, and "the platform knew and only wrote it to a log"
|
||||
// is the worst version of that.
|
||||
return ctx.json({ ...row, cwd: dir, ...(ownershipError ? { ownershipError } : {}) });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -592,13 +631,13 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
is exactly how a deleted commander came back as an untitled ordinary chat.
|
||||
*/
|
||||
const sessionKilled = sessionKey ? await sidecar.killClaudeAndWait(sessionKey, user.id) : false;
|
||||
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||
const stopped = stopCommanderProcesses(who.email, row.dirName);
|
||||
|
||||
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
||||
// not stop the rest from going, and a child is often gone before its parent.
|
||||
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
|
||||
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, row.dirName))).map(
|
||||
(c) => c.spawnedSessionId,
|
||||
);
|
||||
let childrenDeleted = 0;
|
||||
@@ -623,7 +662,17 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||
}
|
||||
}
|
||||
clearSpawnedRecord(who.email, sessionId);
|
||||
/*
|
||||
The commander's OWN directory — `CLAUDE.md`, `state/`, and anything else it wrote for itself. Not just
|
||||
`state/` (which `clearSpawnedRecord` alone would remove): per `docs/commander-directory.md`, deleting a
|
||||
commander is deleting its directory, whole, which is also the only way to be sure nothing is left
|
||||
behind. Best-effort, same posture as the children above — a directory already gone is not a failure.
|
||||
*/
|
||||
try {
|
||||
rmSync(getCommanderDir(who.email, row.dirName), { recursive: true, force: true });
|
||||
} catch {
|
||||
// Already gone, or never fully created. The row is still deleted below regardless.
|
||||
}
|
||||
|
||||
// 3. The commander itself.
|
||||
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
|
||||
@@ -675,10 +724,16 @@ commandHqRouter.post('/purge', async (ctx) => {
|
||||
|
||||
Scanning one directory would have deleted every commander that chose a different one — a housekeeping
|
||||
route quietly unfiling live commanders is the worst possible version of this bug, because the rows are
|
||||
what identity depends on now.
|
||||
what identity depends on now. A row with no transcript yet (`sessionId: null`, mid-creation) is never a
|
||||
purge candidate at all — see `purgeCommandHqSessions`'s own guard — so it needs no entry in this set.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id);
|
||||
const stillThere = new Set(rows.map((row) => row.sessionId).filter((id) => !!loadClaudeSessionSummaryById(who, id)));
|
||||
const stillThere = new Set(
|
||||
rows
|
||||
.map((row) => row.sessionId)
|
||||
.filter((id): id is string => id !== null)
|
||||
.filter((id) => !!loadClaudeSessionSummaryById(who, id)),
|
||||
);
|
||||
const removed = await purgeCommandHqSessions(user.id, stillThere);
|
||||
return ctx.json({ removed });
|
||||
});
|
||||
@@ -716,7 +771,7 @@ commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, row.dirName));
|
||||
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
|
||||
// must not display a confident total that happens to be too low. That understatement is the whole reason
|
||||
// this exists.
|
||||
@@ -731,23 +786,25 @@ commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/record-paths
|
||||
*
|
||||
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||
* Where this commander's loop must write its pid and the session ids it spawns — the same two paths
|
||||
* `CLAUDE.md` already states at creation (`docs/commander-directory.md`). This route still exists for
|
||||
* `BackgroundJobDialog`, which needs them again at ARM time, well after creation, to build the watch loop's
|
||||
* own prompt (`background-job-prompt.ts`) — a live turn's `CLAUDE.md` is not something this route re-reads.
|
||||
*
|
||||
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
||||
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
||||
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
||||
* job, where an authenticated POST would need a credential the loop has no business holding.
|
||||
*
|
||||
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
||||
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
||||
* Rooted in `state/` inside this commander's own directory, keyed by `dirName` — which means it now needs
|
||||
* the ROW (to learn `dirName`), unlike before, when the path was a pure function of the transcript id and
|
||||
* nothing else. A commander always has a row by the time any turn exists, so this is not a new restriction
|
||||
* in practice — it only makes explicit what used to be implicit.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
return ctx.json({
|
||||
loopPidPath: loopPidPath(who.email, sessionId),
|
||||
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||
loopPidPath: loopPidPath(who.email, row.dirName),
|
||||
spawnedPath: spawnedRecordPath(who.email, row.dirName),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
So the loop records it, and the platform reads it. One newline-delimited file per commander:
|
||||
|
||||
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
|
||||
<DATA_PATH>/<email>/commanders/<dirName>/state/spawned
|
||||
|
||||
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
|
||||
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
|
||||
every link is required.
|
||||
path is written into `CLAUDE.md` at creation (`docs/commander-directory.md`) and also carried into the
|
||||
background-job prompt by `background-job-prompt.ts` for the dialog-driven case. That is the whole chain,
|
||||
and every link is required.
|
||||
*/
|
||||
|
||||
const FILE = 'spawned';
|
||||
@@ -36,8 +37,8 @@ const PID_FILE = 'loop-pid';
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
|
||||
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), FILE);
|
||||
export function spawnedRecordPath(email: string, dirName: string): string {
|
||||
return join(getCommanderStateDir(email, dirName), FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +48,8 @@ export function spawnedRecordPath(email: string, commanderSessionId: string): st
|
||||
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
|
||||
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
|
||||
*/
|
||||
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
|
||||
const path = spawnedRecordPath(email, commanderSessionId);
|
||||
export function listSpawnedSessions(email: string, dirName: string): string[] {
|
||||
const path = spawnedRecordPath(email, dirName);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const seen = new Set<string>();
|
||||
@@ -68,18 +69,26 @@ export function listSpawnedSessions(email: string, commanderSessionId: string):
|
||||
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
|
||||
* that arrives it must write the same file rather than a second record.
|
||||
*/
|
||||
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
|
||||
export function recordSpawnedSession(email: string, dirName: string, spawnedId: string): void {
|
||||
if (!SESSION_ID.test(spawnedId)) return;
|
||||
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||
if (listSpawnedSessions(email, dirName).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
|
||||
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
|
||||
/**
|
||||
* Drop `state/` alone, leaving `CLAUDE.md` and anything else the commander wrote in place.
|
||||
*
|
||||
* Not called from the delete route any more — deleting a commander now removes its WHOLE directory
|
||||
* (`docs/commander-directory.md`: `state/` is one part of it, not the only thing that needs to go), which
|
||||
* subsumes this. Kept as the narrower primitive for the shape of operation it names: clearing what a
|
||||
* commander has HANDLED without deleting the commander itself, which nothing calls today but is exactly
|
||||
* the kind of reset a commander asked to "start over" might need.
|
||||
*/
|
||||
export function clearSpawnedRecord(email: string, dirName: string): void {
|
||||
try {
|
||||
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
|
||||
rmSync(getCommanderStateDir(email, dirName), { recursive: true, force: true });
|
||||
} catch {
|
||||
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
|
||||
// without a commander to read it for.
|
||||
@@ -103,13 +112,13 @@ export function clearSpawnedRecord(email: string, commanderSessionId: string): v
|
||||
*/
|
||||
|
||||
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
|
||||
export function loopPidPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
|
||||
export function loopPidPath(email: string, dirName: string): string {
|
||||
return join(getCommanderStateDir(email, dirName), PID_FILE);
|
||||
}
|
||||
|
||||
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
|
||||
const path = loopPidPath(email, commanderSessionId);
|
||||
export function liveLoopPid(email: string, dirName: string): number | null {
|
||||
const path = loopPidPath(email, dirName);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
@@ -128,8 +137,8 @@ export function liveLoopPid(email: string, commanderSessionId: string): number |
|
||||
*
|
||||
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||
*/
|
||||
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
|
||||
const pid = liveLoopPid(email, commanderSessionId);
|
||||
export function stopCommanderProcesses(email: string, dirName: string): number {
|
||||
const pid = liveLoopPid(email, dirName);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
let signalled = 0;
|
||||
|
||||
@@ -284,12 +284,40 @@ router.post('/upload', async (ctx) => {
|
||||
const raw = body['file'];
|
||||
const files = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
|
||||
/*
|
||||
WRITE ASIDE, THEN RENAME. Never straight to the final path.
|
||||
|
||||
`Bun.write(filePath, file)` streams to the name the file will end up with, so for as long as the
|
||||
upload takes, that name exists holding a partial file — and anything watching the folder cannot
|
||||
tell. The failure is silent rather than loud: a truncated m4a decodes fine, it is simply short. A
|
||||
watcher transcribing voice memos as they land would produce a clean transcript of the first few
|
||||
seconds of a two-minute recording and report success.
|
||||
|
||||
A rename within one directory is atomic, so afterwards the final name either does not exist or is
|
||||
a complete file. Every consumer gets that for free — the phone's own `uploadVerified`, a watcher,
|
||||
an agent reading the folder — instead of each inventing a heuristic like "the size stopped
|
||||
changing", which cannot tell a finished upload from a stalled one.
|
||||
|
||||
`.part` rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was
|
||||
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
|
||||
create is a worse trade than a fragment somebody can see.
|
||||
*/
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
const filePath = resolve(targetDir, file.name);
|
||||
if (!isInside(rootDir, filePath)) continue;
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, file);
|
||||
|
||||
const partPath = `${filePath}.part`;
|
||||
try {
|
||||
await Bun.write(partPath, file);
|
||||
await rename(partPath, filePath);
|
||||
} catch (err) {
|
||||
// Leave nothing half-named. Only an interrupted PROCESS leaves a fragment behind, which is
|
||||
// the case worth being able to see.
|
||||
await rm(partPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { listRoles, createRole, renameRole, deleteRole } from 'officerdb';
|
||||
import { listRoles, createRole, renameRole, deleteRole, setRoleGrants } from 'officerdb';
|
||||
import { seedGrantsForNewRole } from '@@/plugins/grant-on-install';
|
||||
|
||||
/*
|
||||
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md).
|
||||
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md), plus grants (phase 3).
|
||||
|
||||
This is the roles TABLE — a name, a description, the `isSystem` flag. It is not the GRANTS a role
|
||||
holds; that stays `role_permissions`, administered at `/api/users/permissions` by
|
||||
`permissionAdminRouter`. The two are separate screens for a reason: this router answers "which roles
|
||||
exist", that one answers "what may each reach" — and deleting a role here has to know the second
|
||||
question's table too, because the guard below is "does anyone hold it", not "what does it grant".
|
||||
This is the roles TABLE — a name, a description, the `isSystem` flag, and since phase 3 the two
|
||||
provisioning flags (`grantsPostgres`, `grantsDocker`). It is not the PERMISSION grants a role holds;
|
||||
those stay `role_permissions`, administered at `/api/users/permissions` by `permissionAdminRouter`. The
|
||||
two are separate screens for a reason: this router answers "which roles exist, and what does holding one
|
||||
provision on the machine", that one answers "what may each reach in the app" — and deleting a role here
|
||||
has to know the second question's table too, because the guard below is "does anyone hold it", not "what
|
||||
does it grant".
|
||||
|
||||
── Owner only, explicitly ──
|
||||
|
||||
@@ -37,19 +40,51 @@ rolesRouter.use(async (ctx, next) => {
|
||||
/** GET /api/roles — every role, with how many accounts hold each. */
|
||||
rolesRouter.get('/', async (ctx) => ctx.json({ roles: await listRoles() }));
|
||||
|
||||
/** POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`. */
|
||||
/**
|
||||
* POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` default to false when omitted, matching the column defaults — creating
|
||||
* a role must not hand out a Postgres login and a Docker daemon because a form field was left out of the
|
||||
* request body.
|
||||
*/
|
||||
rolesRouter.post('/', async (ctx) => {
|
||||
const body = ctx.get('body') as { name?: unknown; description?: unknown };
|
||||
const body = ctx.get('body') as {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
grantsPostgres?: unknown;
|
||||
grantsDocker?: unknown;
|
||||
};
|
||||
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
|
||||
const description = typeof body.description === 'string' ? body.description : null;
|
||||
if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsPostgres must be a boolean');
|
||||
}
|
||||
if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsDocker must be a boolean');
|
||||
}
|
||||
const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined;
|
||||
const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined;
|
||||
|
||||
const result = await createRole({ name: body.name, description });
|
||||
const result = await createRole({ name: body.name, description, grantsPostgres, grantsDocker });
|
||||
if (!result.ok) {
|
||||
throw result.reason === 'invalid-name'
|
||||
? errors.BAD_REQUEST('a role name cannot be empty')
|
||||
: errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
/*
|
||||
A new role starts where Member does: the confined baseline plus every installed plugin.
|
||||
|
||||
Not doing this is how the policy ends up half-applied — granted to roles that existed when a plugin was
|
||||
installed, and not to any created afterwards. It also fixes something older: DEFAULT_ROLE_PERMISSIONS
|
||||
was only ever applied at bootstrap, so a role created here used to begin with nothing at all, not even
|
||||
chat or files, and nothing said why.
|
||||
|
||||
Not fatal. The role exists either way and its grants can be set on the permissions screen; failing the
|
||||
creation over a grant would be the worse trade.
|
||||
*/
|
||||
const seeded = await seedGrantsForNewRole(result.role.name).catch(() => 0);
|
||||
|
||||
return ctx.json({ role: result.role, seededGrants: seeded });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -80,6 +115,41 @@ rolesRouter.patch('/:name', async (ctx) => {
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roles/:name/grants — set `grantsPostgres`/`grantsDocker`. Refuses a system role, for a
|
||||
* different reason than the rename route above: `scripts/seed-roles.ts` re-asserts a system role's grants
|
||||
* on every `postdb:push`, so a write here would look like it worked and then silently revert on the next
|
||||
* deploy. A custom role has no such seed pinning it, so this is where its grants actually get set once it
|
||||
* exists — `createRole` only takes an initial value.
|
||||
*
|
||||
* Either field may be omitted; only the ones present are changed (see `setRoleGrants`).
|
||||
*/
|
||||
rolesRouter.patch('/:name/grants', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const body = ctx.get('body') as { grantsPostgres?: unknown; grantsDocker?: unknown };
|
||||
if (body?.grantsPostgres === undefined && body?.grantsDocker === undefined) {
|
||||
throw errors.BAD_REQUEST('grantsPostgres and/or grantsDocker is required');
|
||||
}
|
||||
if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsPostgres must be a boolean');
|
||||
}
|
||||
if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsDocker must be a boolean');
|
||||
}
|
||||
const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined;
|
||||
const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined;
|
||||
|
||||
const result = await setRoleGrants(name, { grantsPostgres, grantsDocker });
|
||||
if (!result.ok) {
|
||||
throw result.reason === 'not-found'
|
||||
? errors.NOT_FOUND(`no such role '${name}'`)
|
||||
: errors.FORBIDDEN(
|
||||
`'${name}' is a system role — its grants are declared in scripts/seed-roles.ts, not edited here`,
|
||||
);
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/** DELETE /api/roles/:name — refuses a system role, and refuses one any account still holds. */
|
||||
rolesRouter.delete('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
@@ -149,9 +149,19 @@ sendoffRouter.post('/send', async (ctx) => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const written: { name: string; size: number }[] = [];
|
||||
// Aside, then rename — the same reasoning as the file browser upload, and it matters more here
|
||||
// because this is a folder things ARRIVE in unattended. Neither a recipient watching it nor the
|
||||
// notification below may point at a name that is still being written.
|
||||
for (const file of files) {
|
||||
const path = freePath(dir, safeFilename(file.name));
|
||||
await Bun.write(path, await file.arrayBuffer());
|
||||
const partPath = `${path}.part`;
|
||||
try {
|
||||
await Bun.write(partPath, await file.arrayBuffer());
|
||||
await rename(partPath, path);
|
||||
} catch (err) {
|
||||
await rm(partPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ const PROMPT_CONTEXT_TOKENS = 96;
|
||||
// load-bearing. Overflow is trimmed from the front, so nothing depends on the opening words.
|
||||
//
|
||||
// Languages without an entry fall through to no prompt at all, which is handled explicitly below.
|
||||
//
|
||||
// Every prompt uses its language's full diacritic set (and, for a non-Latin script, its own
|
||||
// punctuation — Japanese 。/、, Greek ; as a question mark, Arabic ، and ؟) in ordinary words, never
|
||||
// forced in. Omitting one, e.g. writing Swedish without å ä ö, teaches Whisper to drop it too.
|
||||
const TRANSCRIPT_PROMPTS: Record<string, string> = {
|
||||
en:
|
||||
'Hey, sorry for the late reply — I was stuck in a meeting all morning and only just got out. ' +
|
||||
@@ -90,6 +94,153 @@ const TRANSCRIPT_PROMPTS: Record<string, string> = {
|
||||
'escrita. Diz-me quando tiveres uns minutos e falamos com calma. Estou por aqui toda a tarde, ' +
|
||||
'por isso quando te der jeito está bem para mim. Obrigado outra vez por teres tratado disto, ' +
|
||||
'sei que deu trabalho.',
|
||||
es:
|
||||
'Hola, perdona la tardanza en responder — he estado toda la mañana en una reunión y recién ahora ' +
|
||||
'he podido salir. Ya vi el documento que me enviaste y, la verdad, creo que está bastante bien. ' +
|
||||
'Hay un par de cosas que cambiaría en la segunda mitad, pero nada grave. La introducción está muy ' +
|
||||
'bien escrita. Avísame cuando tengas unos minutos y lo hablamos con calma. Estoy disponible toda ' +
|
||||
'la tarde, así que cuando te venga bien está perfecto para mí. Gracias otra vez por armarlo, sé ' +
|
||||
'que llevó tiempo.',
|
||||
de:
|
||||
'Hallo, entschuldige die späte Antwort — ich saß den ganzen Vormittag in einer Besprechung und ' +
|
||||
'bin gerade erst rausgekommen. Ich habe mir das Dokument angeschaut, das du geschickt hast, und ' +
|
||||
'ehrlich gesagt finde ich es größtenteils gut. In der zweiten Hälfte würde ich ein paar ' +
|
||||
'Kleinigkeiten ändern, aber nichts Ernstes. Die Einleitung liest sich wirklich gut. Sag mir ' +
|
||||
'Bescheid, wenn du später ein paar Minuten hast, dann können wir in Ruhe darüber reden. Ich bin ' +
|
||||
'den ganzen Nachmittag über da, also wann immer es dir passt, ist mir recht. Danke nochmal, dass ' +
|
||||
'du dir die Mühe gemacht hast, ich weiß, das hat Zeit gekostet.',
|
||||
fr:
|
||||
"Salut, désolé pour la réponse tardive — j'étais coincé en réunion toute la matinée et je viens " +
|
||||
"tout juste d'en sortir. J'ai regardé le document que tu as envoyé, et honnêtement, je trouve que " +
|
||||
"c'est plutôt bien. Il y a deux ou trois choses que je changerais dans la deuxième moitié, mais " +
|
||||
"rien de grave. L'introduction est vraiment bien écrite. Dis-moi quand tu as quelques minutes et " +
|
||||
"on en parle tranquillement. Je suis disponible tout l'après-midi, donc quand ça t'arrange, ça me " +
|
||||
"va. Merci encore de l'avoir préparé, je sais que ça a pris du temps.",
|
||||
sv:
|
||||
'Hej, förlåt att jag inte svarat tidigare — jag satt i möte hela förmiddagen och kom precis loss. ' +
|
||||
'Jag har tittat på dokumentet du skickade, och ärligt talat tycker jag att det mesta är bra. Det ' +
|
||||
'är några saker jag skulle ändra i andra halvan, men inget allvarligt. Inledningen är verkligen ' +
|
||||
'bra skriven. Säg till när du har några minuter så pratar vi igenom det ordentligt. Jag är ledig ' +
|
||||
'hela eftermiddagen, så när det passar dig passar det mig. Tack igen för att du satte ihop det, ' +
|
||||
'jag vet att det tog ett tag.',
|
||||
it:
|
||||
'Ciao, scusa il ritardo nella risposta — sono stato in riunione tutta la mattina e sono appena ' +
|
||||
"uscito. Ho dato un'occhiata al documento che mi hai mandato e, sinceramente, penso che vada bene " +
|
||||
'quasi tutto. Ci sono un paio di cose che cambierei nella seconda metà, ma niente di grave. ' +
|
||||
"L'introduzione è scritta davvero bene. Fammi sapere quando hai qualche minuto così ne parliamo " +
|
||||
'con calma. Sono libero tutto il pomeriggio, quindi va bene quando ti fa comodo. Grazie ancora ' +
|
||||
'per averlo messo insieme, so che ci è voluto tempo.',
|
||||
nl:
|
||||
'Hoi, sorry voor het late antwoord — ik zat de hele ochtend in een vergadering en ben er net pas ' +
|
||||
'uit. Ik heb naar het document gekeken dat je hebt gestuurd, en eerlijk gezegd vind ik het ' +
|
||||
'grotendeels goed. Er zijn een paar dingen die ik in de tweede helft zou veranderen, maar niets ' +
|
||||
'ergs. De inleiding leest heel prettig. Laat het me weten wanneer je een paar minuten hebt, dan ' +
|
||||
'bespreken we het rustig. Ik ben de hele middag beschikbaar, dus wanneer het jou uitkomt is voor ' +
|
||||
'mij prima. Bedankt nogmaals dat je het hebt samengesteld, ik weet dat het tijd heeft gekost.',
|
||||
pl:
|
||||
'Cześć, przepraszam za późną odpowiedź — siedziałem cały ranek na spotkaniu i w końcu udało mi ' +
|
||||
'się z niego wyrwać. Zerknąłem na dokument, który przesłałeś, i szczerze mówiąc, myślę, że ' +
|
||||
'większość jest w porządku. Jest kilka rzeczy, które zmieniłbym w drugiej połowie, ale nic ' +
|
||||
'poważnego. Wstęp jest naprawdę dobrze napisany. Daj znać, kiedy będziesz mieć chwilę, możemy to ' +
|
||||
'spokojnie omówić. Jestem wolny całe popołudnie, więc kiedy ci pasuje, mi też pasuje. Dzięki ' +
|
||||
'jeszcze raz za to, że to zebrałeś, wiem, że zajęło to trochę czasu.',
|
||||
da:
|
||||
'Hej, undskyld det sene svar — jeg sad i møde hele formiddagen og er lige kommet ud. Jeg har ' +
|
||||
'kigget på dokumentet, du sendte, og ærligt talt synes jeg det meste er fint. Der er et par ting, ' +
|
||||
'jeg ville ændre i anden halvdel, men ikke noget alvorligt. Indledningen er skrevet rigtig godt. ' +
|
||||
'Sig til, når du har et par minutter, så snakker vi ordentligt om det. Jeg er ledig hele ' +
|
||||
'eftermiddagen, så det passer mig, når det passer dig. Tak igen fordi du satte det sammen, jeg ' +
|
||||
'ved det tog tid.',
|
||||
no:
|
||||
'Hei, beklager det sene svaret — jeg satt i møte hele formiddagen og er akkurat ferdig. Jeg har ' +
|
||||
'sett på dokumentet du sendte, og ærlig talt synes jeg det meste er bra. Det er et par ting jeg ' +
|
||||
'ville endret i andre halvdel, men ingenting alvorlig. Innledningen er skrevet veldig bra. Si ' +
|
||||
'ifra når du har noen minutter, så tar vi en skikkelig prat om det. Jeg er ledig hele ' +
|
||||
'ettermiddagen, så det som passer deg passer meg. Takk igjen for at du satte det sammen, jeg vet ' +
|
||||
'det tok tid.',
|
||||
fi:
|
||||
'Hei, anteeksi myöhäinen vastaus — istuin koko aamupäivän palaverissa ja pääsin juuri äsken pois. ' +
|
||||
'Katsoin dokumentin, jonka lähetit, ja rehellisesti sanottuna suurin osa siitä on ihan hyvä. ' +
|
||||
'Loppupuoliskolla on pari asiaa, jotka muuttaisin, mutta ei mitään vakavaa. Johdanto on ' +
|
||||
'kirjoitettu tosi hyvin. Sano, kun sinulla on hetki aikaa, niin jutellaan siitä kunnolla. Olen ' +
|
||||
'vapaana koko iltapäivän, joten mikä tahansa sopii sinulle, sopii minulle. Kiitos vielä kerran, ' +
|
||||
'kun kokosit sen, tiedän että siihen meni aikaa.',
|
||||
tr:
|
||||
'Selam, geç cevap verdiğim için kusura bakma — bütün sabah toplantıdaydım ve az önce çıkabildim. ' +
|
||||
'Gönderdiğin dokümana baktım ve açıkçası çoğunlukla iyi olduğunu düşünüyorum. İkinci yarıda ' +
|
||||
'değiştireceğim birkaç şey var ama ciddi bir şey değil. Giriş kısmı gerçekten iyi yazılmış. ' +
|
||||
'Birkaç dakikan olduğunda haber ver, düzgünce konuşuruz. Bütün öğleden sonra müsaitim, yani sana ' +
|
||||
'uyan zaman bana da uyar. Bunu hazırladığın için tekrar teşekkürler, biliyorum zaman almıştır.',
|
||||
cs:
|
||||
'Ahoj, promiň za pozdní odpověď — celé dopoledne jsem byl na poradě a teprve teď jsem se dostal ' +
|
||||
'ven. Podíval jsem se na dokument, cos poslal, a upřímně řečeno, myslím, že většina je v pořádku. ' +
|
||||
'V druhé polovině bych pár věcí změnil, ale nic vážného. Úvod je napsaný fakt dobře. Dej vědět, ' +
|
||||
'až budeš mít pár minut, můžeme to pořádně probrat. Celé odpoledne mám volno, takže kdy se ti to ' +
|
||||
'bude hodit, mně to vyhovuje. Díky ještě jednou, žes to dal dohromady, vím, že to zabralo čas.',
|
||||
el:
|
||||
'Γεια σου, συγγνώμη για την καθυστερημένη απάντηση — ήμουν σε συνάντηση όλο το πρωί και μόλις ' +
|
||||
'τώρα βγήκα. Είδα το έγγραφο που έστειλες και, ειλικρινά, νομίζω πως είναι σχεδόν όλο εντάξει. ' +
|
||||
'Υπάρχουν δύο τρία πράγματα που θα άλλαζα στο δεύτερο μισό, αλλά τίποτα σοβαρό. Η εισαγωγή είναι ' +
|
||||
'γραμμένη πολύ καλά. Έχεις λίγα λεπτά να τα πούμε με την ησυχία μας; Είμαι ελεύθερος όλο το ' +
|
||||
'απόγευμα, οπότε όποτε σε βολεύει εμένα με βολεύει. Ευχαριστώ ξανά που το ετοίμασες, ξέρω πως ' +
|
||||
'πήρε χρόνο.',
|
||||
uk:
|
||||
'Привіт, вибач за пізню відповідь — я весь ранок сидів на нараді і щойно звідти вийшов. Подивився ' +
|
||||
'документ, який ти надіслав, і чесно кажучи, мої думки такі — майже все добре. У мене є пара ' +
|
||||
'речей, які я б змінив у другій половині, але нічого серйозного. Вступ написаний дуже добре. ' +
|
||||
'Напиши, коли матимеш кілька хвилин, і спокійно все обговоримо. Я вільний увесь день, тобто коли тобі зручно, ' +
|
||||
'мені теж підходить. Дякую ще раз, що зібрав це докупи, знаю, що це зайняло час.',
|
||||
ru:
|
||||
'Привет, извини, что долго не отвечал — всё утро просидел на встрече и только что освободился. ' +
|
||||
'Посмотрел документ, который ты прислал, и, честно говоря, думаю, что почти всё нормально. Есть ' +
|
||||
'пара моментов, которые я бы изменил во второй половине, но ничего серьёзного. Введение написано ' +
|
||||
'правда хорошо. Напиши, когда будет пара свободных минут, спокойно всё обсудим. Я свободен весь ' +
|
||||
'день, так что когда тебе удобно, мне тоже подходит. Спасибо ещё раз, что всё это собрал, знаю, ' +
|
||||
'что это заняло время.',
|
||||
ro:
|
||||
'Salut, scuze pentru răspunsul întârziat — am fost toată dimineața într-o ședință și abia acum am ' +
|
||||
'scăpat. M-am uitat pe documentul pe care mi l-ai trimis și, sincer, cred că e aproape totul ' +
|
||||
'bine. Sunt vreo două-trei lucruri pe care le-aș schimba în a doua jumătate, dar nimic grav. ' +
|
||||
'Introducerea e scrisă chiar bine. Spune-mi când ai câteva minute și vorbim pe îndelete despre ' +
|
||||
'asta. Sunt liber toată după-amiaza, deci oricând îți convine mie îmi convine. Mulțumesc încă o ' +
|
||||
'dată că ai pus totul cap la cap, știu că a durat.',
|
||||
hu:
|
||||
'Szia, bocs a késői válaszért — egész délelőtt gyűlésen ültem, és csak most sikerült ' +
|
||||
'kiszabadulnom. Megnéztem a dokumentumot, amit küldtél, és őszintén szólva szerintem szinte ' +
|
||||
'minden rendben van. Van pár dolog, amit megváltoztatnék a második felében, de semmi komoly. A ' +
|
||||
'bevezető nagyon jól van megírva. Szólj, ha van pár szabad perced, és nyugodtan átbeszéljük. ' +
|
||||
'Egész délután érek rá, szóval amikor neked jó, nekem is megfelel. Köszönöm még egyszer, hogy ' +
|
||||
'összeraktad, tudom, hogy hosszú időbe telt.',
|
||||
ja:
|
||||
'返信が遅くなってごめん。朝からずっと会議で、今やっと抜けられたところ。送ってもらった資料、見たよ。' +
|
||||
'正直、ほとんど問題ないと思う。後半のところで直したい部分が二つ三つあるけど、大したことじゃない。' +
|
||||
'導入の部分はすごくよく書けてると思う。時間があるときに教えて、ゆっくり話そう。' +
|
||||
'午後はずっと空いてるから、都合のいいときでいいよ。まとめてくれてありがとう、時間かかったよね。',
|
||||
zh:
|
||||
'抱歉这么晚才回你——我一早上都在开会,刚出来。你发的那份文件我看过了,说实话,大部分都挺好的。' +
|
||||
'后半部分有几个地方我想改一下,但都不是什么大问题。开头写得真的很不错。你什么时候有空跟我说一声,我们好好聊聊。' +
|
||||
'我整个下午都有空,所以你什么时候方便都行。再次谢谢你把这个整理出来,我知道挺花时间的。',
|
||||
ko:
|
||||
'답장이 늦어서 미안해. 오전 내내 회의하다가 방금 겨우 빠져나왔어. 보내준 문서 봤는데, 솔직히 거의 다 괜찮은 것 같아. 후반부에 고치고 싶은 부분이 두어 개 있긴 한데, ' +
|
||||
'심각한 건 아니야. 서론은 진짜 잘 쓴 것 같아. 시간 될 때 얘기해줘, 그럼 차분히 얘기해보자. 오후 내내 시간 있으니까 편한 때 아무 때나 괜찮아. 정리해줘서 다시 한번 ' +
|
||||
'고마워, 시간 좀 걸렸을 텐데.',
|
||||
ar:
|
||||
'مرحبًا، آسف على تأخري في الرد — كنت في اجتماع طوال الصباح ولتوّي خرجت منه. اطّلعت على المستند ' +
|
||||
'الذي أرسلته، وبصراحة أعتقد أن معظمه جيد. هناك بعض الأشياء التي أود تغييرها في النصف الثاني، لكن ' +
|
||||
'لا شيء خطير. المقدمة مكتوبة بشكل جيد جدًا. هل يمكن أن تخبرني متى تكون لديك بضع دقائق لنتحدث ' +
|
||||
'بهدوء؟ أنا متفرغ طوال بعد الظهر، فأي وقت يناسبك يناسبني. شكرًا مرة أخرى على إعدادك لهذا، أعرف أن ' +
|
||||
'الأمر استغرق وقتًا.',
|
||||
he:
|
||||
'היי, סליחה על התגובה המאוחרת — הייתי בישיבה כל הבוקר ורק עכשיו יצאתי. הסתכלתי על המסמך ששלחת, ' +
|
||||
'ובכנות, אני חושב שרוב הדברים בסדר. יש כמה דברים שהייתי משנה בחצי השני, אבל שום דבר רציני. הפתיחה ' +
|
||||
'כתובה ממש טוב. תגיד לי מתי יש לך כמה דקות ונדבר על זה בנחת. אני פנוי כל אחר הצהריים, אז מתי שנוח ' +
|
||||
'לך נוח גם לי. תודה שוב שסידרת את זה, אני יודע שזה לקח זמן.',
|
||||
hi:
|
||||
'हाय, देर से जवाब देने के लिए माफ़ करना — मैं पूरी सुबह मीटिंग में था और अभी-अभी निकल पाया। तुमने ' +
|
||||
'जो डॉक्यूमेंट भेजा था, वो देख लिया, और सच कहूं तो ज़्यादातर हिस्सा ठीक लग रहा है। दूसरे हिस्से ' +
|
||||
'में कुछ चीज़ें हैं जो मैं बदलना चाहूंगा, पर कुछ गंभीर नहीं है। शुरुआत बहुत अच्छी लिखी गई है। जब ' +
|
||||
'भी कुछ मिनट मिलें बता देना, फिर आराम से बात करते हैं। पूरी दोपहर मैं फ्री हूं, तो जब भी तुम्हें ' +
|
||||
'सही लगे मुझे ठीक है। इसे तैयार करने के लिए फिर से शुक्रिया, पता है इसमें वक़्त लगा होगा।',
|
||||
};
|
||||
|
||||
// Beam search rather than the default greedy decode (whisper.cpp ships `beam_size = -1`). It is the
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Handler } from 'hono';
|
||||
import { getUsers, getUserById, updateUser, deleteUser, listRoles, getRole, OWNER_USER_ID } from 'officerdb';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { deprovisionOsAccount } from '@@/os-user-deprovision';
|
||||
import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
|
||||
import { dropPostgresRole, provisionPostgresRole } from '@@/os-user-postgres';
|
||||
import { lookupOsUser } from '@@/os-user';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { broadcastProvisionableUsers } from '@@/plugins/user-provisioning';
|
||||
@@ -71,9 +71,11 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||
// phase 2 part 4): a role that is not a row in `roles` must never reach `updateUser`, or the FK on
|
||||
// `users.role` throws a raw Postgres error instead of this message.
|
||||
if (typeof role !== 'string' || !(await getRole(role))) {
|
||||
throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
|
||||
}
|
||||
if (typeof role !== 'string') throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
|
||||
// Kept rather than re-fetched below: this is the one read of the DESTINATION role's row this handler
|
||||
// needs, and `wants` reuses it instead of asking the database the same question twice.
|
||||
const newRole = await getRole(role);
|
||||
if (!newRole) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
|
||||
// ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
|
||||
// message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person
|
||||
@@ -101,7 +103,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
|
||||
// ── The database role follows the platform role ──
|
||||
//
|
||||
// Without this the gate in `rolePermitsDevTools` is decorative: it would decide what a Developer gets at
|
||||
// Without this, `grantsPostgres` on the role row is decorative: it would decide what a Developer gets at
|
||||
// creation and then never look again, so demoting one would leave their Postgres role, their databases
|
||||
// and a working password in their ~/.zshenv. A permission that survives its own revocation is worse than
|
||||
// not having gated it, because the UI then says something untrue.
|
||||
@@ -109,8 +111,13 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
// REVOKE BEFORE RECORDING, grant after. Dropping first means a failure aborts with the role unchanged, so
|
||||
// the account still says Developer and the whole thing can be retried. Doing it the other way round would
|
||||
// leave a Member holding database access with nothing in the row to indicate it.
|
||||
const had = rolePermitsDevTools(existing.role);
|
||||
const wants = rolePermitsDevTools(role);
|
||||
//
|
||||
// Docker does NOT follow the role here — only Postgres does. That is a pre-existing gap (docker was
|
||||
// never wired into role change, only into initial provisioning in provision-os.ts), not something this
|
||||
// change introduces or was asked to close.
|
||||
const oldRole = await getRole(existing.role);
|
||||
const had = oldRole?.grantsPostgres ?? false;
|
||||
const wants = newRole.grantsPostgres;
|
||||
|
||||
if (had && !wants && existing.osUser) {
|
||||
const dropped = await dropPostgresRole(existing.osUser);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { checkOsAccount } from '@@/os-user-verify';
|
||||
import { provisionSshAccess } from '@@/os-user-ssh';
|
||||
import { seedShellConfig } from '@@/os-user-shell';
|
||||
import { provisionClaudeCli } from '@@/os-user-claude';
|
||||
import { provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
|
||||
import { provisionPostgresRole, roleDevGrants } from '@@/os-user-postgres';
|
||||
import { provisionRootlessDocker } from '@@/os-user-docker';
|
||||
import { provisionUserDirs } from '@@/data-path';
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function provisionOsAccount(params: {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
/** Decides whether they get a Postgres role. See `rolePermitsDevTools`. */
|
||||
/** Decides whether they get a Postgres role and/or a Docker daemon. See `roleDevGrants`. */
|
||||
role: UserRole;
|
||||
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
|
||||
inboundKey?: string | null;
|
||||
@@ -92,10 +92,14 @@ export async function provisionOsAccount(params: {
|
||||
// cannot do it for them and must not try, because the alternative is lending them the owner's credential.
|
||||
const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });
|
||||
|
||||
// A Postgres login role of the same name, with CREATEDB — for a Developer, and nobody else. What
|
||||
// replaced rootless Docker for the "let me run a database to develop against" case, and it inherits
|
||||
// that feature's role gate along with its purpose: `rolePermitsDevTools` is the one place that rule is
|
||||
// written down.
|
||||
// One read of the role row for both gates below, rather than one query per tool — `roleDevGrants`
|
||||
// returns both flags together for exactly this caller.
|
||||
const devGrants = await roleDevGrants(params.role);
|
||||
|
||||
// A Postgres login role of the same name, with CREATEDB — gated on `grantsPostgres`. What replaced
|
||||
// rootless Docker for the "let me run a database to develop against" case, and it inherits that
|
||||
// feature's role gate along with its purpose: `roleDevGrants` is the one place that rule is written
|
||||
// down, now as a row on the role rather than a name tested in code.
|
||||
//
|
||||
// Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function
|
||||
// that creates the role rather than in the setup script, so it cannot be skipped by an install that was
|
||||
@@ -103,7 +107,7 @@ export async function provisionOsAccount(params: {
|
||||
//
|
||||
// Null, not a skipped-but-ok result: "there is no database role because of who they are" and "the
|
||||
// database role worked" are different answers and the caller reports them differently.
|
||||
const postgres = rolePermitsDevTools(params.role)
|
||||
const postgres = devGrants.grantsPostgres
|
||||
? await provisionPostgresRole({
|
||||
email: params.email,
|
||||
osUser: account.osUser,
|
||||
@@ -112,14 +116,14 @@ export async function provisionOsAccount(params: {
|
||||
})
|
||||
: null;
|
||||
|
||||
// Their own rootless Docker daemon — for a Developer, and nobody else. Last, and the most tolerant of
|
||||
// failure: a host without the uidmap package or a kernel that will not do rootless still gets a perfectly
|
||||
// good account, minus containers.
|
||||
// Their own rootless Docker daemon — gated on `grantsDocker`. Last, and the most tolerant of failure: a
|
||||
// host without the uidmap package or a kernel that will not do rootless still gets a perfectly good
|
||||
// account, minus containers.
|
||||
//
|
||||
// Behind the role gate rather than unconditional. The cost is real and it is per member, not per install:
|
||||
// one daemon, one image cache and one subuid range each — see os-user-docker.ts. That is worth paying for
|
||||
// someone whose job is to build things here, and not for an account that just reads its dashboards.
|
||||
const docker = rolePermitsDevTools(params.role)
|
||||
const docker = devGrants.grantsDocker
|
||||
? await provisionRootlessDocker({
|
||||
osUser: account.osUser,
|
||||
uid: account.uid,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadSkillsAcross } from './skills/load';
|
||||
import { loadAffordancesAcross } from './affordances/load';
|
||||
import { loadPluginEntries } from './notifications/catalogue';
|
||||
|
||||
/**
|
||||
* A plugin's capabilities are READ where they live rather than copied into `capabilities/`
|
||||
* (docs/plugin-capabilities.md). These pin the three properties that were not obvious.
|
||||
*
|
||||
* `capabilityRoots` itself is not tested here — it reads the real database and the real plugin
|
||||
* directory, so a test of it would assert whatever this machine happens to have installed. What is worth
|
||||
* pinning is what the loaders do with a list once they have one, and that is pure.
|
||||
*/
|
||||
|
||||
const seed = (files: Record<string, string>): string => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'zz-caps-'));
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const abs = join(dir, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const skill = (name: string, desc: string) => `---\nname: ${name}\ndescription: ${desc}\n---\n\nbody\n`;
|
||||
|
||||
describe('skills merge across sources', () => {
|
||||
test('a plugin directory contributes its skills', () => {
|
||||
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'the owner wrote this') });
|
||||
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'a plugin shipped this') });
|
||||
try {
|
||||
const ids = loadSkillsAcross([owner, plugin]).map((s) => s.id).sort();
|
||||
expect(ids).toEqual(['mine', 'theirs']);
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Precedence, and the reason for it: a plugin must not be able to take over the meaning of a name its
|
||||
// owner is already using, just by picking the same directory name.
|
||||
test("the owner's own wins a name collision", () => {
|
||||
const owner = seed({ 'deploy/SKILL.md': skill('Deploy', 'the owner version') });
|
||||
const plugin = seed({ 'deploy/SKILL.md': skill('Deploy', 'the plugin version') });
|
||||
try {
|
||||
const found = loadSkillsAcross([owner, plugin]);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.description).toBe('the owner version');
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Which is what makes disable mean disable: the caller simply stops passing that root.
|
||||
test('dropping a source drops its skills', () => {
|
||||
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
|
||||
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'gone when disabled') });
|
||||
try {
|
||||
expect(loadSkillsAcross([owner, plugin])).toHaveLength(2);
|
||||
expect(loadSkillsAcross([owner])).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a source that does not exist is skipped, not fatal', () => {
|
||||
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
|
||||
try {
|
||||
expect(loadSkillsAcross([owner, join(tmpdir(), 'zz-caps-nope')])).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('affordances are not de-duplicated', () => {
|
||||
// Unlike skills: an affordance is guidance, several can be true at once, and two plugins describing the
|
||||
// same surface differently is information rather than a collision.
|
||||
test('both sources contribute even with the same file name', () => {
|
||||
const a = seed({ 'x/one.md': '---\nname: A\ndescription: from a\n---\nbody a\n' });
|
||||
const b = seed({ 'y/one.md': '---\nname: B\ndescription: from b\n---\nbody b\n' });
|
||||
try {
|
||||
expect(loadAffordancesAcross([a, b])).toHaveLength(2);
|
||||
} finally {
|
||||
rmSync(a, { recursive: true, force: true });
|
||||
rmSync(b, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification codes come from the manifest, not the path', () => {
|
||||
// The whole reason loadPluginEntries exists. Copied to capabilities/ the path carried
|
||||
// <publisher>/<appName>; read in place it carries nothing, so the identity has to travel separately.
|
||||
test('a plugin in its own directory still gets a namespaced code', () => {
|
||||
const dir = seed({ 'notifications/catalogue.yaml': "nowPlaying:\n title: 'Now playing'\n" });
|
||||
try {
|
||||
const entries = loadPluginEntries({ appName: 'music', dir, manifest: { publisher: 'officerdev' } });
|
||||
expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']);
|
||||
expect(entries['officerdev/music:nowPlaying']!.thread).toBe('officerdev/music');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The property that must survive the move: a plugin cannot claim the platform's voice by writing a
|
||||
// core-looking key into its own yaml.
|
||||
test('a yaml key cannot claim another namespace', () => {
|
||||
const dir = seed({ 'notifications/c.yaml': "officer/core:test:\n title: 'Nice try'\n" });
|
||||
try {
|
||||
const keys = Object.keys(loadPluginEntries({ appName: 'x', dir, manifest: { publisher: 'someone' } }));
|
||||
expect(keys).toEqual(['someone/x:officer/core:test']);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { itemsDir, type ItemType } from './data-path';
|
||||
import { snapshotPlugins } from './plugins/mount';
|
||||
|
||||
/**
|
||||
* Where capabilities are read from — the owner's own store, plus every installed and enabled plugin's.
|
||||
*
|
||||
* ── Why plugins are read in place rather than copied in ──
|
||||
*
|
||||
* `capabilities/` is a git repository, and the point of it is that anybody can fork it: core's tasks and
|
||||
* skills, plus their own on top. That only holds if the directory contains exactly two things, what core
|
||||
* ships and what its owner wrote. Publishing a plugin's files into it broke that — a fork accumulated
|
||||
* files it did not author, with nothing on disk saying which, so committing them carried another plugin's
|
||||
* work and not committing them left `git status` permanently dirty.
|
||||
*
|
||||
* Copying was wrong a second way that has nothing to do with git. A copy goes STALE: `plugins:update` is
|
||||
* a `git pull` in the plugin's directory, and a capability copied out of it keeps describing the previous
|
||||
* version until something republishes. And a copy survives `plugins:disable`, so a disabled plugin's
|
||||
* skill stayed in the agent's index — the plugin off, the agent still told it was there.
|
||||
*
|
||||
* Reading in place fixes all of that by construction, and needs no namespace to do it. Scoping the paths
|
||||
* would have made provenance READABLE; separate directories make it STRUCTURAL, which is the stronger
|
||||
* property and the cheaper one.
|
||||
*
|
||||
* See `docs/plugin-capabilities.md`.
|
||||
*
|
||||
* ── Enabled, not merely installed ──
|
||||
*
|
||||
* A disabled plugin contributes nothing. That is the whole meaning of disable — routes down, sidecar
|
||||
* stopped — and a capability that outlived it was the one piece that did not comply.
|
||||
*
|
||||
* ── Cost ──
|
||||
*
|
||||
* This reads the database, so it is async, and it must not be called per turn. Its callers resolve it
|
||||
* once per session (`skillsBlock`, `affordanceBlock`) or per request on surfaces that are already doing
|
||||
* database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities
|
||||
* at all, and that is the ordinary case rather than a fault.
|
||||
*/
|
||||
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
|
||||
const roots = [itemsDir(type as ItemType)];
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
try {
|
||||
({ states } = await snapshotPlugins());
|
||||
} catch {
|
||||
// The owner's own capabilities still load. A database that cannot be read is a much louder problem
|
||||
// somewhere else, and losing the agent's whole skill index to it would only obscure that.
|
||||
return roots;
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
const dir = join(state.plugin.dir, type);
|
||||
if (existsSync(dir)) roots.push(dir);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same list, for a caller that already holds a snapshot.
|
||||
*
|
||||
* Exists so a request doing several of these — the items rescan reads five types — pays for one database
|
||||
* read rather than five.
|
||||
*/
|
||||
export function capabilityRootsFrom(
|
||||
states: Awaited<ReturnType<typeof snapshotPlugins>>['states'],
|
||||
type: ItemType | 'affordances' | 'notifications',
|
||||
): string[] {
|
||||
const roots = [itemsDir(type as ItemType)];
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
const dir = join(state.plugin.dir, type);
|
||||
if (existsSync(dir)) roots.push(dir);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed and enabled plugins, in the shape the notification catalogue wants.
|
||||
*
|
||||
* Notifications are the one capability whose namespace cannot be read off a path once the file stays in
|
||||
* the plugin's own directory — see `notifications/catalogue.ts` → `loadPluginEntries`. So the source list
|
||||
* carries the identity with it.
|
||||
*/
|
||||
export async function pluginCatalogueSources(): Promise<
|
||||
{ appName: string; dir: string; manifest: { publisher: string } }[]
|
||||
> {
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
return states
|
||||
.filter((s) => s.install?.enabled)
|
||||
.map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher } }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+59
-10
@@ -198,17 +198,27 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
||||
join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
|
||||
|
||||
/**
|
||||
* Every commander a user has shares this ONE directory as its cwd — deliberately not one directory per
|
||||
* commander. The first design was per-commander, and was dropped the same night it was proposed: a fresh
|
||||
* directory would have to exist before or exactly when a commander's first turn fires, and racing that
|
||||
* creation against the harness's own spawn is precisely the kind of timing hazard Claude Code's
|
||||
* directory-per-project convention was never built to be driven by. One shared, pre-existing directory
|
||||
* removes the race instead of handling it.
|
||||
* The root every commander's own directory lives under — `$DATA_PATH/<email>/commanders/<dirName>/`.
|
||||
*
|
||||
* **Not a shared cwd any more.** Two designs were tried and retracted before this one. First, one directory
|
||||
* per commander, dropped the night it was proposed: a fresh directory had to exist before or exactly when a
|
||||
* commander's first turn fired, and racing that creation against the harness's own spawn was the kind of
|
||||
* timing hazard Claude Code's directory-per-project convention was never built to be driven by. So it became
|
||||
* one shared directory for every commander a user has — which removed the race but made the directory the
|
||||
* commander's only identity, which is what made the ordering problem `docs/commander-directory.md` describes
|
||||
* inescapable. Then, briefly (2026-08-22), a commander chose an arbitrary directory anywhere in its owner's
|
||||
* tree, once identity moved to the `command_hq_sessions` row instead of the cwd.
|
||||
*
|
||||
* **One directory per commander again, and the race is gone for a different reason than either previous
|
||||
* attempt solved it.** The row (and so `dirName`) is created by `POST /command-hq/commanders` BEFORE any
|
||||
* turn spawns — the ordering fix is exactly what makes a fresh, empty, commander-named directory safe to
|
||||
* create ahead of time: nothing races its own creation, because creation happens first and spawning waits
|
||||
* on it. See `docs/commander-directory.md`.
|
||||
*
|
||||
* `DATA_PATH`-rooted even for the OWNER, which is a deliberate departure from ordinary `/chat` — an owner's
|
||||
* normal chat runs in `getOwnerHomeDir` (their real login home). Commanders use the managed directory
|
||||
* instead, on both sides of the account split, which is what keeps them out of the owner's own project
|
||||
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list. See `docs/command-hq.md`.
|
||||
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list.
|
||||
*
|
||||
* **A sibling of `home`, but reachable like `home` — not confined like every other sibling.** Every other
|
||||
* directory alongside a member's `home` (`attachments`, `email_accounts`, …) is 700 and owned by the
|
||||
@@ -216,11 +226,51 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
||||
* wrong for a directory a member's own `claude` must `cd` into via `setpriv`. `confineUserTree`
|
||||
* (`os-user.ts`) chowns this one to the member the same way it chowns `home`. An account provisioned
|
||||
* BEFORE this existed will not have that chown until it is re-provisioned once — see `docs/command-hq.md`.
|
||||
*
|
||||
* That chown is recursive at provisioning time only — a subdirectory created for a NEW commander afterwards
|
||||
* is written by the platform process (the service user), not by the member, so it will not automatically
|
||||
* carry the member's ownership the way the root does. Not solved here; flagged in
|
||||
* `docs/commander-directory.md`'s implementation notes as a gap for a member-owned commander specifically,
|
||||
* not a regression this file introduces.
|
||||
*/
|
||||
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
|
||||
|
||||
/**
|
||||
* Where a commander records what it spawned — one directory per commander, named by its session id.
|
||||
* One commander's own directory — `CLAUDE.md`, `state/`, and whatever it writes for itself. `dirName` is
|
||||
* `command_hq_sessions.dir_name`, derived from the row's own serial id at creation. See
|
||||
* `docs/commander-directory.md`.
|
||||
*/
|
||||
/**
|
||||
* One commander's own directory.
|
||||
*
|
||||
* **Guarded, because two callers `rm -rf` what this returns** — deleting a commander removes its directory,
|
||||
* and `dropSpawnedRecord` removes its `state/`. An empty `dirName` would resolve to the commanders ROOT and
|
||||
* take every commander the account has with it.
|
||||
*
|
||||
* That is not hypothetical arithmetic. `createCommander` inserts the row with `dirName: ''` and fixes it up
|
||||
* in the same statement pair, because the name is derived from a serial id that does not exist until the
|
||||
* insert has happened. The transaction means nothing else can observe that state — and a guard here means
|
||||
* it does not have to, which is the difference between safe and safe-so-far. A traversal is refused for the
|
||||
* same reason, cheaply, rather than reasoned about.
|
||||
*/
|
||||
export const getCommanderDir = (email: string, dirName: string): string => {
|
||||
if (!dirName || dirName.includes('/') || dirName === '.' || dirName === '..') {
|
||||
throw new Error(`refusing to resolve a commander directory for ${JSON.stringify(dirName)}`);
|
||||
}
|
||||
return join(getCommandersDir(email), dirName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a commander records what it spawned and its loop's own pid — `state/` inside its own directory,
|
||||
* keyed by `dirName` rather than a transcript id.
|
||||
*
|
||||
* Used to be a directory of its own (`commander-state/<transcript id>/`, a sibling of the transcripts
|
||||
* rather than the commander), because there was nothing else to key it on before a commander had an
|
||||
* identity independent of its transcript. Collapsed in here 2026-08-24: `state/` is INSTANCE data (what a
|
||||
* commander has handled, what it spawned, its loop's pid) as opposed to `CLAUDE.md` and whatever else the
|
||||
* commander writes beside it, which is DEFINITION — the split `docs/commander-directory.md` calls
|
||||
* structural, kept as a subdirectory rather than a convention precisely so it is enforced by a path rather
|
||||
* than remembered by whoever writes into it.
|
||||
*
|
||||
* Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does
|
||||
* not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the
|
||||
@@ -231,8 +281,7 @@ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'comma
|
||||
* That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted
|
||||
* completely, its children cannot be followed, and its cost cannot be totalled.
|
||||
*/
|
||||
export const getCommanderStateDir = (email: string, sessionId: string) =>
|
||||
join(DATA_PATH, email, 'commander-state', sessionId);
|
||||
export const getCommanderStateDir = (email: string, dirName: string) => join(getCommanderDir(email, dirName), 'state');
|
||||
|
||||
/**
|
||||
* Which mail account's store to use, found on DISK rather than by asking the database.
|
||||
|
||||
@@ -44,6 +44,8 @@ export type NotificationEntry = {
|
||||
thread?: string;
|
||||
};
|
||||
|
||||
export type PluginCatalogueSource = { appName: string; dir: string; manifest: { publisher: string } };
|
||||
|
||||
export type Catalogue = {
|
||||
/**
|
||||
* Changes whenever the aggregate does. A CONTENT HASH, not a counter.
|
||||
@@ -195,6 +197,58 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single plugin's catalogue, read from its OWN directory.
|
||||
*
|
||||
* Separate from `loadPublishedEntries` because the namespace has to come from somewhere else. That
|
||||
* function derives `<publisher>/<appName>` from the path, which worked while installing COPIED a
|
||||
* plugin's yaml to `capabilities/notifications/<publisher>/<appName>/`. Read in place the path is just
|
||||
* `notifications/catalogue.yaml` and carries no identity at all — so the codes would silently vanish,
|
||||
* which is the failure this exists to prevent.
|
||||
*
|
||||
* The manifest supplies it instead, and that is strictly better provenance: `publisher` and `appName` are
|
||||
* validated at discovery, where the path was only ever a directory somebody chose. The property that
|
||||
* matters is unchanged — the platform imposes the namespace and the yaml cannot claim one. Writing
|
||||
* `officer/core:test` as a key still produces `<you>/<yourplugin>:officer/core:test`.
|
||||
*/
|
||||
export function loadPluginEntries(plugin: {
|
||||
appName: string;
|
||||
dir: string;
|
||||
manifest: { publisher: string };
|
||||
}): Record<string, NotificationEntry> {
|
||||
const root = join(plugin.dir, 'notifications');
|
||||
let files: string[];
|
||||
try {
|
||||
files = (readdirSync(root, { recursive: true }) as string[]).filter((f) => /\.ya?ml$/.test(f));
|
||||
} catch {
|
||||
return {}; // shipping none is the ordinary case
|
||||
}
|
||||
|
||||
const { publisher } = plugin.manifest;
|
||||
const entries: Record<string, NotificationEntry> = {};
|
||||
|
||||
for (const rel of files.sort()) {
|
||||
let doc: unknown;
|
||||
try {
|
||||
doc = YAML.parse(readFileSync(join(root, rel), 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (doc === null || typeof doc !== 'object') continue;
|
||||
|
||||
for (const [key, value] of Object.entries(doc as Record<string, unknown>)) {
|
||||
const entry = coerce(value);
|
||||
if (!entry) continue;
|
||||
entries[`${publisher}/${plugin.appName}:${key}`] = {
|
||||
...entry,
|
||||
thread: entry.thread ?? `${publisher}/${plugin.appName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole catalogue: the platform's own plus everything installed plugins publish.
|
||||
*
|
||||
@@ -202,8 +256,11 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
|
||||
* from its install path — so this only matters if the store is edited by hand, and the platform's own
|
||||
* notifications are the ones that must keep working when it is.
|
||||
*/
|
||||
export function loadCatalogue(root: string = NOTIFICATIONS_DIR): Catalogue {
|
||||
const entries = { ...loadPublishedEntries(root), ...CORE_ENTRIES };
|
||||
export function loadCatalogue(root: string = NOTIFICATIONS_DIR, plugins: PluginCatalogueSource[] = []): Catalogue {
|
||||
// Plugin entries first so the owner's own store, then core, still win a collision — the same precedence
|
||||
// as before, with plugins now read from their own directories rather than copies.
|
||||
const fromPlugins = Object.assign({}, ...plugins.map(loadPluginEntries));
|
||||
const entries = { ...fromPlugins, ...loadPublishedEntries(root), ...CORE_ENTRIES };
|
||||
|
||||
// Sorted so the hash depends on content, not on the order the filesystem happened to return.
|
||||
const canonical = JSON.stringify(
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { NOTIFICATIONS_DIR } from './catalogue';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Copying a plugin's notification catalogue into the store the sidecar reads.
|
||||
//
|
||||
// Deliberately the same shape as `publishAffordances` — a directory in the plugin repository, copied to a
|
||||
// platform-managed place on install, removed on uninstall. Two mechanisms for "the plugin ships some
|
||||
// files" would drift, and that one is already understood.
|
||||
//
|
||||
// The destination namespace is what makes codes safe: `<publisher>/<appName>/`, mirroring `mountPrefix`,
|
||||
// so a plugin's codes are stamped with its provenance by WHERE IT LANDS rather than by anything it
|
||||
// declares. See `catalogue.ts`.
|
||||
|
||||
/** Where a plugin's catalogue lands. `officerdev/music/`, beside `officer/core`'s built-in entries. */
|
||||
export const notificationDirFor = (plugin: DiscoveredPlugin): string =>
|
||||
join(NOTIFICATIONS_DIR, plugin.manifest.publisher, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const notificationSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'notifications');
|
||||
|
||||
/**
|
||||
* Publish a plugin's catalogue. Returns the number of files, or 0 when it ships none — the ordinary case.
|
||||
*
|
||||
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT remove
|
||||
* files the plugin has since dropped; that is uninstall's job, and deleting on every install would make a
|
||||
* half-finished copy destructive.
|
||||
*/
|
||||
export async function publishNotifications(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = notificationSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
const dest = notificationDirFor(plugin);
|
||||
await cp(source, dest, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => /\.ya?ml$/.test(f)).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's catalogue.
|
||||
*
|
||||
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
||||
* plugin's, and taking it out because one was uninstalled would strip the rest.
|
||||
*
|
||||
* The codes then stop resolving, which is a defined state rather than a broken one — a device holding a
|
||||
* queued push for a departed plugin renders the unknown-code fallback. See docs/push-notifications.md.
|
||||
*
|
||||
* Safe to call when nothing was ever published.
|
||||
*/
|
||||
export async function unpublishNotifications(plugin: DiscoveredPlugin): Promise<void> {
|
||||
await rm(notificationDirFor(plugin), { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createRole } from 'officerdb';
|
||||
import { db } from 'officerdb/db';
|
||||
import { roles } from 'officerdb/schema';
|
||||
import { like } from 'drizzle-orm';
|
||||
import { roleDevGrants } from './os-user-postgres';
|
||||
|
||||
/*
|
||||
`roleDevGrants` is the replacement for the hardcoded `role === 'Developer'` gate — see the comment above
|
||||
it. What matters here is that it reads the ROW, not the name: a role called something else entirely gets
|
||||
dev tooling if and only if its `grantsPostgres`/`grantsDocker` columns say so, and a role that does not
|
||||
exist reads as neither rather than throwing.
|
||||
*/
|
||||
|
||||
const PREFIX = 'zz-test-devgrants-';
|
||||
const name = (suffix: string) => `${PREFIX}${suffix}`;
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(roles).where(like(roles.name, `${PREFIX}%`));
|
||||
});
|
||||
|
||||
describe('roleDevGrants', () => {
|
||||
test('reads both flags off a role that is not named Developer', async () => {
|
||||
const created = await createRole({ name: name('custom-dev'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
expect(await roleDevGrants(name('custom-dev'))).toEqual({ grantsPostgres: true, grantsDocker: true });
|
||||
});
|
||||
|
||||
test('a role with grantsDocker false does not get docker, even with grantsPostgres true', async () => {
|
||||
const created = await createRole({ name: name('pg-only'), grantsPostgres: true, grantsDocker: false });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
expect(await roleDevGrants(name('pg-only'))).toEqual({ grantsPostgres: true, grantsDocker: false });
|
||||
});
|
||||
|
||||
test('a role granted neither gets neither', async () => {
|
||||
const created = await createRole({ name: name('plain') });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
expect(await roleDevGrants(name('plain'))).toEqual({ grantsPostgres: false, grantsDocker: false });
|
||||
});
|
||||
|
||||
test('a role that does not exist reads as neither, matching "absence denies" rather than throwing', async () => {
|
||||
await expect(roleDevGrants(name('never-created'))).resolves.toEqual({
|
||||
grantsPostgres: false,
|
||||
grantsDocker: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import postgres from 'postgres';
|
||||
import { db } from 'officerdb/db';
|
||||
import { getRole } from 'officerdb';
|
||||
import { osUserHome, runAs } from './os-user';
|
||||
|
||||
// A Postgres login role for a member, named the same as their Linux account.
|
||||
@@ -66,20 +66,34 @@ import { osUserHome, runAs } from './os-user';
|
||||
// OWNER TO, CREATE OR REPLACE over the top, and `PGOPTIONS=-c event_triggers=off` (that GUC is superuser
|
||||
// only). A superuser can still use it, which is the recovery path if this ever locks something out.
|
||||
|
||||
/** Which dev tools a role's accounts get, read off the role row. See `roleDevGrants` for the reasoning. */
|
||||
export type DevToolGrants = { grantsPostgres: boolean; grantsDocker: boolean };
|
||||
|
||||
/**
|
||||
* Which platform roles get a database role at all.
|
||||
* Which platform roles get a database role, and which get a rootless Docker daemon — read from the
|
||||
* `roles` table (`grantsPostgres`, `grantsDocker`) instead of tested by name.
|
||||
*
|
||||
* `Developer` only. This is the gate rootless Docker was under before it was disabled, inherited by the
|
||||
* thing that replaced it rather than invented for it — a member who was not trusted to run containers is
|
||||
* not thereby trusted to run databases.
|
||||
* Historically this was `role === 'Developer'`, a single hardcoded check that gated both. Docker was the
|
||||
* original gate — Postgres is what replaced rootless Docker per member (disabled 2026-08-13), and it
|
||||
* inherited Docker's role gate rather than being given a new one: a member who was not trusted to run
|
||||
* containers is not thereby trusted to run databases. That history is why both flags still default to the
|
||||
* same seed value for `Developer` (`scripts/seed-roles.ts`), even though the row now lets them diverge for
|
||||
* a custom role.
|
||||
*
|
||||
* `Admin` is deliberately NOT included. Administering the platform is not developing on it, and the two
|
||||
* are separate roles precisely so they can be held separately. Add it here if that turns out to be wrong;
|
||||
* do not add it at a call site.
|
||||
* `Admin` is deliberately seeded with both flags false. Administering the platform is not developing on
|
||||
* it, and the two are separate roles precisely so they can be held separately — see the seed for where
|
||||
* that is now decided, since there is no longer a call site to add it at.
|
||||
*
|
||||
* The owner never reaches this — they run as the service account and already have the cluster.
|
||||
* The owner never reaches this — they run as the service account and already have the cluster and a
|
||||
* regular Docker install.
|
||||
*
|
||||
* A role that no longer exists (renamed out from under a stale reference, or deleted) reads as neither
|
||||
* grant, matching "absence denies" everywhere else in this system rather than throwing.
|
||||
*/
|
||||
export const rolePermitsDevTools = (role: UserRole): boolean => role === 'Developer';
|
||||
export async function roleDevGrants(role: string): Promise<DevToolGrants> {
|
||||
const row = await getRole(role);
|
||||
return { grantsPostgres: row?.grantsPostgres ?? false, grantsDocker: row?.grantsDocker ?? false };
|
||||
}
|
||||
|
||||
/** Legal Linux/Postgres account name. Identical to `validateUsername`, restated because this one reaches SQL. */
|
||||
const ROLE_NAME_RE = /^[a-zA-Z0-9._-]{2,32}$/;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { listRoles, setRoleGrant } from 'officerdb';
|
||||
import { invalidateRoleGrants } from '../permissions/authorize';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
|
||||
/**
|
||||
* Installing a plugin grants it to every role.
|
||||
*
|
||||
* ── The policy, and why it changed ──
|
||||
*
|
||||
* Until 2026-08-24 a newly installed plugin reached nobody. Every grant had to be added by hand afterwards,
|
||||
* per role, through the permissions screen — and the owner was the only one who could do it. The defensive
|
||||
* default made sense while the permission model was being built and nobody had members; it stopped making
|
||||
* sense once installing a plugin became a deliberate administrative act. A system administrator installing
|
||||
* something has already decided the server should have it. Requiring a second decision, in a different
|
||||
* screen, to make it reachable is a step whose only reliable outcome is that it gets forgotten and the
|
||||
* plugin appears broken.
|
||||
*
|
||||
* So: install grants, the owner revokes what should not be there. `uninstallPlugin` already removes those
|
||||
* grants, so the pair is symmetric and reinstalling means granting again — which is the honest behaviour.
|
||||
*
|
||||
* ── What did NOT change, and this is the important part ──
|
||||
*
|
||||
* **"Absence denies" is still the only rule in the resolver.** This writes ROWS. It does not teach
|
||||
* `authorize.ts` that a missing grant means yes, which would have been the other way to get this behaviour
|
||||
* and is the version that bites later: every capability with no row for any reason — a failed write, a
|
||||
* half-finished install, a bug — would become permitted, and nothing would look wrong.
|
||||
*
|
||||
* The blast radius is also only ever a plugin's own keys. `core` is granted to everyone by definition,
|
||||
* `execution` and `admin` are never grantable at any level, and `confined` still resolves to nothing for an
|
||||
* account with no Linux user. None of that is reachable from here.
|
||||
*
|
||||
* ── Why the owner's role is skipped ──
|
||||
*
|
||||
* Not politeness: `ck_role_permissions_not_owner` is a CHECK constraint that refuses `Super Admin` in that
|
||||
* table outright, so including it would fail the whole insert rather than grant anything. The owner
|
||||
* bypasses every permission check before grants are consulted, so the row would be inert regardless — and
|
||||
* that schema comment says why an inert row is worse than none: somebody eventually revokes one and expects
|
||||
* it to do something.
|
||||
*
|
||||
* The name is a literal here because it is a literal in the constraint. There is no constant for it: the
|
||||
* owner is an IDENTITY (`OWNER_USER_ID`) rather than a role, and this string is the one place the old
|
||||
* role-shaped notion of ownership still survives.
|
||||
*/
|
||||
const OWNER_ROLE_NAME = 'Super Admin';
|
||||
|
||||
export type GrantOnInstallResult = { granted: number; roles: string[] };
|
||||
|
||||
export async function grantPluginToAllRoles(plugin: DiscoveredPlugin): Promise<GrantOnInstallResult> {
|
||||
const keys = plugin.manifest.permissions.map((p) => p.key);
|
||||
if (!keys.length) return { granted: 0, roles: [] };
|
||||
|
||||
const roles = (await listRoles()).map((r) => r.name).filter((name) => name !== OWNER_ROLE_NAME);
|
||||
if (!roles.length) return { granted: 0, roles: [] };
|
||||
|
||||
let granted = 0;
|
||||
for (const role of roles) {
|
||||
for (const key of keys) {
|
||||
// `write`, not `read`. "Full access unless revoked" is the policy, and a read-only default would be a
|
||||
// third state nobody asked for — the owner would still have to visit the screen to make the plugin
|
||||
// usable, which is the step this exists to remove.
|
||||
await setRoleGrant(role, key, 'write');
|
||||
granted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The cache is keyed on role and its whole invalidation contract is this call. Without it the grants are
|
||||
// in the database and invisible until something else happens to invalidate — which would present as the
|
||||
// plugin still being unreachable right after an install that said it granted access.
|
||||
invalidateRoleGrants();
|
||||
|
||||
return { granted, roles };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a role created at runtime starts with.
|
||||
*
|
||||
* The mirror of the above, and it closes a gap that predates the policy change. `DEFAULT_ROLE_PERMISSIONS`
|
||||
* — the `confined` baseline every seeded role gets — was only ever applied in `auth/bootstrap.ts`, so a
|
||||
* role created through `/api/roles` began with NOTHING. Not the plugins, not even chat, files and terminal.
|
||||
* A new "Support" role could reach less than Member and nothing said why.
|
||||
*
|
||||
* That was survivable while the default was deny-everything and grants were expected to be added by hand.
|
||||
* It stops being survivable now: leaving it would mean the new policy holds for roles that existed when a
|
||||
* plugin was installed and not for any created afterwards, which is the kind of half-applied rule that
|
||||
* reads as a bug in whichever half somebody meets first.
|
||||
*
|
||||
* So a new role starts where Member does — the confined baseline plus every installed plugin — and the
|
||||
* owner takes away what should not be there. Same direction as everything else here.
|
||||
*/
|
||||
export async function seedGrantsForNewRole(role: string): Promise<number> {
|
||||
if (role === OWNER_ROLE_NAME) return 0;
|
||||
|
||||
const { snapshotPlugins } = await import('./mount');
|
||||
const { DEFAULT_ROLE_PERMISSIONS } = await import('../permissions/registry');
|
||||
|
||||
const keys = new Set<string>(DEFAULT_ROLE_PERMISSIONS);
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
for (const p of state.plugin.manifest.permissions) keys.add(p.key);
|
||||
}
|
||||
} catch {
|
||||
// The baseline still lands. A plugin list that cannot be read is a louder problem elsewhere, and
|
||||
// failing the whole seed over it would leave the role with nothing — the state this exists to prevent.
|
||||
}
|
||||
|
||||
for (const key of keys) await setRoleGrant(role, key, 'write');
|
||||
invalidateRoleGrants(role);
|
||||
return keys.size;
|
||||
}
|
||||
@@ -16,10 +16,8 @@ import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } fr
|
||||
import { discoverPlugins } from './discover';
|
||||
import { fetchPlugin } from './fetch';
|
||||
import { revokeGrantsForPermissions } from 'officerdb';
|
||||
import { grantPluginToAllRoles } from './grant-on-install';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
|
||||
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
|
||||
import { publishSkills, unpublishSkills } from '../skills/publish';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import { publishExtension, unpublishExtension, extensionFileName } from './zip';
|
||||
import { generatePluginSchemas, pushPluginSchema } from './schema';
|
||||
@@ -402,40 +400,19 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`);
|
||||
}
|
||||
|
||||
// What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at
|
||||
// runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is
|
||||
// authenticated unless the plugin says so. Same shape as the assets above — a directory in the
|
||||
// repository, copied to a platform-managed place.
|
||||
// GRANT IT TO EVERYONE. Installing a plugin is a deliberate administrative act, and requiring a second
|
||||
// decision in a different screen to make it reachable has one reliable outcome: it gets forgotten and
|
||||
// the plugin looks broken. The owner revokes what should not be there.
|
||||
//
|
||||
// Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install
|
||||
// over it would cost the owner the plugin.
|
||||
// Rows, not a rule change — authorize.ts still denies on absence. See grant-on-install.ts.
|
||||
try {
|
||||
const published = await publishAffordances(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`);
|
||||
}
|
||||
const { granted, roles } = await grantPluginToAllRoles(plugin);
|
||||
if (granted) await step(steps, onStep, `access: granted to ${roles.join(', ')}`);
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs,
|
||||
// and its pushes render the unknown-code fallback on the device rather than nothing at all.
|
||||
try {
|
||||
const published = await publishNotifications(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`);
|
||||
}
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does
|
||||
// not learn the reference exists, which is the state every plugin was in before this.
|
||||
try {
|
||||
const published = await publishSkills(plugin);
|
||||
if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`);
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
// Not fatal, and said out loud rather than swallowed: the plugin is installed and the owner can reach
|
||||
// it, so the grants can be added by hand. Silently installing something nobody can reach is the exact
|
||||
// failure this change removes, so it must not become a silent failure of its own.
|
||||
await step(steps, onStep, `access: NOT granted — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
|
||||
@@ -535,12 +512,13 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
|
||||
// Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops
|
||||
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
|
||||
// worse than none, because it reads as true.
|
||||
if (plugin) await unpublishAffordances(plugin).catch(() => {});
|
||||
// And its notification codes, which stop resolving rather than resolving to something stale. A device
|
||||
// still holding a queued push renders the unknown-code fallback, which is a defined state.
|
||||
if (plugin) await unpublishNotifications(plugin).catch(() => {});
|
||||
if (plugin) await unpublishSkills(plugin).catch(() => {});
|
||||
await step(steps, onStep, 'tables and data: untouched (published assets, affordances and notifications removed)');
|
||||
await step(
|
||||
steps,
|
||||
onStep,
|
||||
'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)',
|
||||
);
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
|
||||
@@ -552,11 +552,14 @@ function armIdle(session: PersistentSession): void {
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function createSession(
|
||||
// Async only because the preamble is: resolving which plugins are installed and enabled, so their
|
||||
// capabilities can be read where they live, reads the database. Once per session — see
|
||||
// `capability-sources.ts`. Its single caller already awaits.
|
||||
async function createSession(
|
||||
params: ClaudeSpawnStreamingParams,
|
||||
onEvent: (event: ChatEvent) => void,
|
||||
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
|
||||
): PersistentSession {
|
||||
): Promise<PersistentSession> {
|
||||
const { sessionKey } = params;
|
||||
const input = makeInputQueue();
|
||||
const abort = new AbortController();
|
||||
@@ -625,24 +628,24 @@ function createSession(
|
||||
|
||||
// Read here rather than at module load: a plugin installed an hour ago should be live on the next
|
||||
// message, and this costs a few kilobytes once per session.
|
||||
const affordances = affordanceBlock();
|
||||
const affordances = await affordanceBlock();
|
||||
// The skills INDEX — names, descriptions and paths, never bodies. See skills/load.ts for why this is
|
||||
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
|
||||
// written an hour ago should be offered on the next session.
|
||||
const skills = skillsBlock();
|
||||
const skills = await skillsBlock();
|
||||
/*
|
||||
And, for a commander, the two paths only this session can be told.
|
||||
No commander block here, and none in `startClaudeTurn` either, as of `docs/commander-directory.md`.
|
||||
|
||||
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
|
||||
preamble, which is where this used to live and which an agent arming itself never sees. See
|
||||
`api/command-hq/commander-block.ts`.
|
||||
*/
|
||||
/*
|
||||
No commander block here, deliberately.
|
||||
Two things used to live here in turn, both dead ends. First, the background-job dialog's own preamble,
|
||||
which an agent arming itself from the conversation never saw. Then a per-turn block riding
|
||||
`effectivePrompt` (`commander-block.ts`, deleted 2026-08-24) — which could not resolve on a commander's
|
||||
FIRST turn either, since this function runs once per session and that turn has no row and no transcript
|
||||
id yet, so the block was null and no later turn ever rebuilt the preamble.
|
||||
|
||||
It used to be composed in, and could never arrive: this runs once per session, and a commander's first
|
||||
turn has no row and no transcript id, so the block resolved to null and no later turn rebuilt the
|
||||
preamble. It rides `effectivePrompt` instead — see `startClaudeTurn` in api/chat/websocket.ts.
|
||||
`POST /command-hq/commanders` creates the row, its directory and its `CLAUDE.md` BEFORE any turn spawns
|
||||
now, so a commander is told its name, its reference path and the two paths it must record itself at by a
|
||||
FILE Claude Code reads from its own working directory at session start — the same mechanism this
|
||||
function's `affordances`/`skills` blocks still use for everything else, minus the per-turn cost.
|
||||
*/
|
||||
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
|
||||
|
||||
@@ -916,7 +919,7 @@ export async function spawnClaudeStreaming(
|
||||
await applyModelChange(session, params.model);
|
||||
await applyPlanModeChange(session, params.planMode);
|
||||
} else {
|
||||
session = createSession(params, onEvent, onNotify);
|
||||
session = await createSession(params, onEvent, onNotify);
|
||||
}
|
||||
session.pushTurn(params.prompt, params.images);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Channel, DeliveryResult, Notification } from './types';
|
||||
import { loadCatalogue } from '../../notifications/catalogue';
|
||||
import { pluginCatalogueSources } from '../../capability-sources';
|
||||
import { discordChannel } from './discord';
|
||||
import { apnsChannel } from './apns';
|
||||
import { fcmChannel } from './fcm';
|
||||
@@ -28,7 +29,7 @@ export async function dispatch(n: Notification): Promise<DeliveryResult[]> {
|
||||
// Read ONCE, here — not per channel and not per device. `version` travels with the push as `mv`, and
|
||||
// every copy of one notification must carry the same one; two channels reading disk either side of a
|
||||
// plugin install would otherwise tell two devices different things about the same event.
|
||||
const ctx = { catalogue: loadCatalogue() };
|
||||
const ctx = { catalogue: loadCatalogue(undefined, await pluginCatalogueSources()) };
|
||||
|
||||
return Promise.all(
|
||||
active.map(async (c) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSidecarConnector } from '../connect';
|
||||
import { dispatch, configuredChannels } from './dispatch';
|
||||
import { handleDeviceRoute } from './devices';
|
||||
import { loadCatalogue } from '../../notifications/catalogue';
|
||||
import { pluginCatalogueSources } from '../../capability-sources';
|
||||
import { resolveNotifyUser } from './resolve-user';
|
||||
import { closeApnsSessions } from './apns';
|
||||
import type { Notification } from './types';
|
||||
@@ -76,7 +77,7 @@ const server = Bun.serve({
|
||||
// it would present as "the new plugin's notifications don't work" — the hardest kind of bug to place.
|
||||
// It is a few kilobytes off local disk against a request the app makes about once per launch.
|
||||
if (url.pathname === '/mapping' && req.method === 'GET') {
|
||||
const catalogue = loadCatalogue();
|
||||
const catalogue = loadCatalogue(undefined, await pluginCatalogueSources());
|
||||
return Response.json(catalogue, {
|
||||
// The client decides by `version`, so a cached copy would defeat the sync it exists to drive.
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ITEMS_DIR } from '../data-path';
|
||||
import { capabilityRoots } from '../capability-sources';
|
||||
|
||||
// Skills: reference the agent reads WHEN IT NEEDS IT, not on every turn.
|
||||
//
|
||||
@@ -104,6 +105,31 @@ export function compileSkills(skills: Skill[]): string | null {
|
||||
}
|
||||
|
||||
/** Read and compile in one step, for callers that only want the string. */
|
||||
export function skillsBlock(root: string = SKILLS_DIR): string | null {
|
||||
return compileSkills(loadSkills(root));
|
||||
/**
|
||||
* Every skill on offer: the owner's own, plus each installed and enabled plugin's own directory.
|
||||
*
|
||||
* A plugin's skills are read WHERE THEY LIVE rather than copied into `capabilities/` — see
|
||||
* `capability-sources.ts` for why, and `docs/plugin-capabilities.md` for the whole argument.
|
||||
*
|
||||
* **The owner's own win a name collision**, because `capabilityRoots` puts their directory first and this
|
||||
* keeps the first of each id. That is the right precedence: a skill somebody wrote deliberately should
|
||||
* not be displaced by a plugin that happened to pick the same directory name, and a plugin cannot
|
||||
* silently take over the meaning of a name already in use.
|
||||
*/
|
||||
export function loadSkillsAcross(roots: string[]): Skill[] {
|
||||
const byId = new Map<string, Skill>();
|
||||
for (const root of roots) {
|
||||
for (const skill of loadSkills(root)) if (!byId.has(skill.id)) byId.set(skill.id, skill);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and compile in one step, for callers that only want the string.
|
||||
*
|
||||
* Async because resolving which plugins are enabled reads the database. Called once per session, which is
|
||||
* what makes that affordable — see the note in `capability-sources.ts`.
|
||||
*/
|
||||
export async function skillsBlock(): Promise<string | null> {
|
||||
return compileSkills(loadSkillsAcross(await capabilityRoots('skills')));
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SKILLS_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Publishing a plugin's skills, the same shape as affordances and notification catalogues.
|
||||
//
|
||||
// Deliberately NOT namespaced by publisher, unlike those two. A skill's directory name is what appears in
|
||||
// the index and what the agent reads a path to, and `officerdev/gitea/SKILL.md` in a prompt is noise for
|
||||
// a human and for a model. Collisions are handled by the loader taking whichever sorts first, which is
|
||||
// the same rule the items store has always used — one flat namespace, first come.
|
||||
//
|
||||
// That trade is only acceptable because a skill is REFERENCE. An affordance's namespace matters because a
|
||||
// plugin must not be able to claim `officer/core:`; a skill has no authority to usurp.
|
||||
|
||||
export const skillDirFor = (plugin: DiscoveredPlugin): string => join(SKILLS_DIR, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const skillSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'skills');
|
||||
|
||||
/** Publish a plugin's skills. Returns the number of directories, or 0 when it ships none. */
|
||||
export async function publishSkills(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = skillSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
await cp(source, SKILLS_DIR, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's skills.
|
||||
*
|
||||
* Removes only the directories THIS plugin shipped, read from its own source tree — not everything under
|
||||
* `skills/`, which holds the platform's own and every other plugin's.
|
||||
*/
|
||||
export async function unpublishSkills(plugin: DiscoveredPlugin): Promise<void> {
|
||||
const source = skillSourceFor(plugin);
|
||||
if (!existsSync(source)) return;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const dir of dirs) await rm(join(SKILLS_DIR, dir), { recursive: true, force: true });
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import enGB from './en-GB.json';
|
||||
import esES from './es-ES.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import svSE from './sv-SE.json';
|
||||
|
||||
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
|
||||
export const uiCatalogs: TranslationMap = {
|
||||
@@ -12,6 +13,7 @@ export const uiCatalogs: TranslationMap = {
|
||||
'es-ES': esES,
|
||||
'de-DE': deDE,
|
||||
'fr-FR': frFR,
|
||||
'sv-SE': svSE,
|
||||
};
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('shared components (ui.*) locale catalogs', () => {
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Avbryt",
|
||||
"confirm": "Bekräfta",
|
||||
"ok": "Ok",
|
||||
"close": "Stäng"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Sök",
|
||||
"open": "Öppna sökning",
|
||||
"close": "Stäng sökning",
|
||||
"clear": "Rensa sökning"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Välj..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrera"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Kör i terminalen",
|
||||
"copy": "Kopiera kommando"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Hexfärg"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Läser in…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Ingen data hittades.",
|
||||
"pagination": {
|
||||
"showingPage": "Visar sida {{current}} av {{total}}",
|
||||
"showingPageShort": "s {{current}}/{{total}}",
|
||||
"rowsPerPage": "Rader per sida",
|
||||
"perPage": "{{size}} / sida",
|
||||
"perPageShort": "{{size}}/s",
|
||||
"first": "Första",
|
||||
"prev": "Föreg.",
|
||||
"next": "Nästa",
|
||||
"last": "Sista"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Sidnumrering",
|
||||
"previous": "Föregående",
|
||||
"next": "Nästa",
|
||||
"morePages": "Fler sidor",
|
||||
"goToPrevious": "Gå till föregående sida",
|
||||
"goToNext": "Gå till nästa sida"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Föregående bild",
|
||||
"next": "Nästa bild"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Mer"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Växla färgläge"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Obehörig åtkomst",
|
||||
"description": "Du har inte behörighet att utföra denna åtgärd.",
|
||||
"hint": "Om du tror att detta är ett fel, kontakta din ansvariga person."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Ogiltiga inloggningsuppgifter",
|
||||
"description": "Din åtkomsttoken har gått ut",
|
||||
"hint": "Logga in igen"
|
||||
},
|
||||
"server": {
|
||||
"title": "Serverfel"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Officer-ikon",
|
||||
"logo": "Officer-logotyp"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export { I18nProvider, I18nContext } from './I18nProvider';
|
||||
export { useTranslation } from './useTranslation';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
export { findMissingKeys, findExtraKeys } from './parity';
|
||||
// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach.
|
||||
export { activeLocale } from './standalone';
|
||||
export type { MissingKey } from './parity';
|
||||
|
||||
@@ -25,3 +25,24 @@ export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>):
|
||||
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys a locale has that `en-GB` does NOT — the other direction, and it is not symmetry for its own sake.
|
||||
*
|
||||
* A locale written against a source that has since changed keeps the old names, and `findMissingKeys`
|
||||
* cannot see that: it only asks whether every English key is present. Rename a key and the translated
|
||||
* catalogue holds both the new one (missing, caught) and the stale one (extra, invisible), so half the
|
||||
* problem is reported and the file quietly grows dead entries.
|
||||
*
|
||||
* It happened the day sv-SE landed. It was translated from a working tree where two keys had been renamed
|
||||
* and that rename was not yet committed, so on `master` the Swedish catalogue named two keys English did
|
||||
* not have — and every parity test passed, because none of them looked in this direction.
|
||||
*/
|
||||
export const findExtraKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
|
||||
const enKeys = new Set(flattenKeys(en));
|
||||
return Object.entries(locales).flatMap(([locale, catalog]) =>
|
||||
flattenKeys(catalog)
|
||||
.filter((key) => !enKeys.has(key))
|
||||
.map((key) => ({ locale, key })),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,8 +11,11 @@ type EmbeddableChatProps = {
|
||||
* What the transcript shows before there is anything in it. `null` shows nothing.
|
||||
*
|
||||
* The default, t('chat.composer.startHint'), is true on the chat screen and wrong wherever something else
|
||||
* already explains what to do — a new commander has a setup card and a Start button above it, so the
|
||||
* message contradicts them and takes 200px of height while doing it.
|
||||
* already explains what to do, or where nothing shows it at all: a new commander used to render its setup
|
||||
* card and Start button inside the same pane as this composer, which is what this prop was added for —
|
||||
* `docs/commander-directory.md` moved setup into its own component, mounted before any chat exists, so
|
||||
* there is no longer a caller passing `null` here. Left in place; a future caller with the same shape of
|
||||
* conflict should use it rather than reinvent it.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
/** Which panel this is — see `queue-store`. Absent on the screen, where there is only one chat. */
|
||||
@@ -44,9 +47,12 @@ type EmbeddableChatProps = {
|
||||
/**
|
||||
* Refuse input entirely, with a reason the composer shows.
|
||||
*
|
||||
* For a surface where something must be chosen before a conversation can start — a new commander needs a
|
||||
* name and a working directory, both unchangeable afterwards. Disabling the composer is the only honest
|
||||
* place to enforce that: anywhere later and the person has already written what they meant to say.
|
||||
* For a surface where something must be chosen before a conversation can start at all. Disabling the
|
||||
* composer is the only honest place to enforce that: anywhere later and the person has already written
|
||||
* what they meant to say. No current caller uses this — a new commander used to (name and working
|
||||
* directory, both unchangeable afterwards), until `docs/commander-directory.md` moved that choice into a
|
||||
* separate setup step BEFORE any chat exists to disable, which needed no `disabled` at all — but the prop
|
||||
* is generic and worth keeping for the next surface with the same shape of requirement.
|
||||
*/
|
||||
disabled?: false | { reason: string };
|
||||
onMessageComplete?: () => void;
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
useCommandHqChildren,
|
||||
useCommandHqCommander,
|
||||
useCommandHqCommanderActions,
|
||||
useCreateCommander,
|
||||
} from './useCommandHq';
|
||||
import type { CommandHqCommanderCreated } from './types';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
import { useTranslation } from '../../locales';
|
||||
@@ -26,9 +28,10 @@ import { useTranslation } from '../../locales';
|
||||
// read the route directly, per `docs/navigation-audit.md`):
|
||||
//
|
||||
// no :sessionId → empty state, "select or start one"
|
||||
// :sessionId === 'new' → the composer that CREATES a commander (an ordinary chat turn with
|
||||
// `context: 'command-hq'`, per `docs/command-hq.md` → "How a commander is
|
||||
// created" — there is no separate "file this" step)
|
||||
// :sessionId === 'new' → the composer. `POST /command-hq/commanders` creates the row, ITS directory
|
||||
// and `CLAUDE.md` first (`NewCommanderSetup`, below); only once that exists does
|
||||
// a chat turn start, with `cwd` set to that directory (`ActiveNewCommander`) —
|
||||
// see `docs/commander-directory.md` for why the ordering is the fix
|
||||
// :sessionId is a real id → the commander's own transcript, resumed, plus a metadata bar
|
||||
|
||||
const CHAT_TAIL = 20;
|
||||
@@ -89,12 +92,13 @@ const NewCommanderGuide = () => {
|
||||
* before asking anything at all, all live in the Commanders skill — which is re-read from disk every run, so
|
||||
* it can be reworded without a deploy. Duplicating any of it here would create a second copy to drift.
|
||||
*/
|
||||
const startBrief = (name: string): string =>
|
||||
const startBrief = (name: string, referencePath: string): string =>
|
||||
[
|
||||
// i18n-exempt: agent brief — the MODEL reads this; the transcript shows displayText to the person
|
||||
`You have been named "${name}", and that is the only thing I have told you about what I want.`,
|
||||
'It is not decoration — I chose it before anything else, so treat it as the subject and read this',
|
||||
'directory looking for what it refers to.',
|
||||
`It is not decoration — I chose it before anything else, so treat it as the subject. Your own`,
|
||||
`CLAUDE.md already names what to look at: \`${referencePath}\`. Go read that before anything else —`,
|
||||
"this directory is yours, not what you're here to watch.",
|
||||
'',
|
||||
'Read the **Commanders** skill before you reply. It is how this works, and it is short.',
|
||||
'',
|
||||
@@ -148,20 +152,20 @@ type StartCommanderButtonProps = {
|
||||
* these choices without leaving the page, and somebody who already knows can fill both in without opening
|
||||
* anything.
|
||||
*
|
||||
* The directory is a button rather than a text field. It is an absolute path on the server and typing one is
|
||||
* how you get a commander running in a directory that does not exist — `DirPickerModal` browses the account's
|
||||
* own tree, which is the same containment the server applies to whatever it is handed.
|
||||
* The reference path is a button rather than a text field. It is an absolute path on the server and typing
|
||||
* one is how you get a commander pointed at a path that does not exist — `DirPickerModal` browses the
|
||||
* account's own tree, which is the same containment the server applies to whatever it is handed.
|
||||
*/
|
||||
const CommanderSetup = ({
|
||||
name,
|
||||
onName,
|
||||
cwd,
|
||||
onPickDirectory,
|
||||
referencePath,
|
||||
onPickReferencePath,
|
||||
}: {
|
||||
name: string;
|
||||
onName: (next: string) => void;
|
||||
cwd: string | null;
|
||||
onPickDirectory: () => void;
|
||||
referencePath: string | null;
|
||||
onPickReferencePath: () => void;
|
||||
}) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
return (
|
||||
@@ -186,34 +190,95 @@ const CommanderSetup = ({
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('commandHq.workingDirectory')}
|
||||
{t('commandHq.referencePath')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickDirectory}
|
||||
onClick={onPickReferencePath}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
cwd
|
||||
referencePath
|
||||
? 'border-border bg-background'
|
||||
: 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{cwd ?? 'Choose a directory…'}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{referencePath ?? 'Choose a directory…'}
|
||||
</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">{t('commandHq.cwdHint')}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('commandHq.referencePathHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The setup step — name and reference path, nothing live yet. No `useChat` here at all: there is nothing to
|
||||
* talk to until `POST /command-hq/commanders` returns a row, its directory and its `CLAUDE.md`
|
||||
* (`docs/commander-directory.md`). `onCreated` hands the result up to `NewCommander`, which switches to
|
||||
* `ActiveNewCommander` — a fresh mount, not a state transition inside one component, which is what lets
|
||||
* that component's own `useChat` be constructed with `commanderId` already known from its very first render
|
||||
* rather than raced against a `setState` that has not landed yet.
|
||||
*/
|
||||
function NewCommanderSetup({
|
||||
category,
|
||||
onCreated,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [referencePath, setReferencePath] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const ready = name.trim().length > 0 && !!referencePath;
|
||||
const createCommander = useCreateCommander();
|
||||
|
||||
const onStart = async () => {
|
||||
if (!ready || creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const commander = await createCommander.mutateAsync({
|
||||
name: name.trim(),
|
||||
referencePath: referencePath!,
|
||||
categoryId: typeof category === 'number' ? category : undefined,
|
||||
});
|
||||
onCreated(commander);
|
||||
} catch (err) {
|
||||
toast.error(errorText(err, 'could not create this commander'));
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<CommanderSetup
|
||||
name={name}
|
||||
onName={setName}
|
||||
referencePath={referencePath}
|
||||
onPickReferencePath={() => setPickerOpen(true)}
|
||||
/>
|
||||
<StartCommanderButton
|
||||
ready={ready && !creating}
|
||||
blockedBy={!name.trim() ? 'Name it first' : !referencePath ? 'Choose a reference path' : 'Creating…'}
|
||||
onStart={() => void onStart()}
|
||||
/>
|
||||
<NewCommanderGuide />
|
||||
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setReferencePath} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
|
||||
*
|
||||
* `useChat` mints the transcript id on the harness's first response (`session:claude`); once that lands the
|
||||
* URL is replaced onto it so a refresh resumes this conversation instead of starting a second one.
|
||||
* `replaceUrl: false` because `useChat`'s own permalink always writes `/chat/<id>` (see `writePermalink`),
|
||||
* which is the wrong screen here.
|
||||
* Mounted only once `NewCommanderSetup` has a real row — `commander` is a prop, not state raced against a
|
||||
* `useChat` built before it existed, which is the whole reason this is a SEPARATE component rather than a
|
||||
* branch inside one. `useChat` mints the transcript id on the harness's first response (`session:claude`);
|
||||
* once that lands the URL is replaced onto it so a refresh resumes this conversation instead of starting a
|
||||
* second one. `replaceUrl: false` because `useChat`'s own permalink always writes `/chat/<id>` (see
|
||||
* `writePermalink`), which is the wrong screen here.
|
||||
*
|
||||
* **The URL change must not remount anything.** It used to: the pane swapped to `ExistingCommander`, which
|
||||
* tore down the live websocket and re-read the transcript from the API. The whole conversation vanished at
|
||||
@@ -222,103 +287,79 @@ const CommanderSetup = ({
|
||||
* Tearing down mid-turn is worse than it looks with a commander, whose turns are long: the pane would drop
|
||||
* the stream and the reader would watch nothing happen for twenty minutes.
|
||||
*
|
||||
* So this component holds the session from the composer through to the end, exactly as `/chat` does — a
|
||||
* permalink is for a refresh, not an instruction to reload. `CommandHqDetail` keeps it mounted by
|
||||
* So this component holds the session from the first message through to the end, exactly as `/chat` does —
|
||||
* a permalink is for a refresh, not an instruction to reload. `CommandHqDetail` keeps it mounted by
|
||||
* remembering which id started here.
|
||||
*/
|
||||
function NewCommander({
|
||||
function ActiveNewCommander({
|
||||
commander,
|
||||
category,
|
||||
onStarted,
|
||||
}: {
|
||||
commander: CommandHqCommanderCreated;
|
||||
category: number | 'none' | undefined;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const { t, tUi } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
/*
|
||||
A name and a working directory, both required BEFORE the first message.
|
||||
|
||||
Neither can be set afterwards without cost. The directory decides where every relative path in every turn
|
||||
resolves and which project group holds the transcripts, so changing it later would strand a conversation
|
||||
away from its own history. The name has to reach the transcript the moment it exists — `commanderName` on
|
||||
the turn — because Claude Code names a new session from its first prompt, and the platform's rename only
|
||||
wins by getting there first.
|
||||
|
||||
So the composer is disabled until both are chosen. That is why this lives on the card rather than in a
|
||||
dialog: you read what a commander is and make the two decisions in the same place.
|
||||
*/
|
||||
const [name, setName] = useState('');
|
||||
const [cwd, setCwd] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const ready = name.trim().length > 0 && !!cwd;
|
||||
|
||||
const chat = useChat(undefined, undefined, {
|
||||
replaceUrl: false,
|
||||
context: 'command-hq',
|
||||
// The name rides the turn; the DIRECTORY is a prop on EmbeddableChat below, because that is what feeds
|
||||
// `sendPrompt`'s `cwdParam`. Two different paths for two values chosen in the same place, which is worth
|
||||
// a note rather than a discovery.
|
||||
...(name.trim() ? { commanderName: name.trim() } : {}),
|
||||
// Known from this component's very first render — see the docstring above for why that timing is the
|
||||
// whole point of the split. Rides every turn, not just the first: `resolveChatCwd` reads it server-side
|
||||
// on every one, and `fileAsCommander` uses it to point this row at the transcript id the moment it
|
||||
// exists.
|
||||
commanderId: commander.id,
|
||||
});
|
||||
// Also the transcript id: `command_hq_sessions` is keyed on it, so an action addressed by officer's key
|
||||
// updates nothing and says nothing. Same root cause as the navigate below.
|
||||
const { setCategory } = useCommandHqCommanderActions(chat.claudeSessionId ?? '');
|
||||
const [filed, setFiled] = useState(false);
|
||||
|
||||
/*
|
||||
Has this commander been started — which is NOT the same as "has it any messages".
|
||||
|
||||
`messages.length === 0` was standing in for it, and it lies in exactly the situation that matters.
|
||||
Lose the socket mid-turn and the messages come back empty, so the setup pane and its Start button
|
||||
reappear on a commander that is already running; pressing Start then sends the opening brief a
|
||||
SECOND time, and the agent answers a question nobody asked twice. Seen tonight, after a restart I
|
||||
ran while a commander was live.
|
||||
|
||||
A latch, not a derived value. Once started, always started — the point is to survive the moment the
|
||||
live state goes empty, so anything recomputed from that state is the bug rather than the fix.
|
||||
Sent exactly once, the moment this component mounts — not on a button click captured in a closure, so
|
||||
there is nothing here racing `setState` the way the old single-component version did. `useRef`, not
|
||||
`useState`: a ref write is visible to the very next synchronous check, which is what makes "send it
|
||||
exactly once" enforceable at all without a second render to notice a state flag had already flipped.
|
||||
*/
|
||||
const [started, setStarted] = useState(false);
|
||||
const sentRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (chat.messages.length > 0 || chat.claudeSessionId || chat.isGenerating) setStarted(true);
|
||||
}, [chat.messages.length, chat.claudeSessionId, chat.isGenerating]);
|
||||
if (sentRef.current) return;
|
||||
sentRef.current = true;
|
||||
chat.sendPrompt(
|
||||
startBrief(commander.name ?? '', commander.referencePaths[0] ?? ''),
|
||||
undefined,
|
||||
undefined,
|
||||
{ path: commander.cwd },
|
||||
undefined,
|
||||
undefined,
|
||||
'Set me up.',
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/*
|
||||
Refresh the grid when the first turn ends.
|
||||
|
||||
The commander's NAME is written server-side, into the transcript's summary record, on the first
|
||||
`result` — the rename can only land once the transcript exists. Nothing tells the client that
|
||||
happened, so the list kept whatever title it had fetched before, which for a self-started commander
|
||||
is the opening brief: the pill read "I have given you a name and a working directory…" while the
|
||||
header beside it read the real name from local state.
|
||||
|
||||
The falling edge of `isGenerating` is the signal, rather than a poll or a shorter `staleTime`: it is
|
||||
the one moment the server is known to have written something the list is showing.
|
||||
|
||||
Command HQ's own metadata query (`GET /commanders/:sessionId`) has nothing to show until the row has a
|
||||
`sessionId` — which lands mid-turn, off `fileAsCommander` — so the grid and any open detail view are
|
||||
stale until this fires once.
|
||||
*/
|
||||
const wasGenerating = useRef(false);
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !chat.isGenerating) {
|
||||
// Both shapes, not just the grid: the header reads the per-commander query and went stale the same
|
||||
// way, so the first attempt at this fixed the pill and left the title beside it still wrong.
|
||||
qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] });
|
||||
qc.invalidateQueries({ queryKey: ['command-hq', 'commander'] });
|
||||
}
|
||||
wasGenerating.current = chat.isGenerating;
|
||||
}, [chat.isGenerating, qc]);
|
||||
|
||||
const [filed, setFiled] = useState(false);
|
||||
useEffect(() => {
|
||||
// `claudeSessionId`, NOT `sessionId`. The commander is filed under Claude's transcript id
|
||||
// (`fileAsCommander` in websocket.ts), and this URL has to match or the detail pane fetches an id
|
||||
// nothing knows. `sessionId` arrives FIRST — `session:init` sets it to officer's own key — so
|
||||
// navigating on it sent the page to an id that 404s, and `filed` then blocked the correction when the
|
||||
// real one arrived. A commander that had run and answered correctly rendered as "Send a message to
|
||||
// start". Waiting for the transcript id costs one more round trip and is the only id that works.
|
||||
// `claudeSessionId`, NOT `sessionId`. The commander is addressed by Claude's transcript id everywhere
|
||||
// else in Command HQ, and this URL has to match or the detail pane fetches an id nothing knows.
|
||||
// `sessionId` arrives FIRST — `session:init` sets it to officer's own key — so navigating on it sent the
|
||||
// page to an id that 404s, and `filed` then blocked the correction when the real one arrived.
|
||||
if (!chat.claudeSessionId || filed) return;
|
||||
setFiled(true);
|
||||
// `fileAsCommander` (websocket.ts) always files with no category — it has no way to know one was
|
||||
// requested from here. The same race the `/commanders/new` HTTP route resolves server-side by always
|
||||
// running its UPDATE last; done client-side here for the same reason, since the web composer goes
|
||||
// through the websocket path instead.
|
||||
if (typeof category === 'number') void setCategory.mutateAsync(category).catch(() => {});
|
||||
onStarted(chat.claudeSessionId);
|
||||
navigate(commandHqSessionPath(chat.claudeSessionId, category), { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -329,15 +370,9 @@ function NewCommander({
|
||||
{chat.claudeSessionId ? (
|
||||
<MetaBar
|
||||
sessionId={chat.claudeSessionId}
|
||||
/*
|
||||
The name the owner typed, straight from state.
|
||||
|
||||
It cannot come from the server yet: the rename lands at the END of the first turn (the transcript
|
||||
does not exist when the turn starts), and this bar has no fetched title of its own — a brand-new
|
||||
commander has nothing to resume. So the header read "Untitled commander" until a refresh, showing
|
||||
the one thing on screen that nobody had chosen while the chosen name sat in a field above it.
|
||||
*/
|
||||
title={name.trim() || undefined}
|
||||
// The name chosen at creation, straight from the row this component was handed — it does not need
|
||||
// to come from the server again: `POST /commanders` already wrote it before this component mounted.
|
||||
title={commander.name ?? undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onDisconnect={chat.disconnectSession}
|
||||
@@ -351,61 +386,37 @@ function NewCommander({
|
||||
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
|
||||
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
|
||||
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
|
||||
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
|
||||
message to start", which is true and tells a first-time user nothing about what to send. */}
|
||||
{/*
|
||||
One scroll region for the whole pre-start pane, rather than three `shrink-0` siblings.
|
||||
|
||||
They stacked to whatever height they wanted and nothing could scroll: the guide carried its own
|
||||
`overflow-y-auto`, which never engaged, because `shrink-0` had already given it its full natural
|
||||
height. Fine at 100%, and the panel system is zoomable — at 120% the setup ran off the bottom of
|
||||
the panel with no way to reach it.
|
||||
|
||||
So the scroller is here, where the height is actually constrained, and the composer below keeps
|
||||
`shrink-0` so it stays reachable at the bottom whatever the zoom.
|
||||
*/}
|
||||
{!started && (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<CommanderSetup name={name} onName={setName} cwd={cwd} onPickDirectory={() => setPickerOpen(true)} />
|
||||
<StartCommanderButton
|
||||
ready={ready}
|
||||
blockedBy={!name.trim() ? 'Name it first' : 'Choose a working directory'}
|
||||
onStart={() =>
|
||||
// Positional, and the shape is why `opts` was added rather than a tenth slot: displayText is #7.
|
||||
chat.sendPrompt(
|
||||
startBrief(name.trim()),
|
||||
undefined,
|
||||
undefined,
|
||||
cwd ? { path: cwd } : undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'Set me up.',
|
||||
undefined,
|
||||
{ selfStarted: true },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NewCommanderGuide />
|
||||
</div>
|
||||
)}
|
||||
{/* Disabled until both are chosen. The composer is where somebody would otherwise type first and
|
||||
discover the requirement afterwards, having already written what they meant to say. */}
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
sessionId={undefined}
|
||||
context="command-hq"
|
||||
{...(cwd ? { cwd: { path: cwd } } : {})}
|
||||
disabled={
|
||||
!started && !ready ? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' } : false
|
||||
}
|
||||
className={!started ? 'shrink-0' : 'min-h-0 flex-1'}
|
||||
{...(!started ? { emptyState: null } : {})}
|
||||
cwd={{ path: commander.cwd }}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setCwd} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer screen — a thin switch between the two phases described above. `commander` is local state,
|
||||
* not derived from the URL: once set, it never goes back to `NewCommanderSetup`, even if the live chat
|
||||
* state momentarily looks empty (a restart mid-turn, say) — the switch depends on "did we create a row",
|
||||
* not on "does the chat have messages right now", which is exactly the class of bug the OLD single-component
|
||||
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
|
||||
* socket dropped").
|
||||
*/
|
||||
function NewCommander({
|
||||
category,
|
||||
onStarted,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
||||
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
||||
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents this commander spawned, each a link to its own transcript.
|
||||
*
|
||||
|
||||
@@ -40,6 +40,8 @@ export type CommandHqCommander = {
|
||||
/** `GET /command-hq/commanders/:sessionId` — Command HQ's own metadata, not the transcript. */
|
||||
export type CommandHqCommanderDetail = {
|
||||
sessionId: string;
|
||||
name: string | null;
|
||||
referencePaths: string[];
|
||||
categoryId: number | null;
|
||||
tagIds: number[];
|
||||
running: boolean;
|
||||
@@ -51,6 +53,21 @@ export type CommandHqCommanderDetail = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `POST /command-hq/commanders` — the row just created, no transcript yet. `cwd` is the commander's own
|
||||
* directory (`$DATA_PATH/<email>/commanders/<dirName>/`), computed server-side — see
|
||||
* `docs/commander-directory.md`. This is what a client starts its first chat turn's `cwd` from.
|
||||
*/
|
||||
export type CommandHqCommanderCreated = {
|
||||
id: number;
|
||||
dirName: string;
|
||||
cwd: string;
|
||||
name: string | null;
|
||||
referencePaths: string[];
|
||||
categoryId: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CommandHqTurn = {
|
||||
id: number;
|
||||
fireId: number | null;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { CommandHqCategory, CommandHqCategoryFilter, CommandHqCommander, CommandHqCommanderDetail } from './types';
|
||||
import type {
|
||||
CommandHqCategory,
|
||||
CommandHqCategoryFilter,
|
||||
CommandHqCommander,
|
||||
CommandHqCommanderCreated,
|
||||
CommandHqCommanderDetail,
|
||||
} from './types';
|
||||
|
||||
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
||||
const commandersKey = (category: CommandHqCategoryFilter) => ['command-hq', 'commanders', category ?? 'all'] as const;
|
||||
@@ -9,6 +15,23 @@ const commanderKey = (sessionId: string) => ['command-hq', 'commander', sessionI
|
||||
const EMPTY_CATEGORIES: CommandHqCategory[] = [];
|
||||
const EMPTY_COMMANDERS: CommandHqCommander[] = [];
|
||||
|
||||
/**
|
||||
* Create a commander — the row, its directory and its `CLAUDE.md`, all before any turn spawns. See
|
||||
* `docs/commander-directory.md`. The caller starts its chat turn with `cwd` set to the response's `cwd` and
|
||||
* `commanderId` set to its `id`, which is what lets the server point this same row at the transcript id the
|
||||
* moment one exists rather than creating a second row for it.
|
||||
*/
|
||||
export function useCreateCommander() {
|
||||
const { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: { name: string; referencePath: string; categoryId?: number | null }) =>
|
||||
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
|
||||
});
|
||||
}
|
||||
|
||||
/** The user's categories, flat — v1 has no nesting and no search, per `docs/command-hq.md` → "Layout, v1". */
|
||||
export function useCommandHqCategories() {
|
||||
const { get, post } = useClient();
|
||||
|
||||
@@ -27,14 +27,18 @@ type UsePiChatOptions = {
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/**
|
||||
* The name chosen for a COMMANDER before its first message.
|
||||
* The COMMANDER row this conversation belongs to — `command_hq_sessions.id`, created by
|
||||
* `POST /command-hq/commanders` BEFORE this hook's first turn is ever sent. See
|
||||
* `docs/commander-directory.md`.
|
||||
*
|
||||
* Rides every turn rather than just the first: the server applies it once, in `fileAsCommander`, and
|
||||
* sending it again costs a few bytes. Sending it only on the first turn would mean losing it if that turn
|
||||
* failed and was retried, which is the case that matters — a commander named after its second prompt is
|
||||
* the bug this exists to prevent.
|
||||
* Rides every turn rather than just the first: the server uses it twice — to point the row at the
|
||||
* transcript id the moment one exists (`fileAsCommander`), and on EVERY turn to resolve `cwd` to the
|
||||
* commander's own directory server-side (`resolveChatCwd`) rather than trusting a client-chosen path.
|
||||
* Dropping it after turn one would mean turn two runs with nothing telling the server which directory
|
||||
* this is, other than the transcript id it resolves to once a resume id exists — which is one more
|
||||
* lookup than just continuing to say so.
|
||||
*/
|
||||
commanderName?: string;
|
||||
commanderId?: number;
|
||||
resumeSummary?: string;
|
||||
resumeSessionId?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
@@ -82,7 +86,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
projectScoped,
|
||||
context,
|
||||
contextId,
|
||||
commanderName,
|
||||
commanderId,
|
||||
resumeSummary: initialResumeSummary,
|
||||
resumeSessionId,
|
||||
initialMessages: preloadedMessages,
|
||||
@@ -802,20 +806,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
* exact message that starts the watching turn, not a later one.
|
||||
*/
|
||||
armWatcher?: boolean,
|
||||
/**
|
||||
* Anything else this turn needs to say about ITSELF rather than about its content.
|
||||
*
|
||||
* An object rather than a tenth positional: nine is already past the point where a call site reads as
|
||||
* a row of undefineds, and every flag added here would make that worse for every existing caller.
|
||||
*/
|
||||
opts?: {
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opening message, so this turn is a request for the
|
||||
* commander to interview them — see `commanderBlock`. Only the client that rendered the button can
|
||||
* know this, which is why it rides the message rather than being derived on the server.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
},
|
||||
) {
|
||||
// Mark session as started on first message
|
||||
if (!hasStarted) {
|
||||
@@ -856,7 +846,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
type: 'chat',
|
||||
prompt: text,
|
||||
...(displayText ? { displayText } : {}),
|
||||
...(opts?.selfStarted ? { selfStarted: true } : {}),
|
||||
sessionId: sessionIdRef.current,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
||||
@@ -869,7 +858,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(context ? { context } : {}),
|
||||
...(contextId ? { contextId } : {}),
|
||||
...(commanderName ? { commanderName } : {}),
|
||||
...(commanderId !== undefined ? { commanderId } : {}),
|
||||
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
...(armWatcher ? { armWatcher } : {}),
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
"startConversation": "Unterhaltung starten",
|
||||
"name": "Name",
|
||||
"nameHint": "Wird der Unterhaltung bei ihrem Start zugewiesen, statt sie vom Harness anhand Ihrer ersten Nachricht benennen zu lassen.",
|
||||
"workingDirectory": "Arbeitsverzeichnis",
|
||||
"cwdHint": "Von hier aus werden alle ausgeführten Befehle aufgelöst, und hier wird der Verlauf gespeichert. Kann später nicht verschoben werden.",
|
||||
"referencePath": "Referenzpfad",
|
||||
"referencePathHint": "Worauf dieser Kommandant achtet oder woran er arbeitet — nicht, wo er lebt. Er kann später selbst weitere hinzufügen.",
|
||||
"new": "Neuer Commander",
|
||||
"spawned": "Gestartet",
|
||||
"deleteQ": "Löschen?",
|
||||
@@ -242,7 +242,7 @@
|
||||
"whileRunningBody1": "TODO: was der Eigentümer sieht — wo die Kosten angezeigt werden, wie man erkennt, was die von ihm gestarteten Agenten gerade tun, und wie man ihn stoppt.",
|
||||
"draft": "Entwurf"
|
||||
},
|
||||
"startHint": "Er sieht sich zunächst in diesem Verzeichnis um und fragt Sie dann, worauf Sie achten möchten.",
|
||||
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
|
||||
"startBlocked": "{{missing}} — dann startet dies ihn.",
|
||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
|
||||
},
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
"startConversation": "Start the conversation",
|
||||
"name": "Name",
|
||||
"nameHint": "Applied to the conversation as it starts, instead of the harness naming it after your first message.",
|
||||
"workingDirectory": "Working directory",
|
||||
"cwdHint": "Where every command it runs resolves from, and where its history is kept. It cannot be moved later.",
|
||||
"referencePath": "Reference path",
|
||||
"referencePathHint": "What this commander watches or works on — not where it lives. It can add more of these itself once it's running.",
|
||||
"new": "New commander",
|
||||
"spawned": "Spawned",
|
||||
"deleteQ": "Delete?",
|
||||
@@ -242,7 +242,7 @@
|
||||
"whileRunningBody1": "TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.",
|
||||
"draft": "draft"
|
||||
},
|
||||
"startHint": "It will look around this directory, then ask you what to watch for.",
|
||||
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
||||
"startBlocked": "{{missing}} — then this starts it.",
|
||||
"confirmDeletingNamed": "Confirm deleting {{name}}"
|
||||
},
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
"startConversation": "Iniciar la conversación",
|
||||
"name": "Nombre",
|
||||
"nameHint": "Se aplica a la conversación al iniciarse, en lugar de que el harness la nombre a partir de tu primer mensaje.",
|
||||
"workingDirectory": "Directorio de trabajo",
|
||||
"cwdHint": "Desde donde se resuelve cada comando que ejecuta, y donde se guarda su historial. No se puede mover después.",
|
||||
"referencePath": "Ruta de referencia",
|
||||
"referencePathHint": "Lo que este comandante vigila o en lo que trabaja — no dónde vive. Puede añadir más por sí mismo una vez en marcha.",
|
||||
"new": "Nuevo commander",
|
||||
"spawned": "Iniciado",
|
||||
"deleteQ": "¿Eliminar?",
|
||||
@@ -242,7 +242,7 @@
|
||||
"whileRunningBody1": "TODO: lo que ve el propietario — dónde aparece su coste, cómo leer lo que están haciendo los agentes que ha iniciado, y cómo detenerlo.",
|
||||
"draft": "borrador"
|
||||
},
|
||||
"startHint": "Explorará este directorio y luego te preguntará qué debe vigilar.",
|
||||
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
||||
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
|
||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
|
||||
},
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
"startConversation": "Démarrer la conversation",
|
||||
"name": "Nom",
|
||||
"nameHint": "Appliqué à la conversation dès son démarrage, au lieu de laisser le harness la nommer d'après votre premier message.",
|
||||
"workingDirectory": "Répertoire de travail",
|
||||
"cwdHint": "D'où toute commande qu'il exécute est résolue, et où son historique est conservé. Il ne peut pas être déplacé ensuite.",
|
||||
"referencePath": "Chemin de référence",
|
||||
"referencePathHint": "Ce que ce commandant surveille ou sur quoi il travaille — pas où il vit. Il peut en ajouter d'autres lui-même une fois en cours d'exécution.",
|
||||
"new": "Nouveau commander",
|
||||
"spawned": "Lancé",
|
||||
"deleteQ": "Supprimer ?",
|
||||
@@ -242,7 +242,7 @@
|
||||
"whileRunningBody1": "TODO : ce que le propriétaire voit — où son coût s'affiche, comment lire ce que font les agents qu'il a lancés, et comment l'arrêter.",
|
||||
"draft": "brouillon"
|
||||
},
|
||||
"startHint": "Il va regarder autour de ce répertoire, puis vous demander ce qu'il doit surveiller.",
|
||||
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
|
||||
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
|
||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import enGB from './en-GB.json';
|
||||
import esES from './es-ES.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import svSE from './sv-SE.json';
|
||||
|
||||
/** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */
|
||||
export const panelCatalogs: TranslationMap = {
|
||||
@@ -12,6 +13,7 @@ export const panelCatalogs: TranslationMap = {
|
||||
'es-ES': esES,
|
||||
'de-DE': deDE,
|
||||
'fr-FR': frFR,
|
||||
'sv-SE': svSE,
|
||||
};
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('officerdev (panel.*) locale catalogs', () => {
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
"startConversation": "Iniciar a conversa",
|
||||
"name": "Nome",
|
||||
"nameHint": "Aplicado à conversa quando esta começa, em vez de o harness a nomear a partir da sua primeira mensagem.",
|
||||
"workingDirectory": "Diretório de trabalho",
|
||||
"cwdHint": "A partir de onde todos os comandos que executa são resolvidos, e onde o histórico é guardado. Não pode ser movido depois.",
|
||||
"referencePath": "Caminho de referência",
|
||||
"referencePathHint": "Aquilo que este comandante vigia ou em que trabalha — não onde vive. Pode acrescentar mais por si próprio depois de estar em funcionamento.",
|
||||
"new": "Novo comandante",
|
||||
"spawned": "Iniciado",
|
||||
"deleteQ": "Eliminar?",
|
||||
@@ -242,7 +242,7 @@
|
||||
"whileRunningBody1": "TODO: o que o proprietário vê — onde aparece o custo, como ler o que os agentes que ele inicia estão a fazer, e como o parar.",
|
||||
"draft": "rascunho"
|
||||
},
|
||||
"startHint": "Vai observar este diretório e depois perguntar-lhe o que deve vigiar.",
|
||||
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
||||
"startBlocked": "{{missing}} — depois é isto que o inicia.",
|
||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Spara",
|
||||
"delete": "Ta bort"
|
||||
},
|
||||
"qrTransfer": {
|
||||
"chooseFile": "Välj en fil att skicka",
|
||||
"startScanning": "Börja skanna",
|
||||
"receiveAnother": "Ta emot en till",
|
||||
"stop": "Stoppa"
|
||||
},
|
||||
"dav": {
|
||||
"empty": "Inget här än. Lägg till en från en telefon eller stationär klient via CalDAV/CardDAV.",
|
||||
"calendar": "Kalender",
|
||||
"contacts": "Kontakter",
|
||||
"readFailed": "Det gick inte att läsa {{name}}: {{reason}}"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
"find": "Sök",
|
||||
"runningShells": "Körande skal",
|
||||
"loading": "Läser in…",
|
||||
"noShells": "Inga skal körs.",
|
||||
"refresh": "Uppdatera",
|
||||
"kill": "Döda detta skal"
|
||||
},
|
||||
"appStore": {
|
||||
"loading": "Läser in",
|
||||
"loadFailed": "Det gick inte att läsa in appbutiken.",
|
||||
"installed": "Installerad",
|
||||
"available": "Tillgänglig",
|
||||
"needsYou": "Behöver dig",
|
||||
"needsYouShort": "behöver dig",
|
||||
"selectApp": "Välj en app",
|
||||
"installFailed": "Installationen misslyckades",
|
||||
"uninstall": "Avinstallera",
|
||||
"enable": "Aktivera",
|
||||
"disable": "Inaktivera",
|
||||
"enabled": "aktiverad",
|
||||
"disabled": "inaktiverad",
|
||||
"removalNote": "Att inaktivera stoppar sidovagnen och dess container. Att avinstallera tar också bort containrarna — din data, konfiguration och den här appens tabeller sparas i båda fallen.",
|
||||
"installing": "installerar",
|
||||
"failed": "misslyckades",
|
||||
"installedIcon": "installerad"
|
||||
},
|
||||
"systemMonitor": {
|
||||
"cpuPackage": "CPU-paket",
|
||||
"user": "Användare",
|
||||
"cpuPct": "CPU%",
|
||||
"memPct": "MEM%",
|
||||
"command": "Kommando",
|
||||
"memory": "Minne",
|
||||
"disks": "Diskar",
|
||||
"temperature": "Temperatur",
|
||||
"network": "Nätverk",
|
||||
"power": "Ström",
|
||||
"topProcesses": "Mest aktiva processer",
|
||||
"filterPlaceholder": "filtrera namn eller image…",
|
||||
"name": "Namn",
|
||||
"status": "Status",
|
||||
"mem": "Minne",
|
||||
"uptime": "Drifttid",
|
||||
"scopeDockers": "dockers"
|
||||
},
|
||||
"codeEditor": {
|
||||
"closeFile": "Stäng {{name}}",
|
||||
"loading": "Läser in…",
|
||||
"noFiles": "Inga filer",
|
||||
"explorer": "Utforskare",
|
||||
"openAFile": "Öppna en fil från utforskaren för att börja redigera",
|
||||
"refresh": "Uppdatera trädet och läs om oförändrade öppna filer",
|
||||
"readFailed": "Det gick inte att läsa filen",
|
||||
"saved": "Filen sparad",
|
||||
"saveFailed": "Det gick inte att spara filen",
|
||||
"title": "Kodeditor"
|
||||
},
|
||||
"widgets": {
|
||||
"resync": "Synka om widgetar (läs om för att fånga upp nyligen distribuerade widgetar)"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Förhandsgranskning",
|
||||
"reload": "Läs om",
|
||||
"changeUrl": "Ändra URL",
|
||||
"openNewTab": "Öppna i en ny flik",
|
||||
"pointAtApp": "Peka den här panelen mot en körande app",
|
||||
"open": "Öppna",
|
||||
"hint": "Allt webbläsaren kan nå. Det stannar kvar på den här panelen och överlever en omläsning.",
|
||||
"connecting": "Ansluter…"
|
||||
},
|
||||
"sendOff": {
|
||||
"files": "Filer",
|
||||
"where": "Vart",
|
||||
"loading": "Läser in…",
|
||||
"landsIn": "Hamnar i deras",
|
||||
"landedIn": "Hamnade i",
|
||||
"whoMaySend": "Vem som får skicka till mig",
|
||||
"allowedLandIn": "Filer från alla du tillåter hamnar i",
|
||||
"removeFile": "Ta bort {{name}}",
|
||||
"sendTo": "Skicka till {{name}}",
|
||||
"turningOff": "Att stänga av någon stoppar framtida sändningar och lämnar det de redan skickat orört."
|
||||
},
|
||||
"panelName": {
|
||||
"appStore": "Appbutik",
|
||||
"appDetail": "Appdetaljer",
|
||||
"calendar": "Kalender",
|
||||
"contacts": "Kontakter",
|
||||
"categories": "Kategorier",
|
||||
"chat": "Chatt",
|
||||
"chatSessions": "Sessioner",
|
||||
"codeEditor": "Kodeditor",
|
||||
"commander": "Commander",
|
||||
"commanders": "Commanders",
|
||||
"dashboardPreview": "Dashboard-förhandsgranskning",
|
||||
"dashboards": "Dashboards",
|
||||
"fileBrowser": "Filhanterare",
|
||||
"pluginDetail": "Plugin-detaljer",
|
||||
"plugins": "Plugins",
|
||||
"preview": "Förhandsgranskning",
|
||||
"qrReceive": "QR-mottagning",
|
||||
"qrSend": "QR-sändning",
|
||||
"scope": "Omfattning",
|
||||
"systemMonitor": "Systemövervakning",
|
||||
"widgets": "Widgetar",
|
||||
"terminal": "Terminal",
|
||||
"hostTerminal": "Värdterminal",
|
||||
"runningShells": "Körande skal",
|
||||
"pm2Processes": "pm2-processer"
|
||||
},
|
||||
"dashboards": {
|
||||
"title": "Dashboards",
|
||||
"new": "Ny dashboard",
|
||||
"delete": "Ta bort dashboard",
|
||||
"confirmDelete": "Är du säker på att du vill ta bort",
|
||||
"search": "Sök dashboards",
|
||||
"name": "Namn",
|
||||
"description": "Beskrivning",
|
||||
"open": "Öppna dashboard",
|
||||
"selectOne": "Välj en dashboard eller skapa en ny",
|
||||
"namePlaceholder": "Min Dashboard",
|
||||
"descriptionPlaceholder": "Vad är den här dashboarden till för?",
|
||||
"template": "Mall",
|
||||
"create": "Skapa",
|
||||
"templates": {
|
||||
"single": "Enkel",
|
||||
"twoColumns": "2 kolumner",
|
||||
"mainSide": "Huvud + sido",
|
||||
"sidebar": "Sidopanel",
|
||||
"grid": "2x2-rutnät",
|
||||
"colsBottom": "Kolumner + botten"
|
||||
}
|
||||
},
|
||||
"fileViewer": {
|
||||
"readAloud": "Läs upp",
|
||||
"ocr": "Extrahera text (OCR)",
|
||||
"transcribe": "Transkribera",
|
||||
"extractAudio": "Extrahera ljud",
|
||||
"extract": "Extrahera",
|
||||
"saveNextTo": "Spara bredvid originalet",
|
||||
"download": "Ladda ner",
|
||||
"pdfViewer": "PDF-visare",
|
||||
"videoFailed": "Det gick inte att läsa in videon",
|
||||
"imageFailed": "Det gick inte att läsa in bilden",
|
||||
"audioFailed": "Det gick inte att läsa in ljudfilen",
|
||||
"audioTrack": "Ljudspår",
|
||||
"subtitles": "Undertexter",
|
||||
"track": "Spår {{n}}",
|
||||
"saved": "Filen sparad",
|
||||
"saveFailed": "Det gick inte att spara filen",
|
||||
"scrollTop": "Rulla till toppen",
|
||||
"speechFailed": "Det gick inte att generera talljud",
|
||||
"ocrFailed": "Det gick inte att extrahera text från bilden",
|
||||
"transcribeFailed": "Det gick inte att transkribera ljudet",
|
||||
"extractAudioFailed": "Det gick inte att extrahera ljud från videon",
|
||||
"extractFailed": "Det gick inte att extrahera arkivet",
|
||||
"savedAs": "Sparad som ”{{name}}”",
|
||||
"extractedTo": "Extraherad till ”{{name}}”",
|
||||
"downloadInstead": "Ladda ner i stället",
|
||||
"archiveFile": "Arkivfil"
|
||||
},
|
||||
"plugins": {
|
||||
"loading": "Läser in…",
|
||||
"onThisMachine": "På den här maskinen",
|
||||
"noPluginsIn": "Inga plugins i",
|
||||
"available": "Tillgänglig",
|
||||
"needsOnThisMachine": "Behöver på den här maskinen",
|
||||
"install": "Installera",
|
||||
"clonesInto": "Klonar förvaret till",
|
||||
"deleteData": "Ta även bort all dess data",
|
||||
"noTables": "Denna plugin deklarerar inga tabeller, så det finns ingen data att spara.",
|
||||
"selectOne": "Välj en plugin.",
|
||||
"disable": "Inaktivera",
|
||||
"enable": "Aktivera",
|
||||
"publisher": "Utgivare",
|
||||
"version": "Version",
|
||||
"needsPlatform": "Behöver plattform",
|
||||
"wouldMount": "Skulle montera",
|
||||
"ships": "Levererar",
|
||||
"permissions": "Behörigheter",
|
||||
"source": "Källa",
|
||||
"mountsAt": "Monteras på",
|
||||
"sidecar": "Sidovagn",
|
||||
"deleteNamed": "Ta bort {{name}}",
|
||||
"updateTo": "Uppdatera till {{version}}"
|
||||
},
|
||||
"commandHq": {
|
||||
"selectOne": "Välj en commander, eller starta en ny.",
|
||||
"startConversation": "Starta konversationen",
|
||||
"name": "Namn",
|
||||
"nameHint": "Används för konversationen från start, i stället för att harnesset döper den efter ditt första meddelande.",
|
||||
"referencePath": "Referenssökväg",
|
||||
"referencePathHint": "Vad denna commander bevakar eller arbetar med — inte var den bor. Den kan lägga till fler av dessa själv när den väl körs.",
|
||||
"new": "Ny commander",
|
||||
"spawned": "Startade",
|
||||
"deleteQ": "Ta bort?",
|
||||
"uncategorised": "Okategoriserad",
|
||||
"loading": "Läser in…",
|
||||
"platformDeploys": "Plattformsdistributioner",
|
||||
"endSession": "Avsluta session — släpper den så den kan återupptas någon annanstans",
|
||||
"confirmDeleteHint": "Bekräfta borttagning — tar även bort konversationen, inte bara Command HQ-posten",
|
||||
"confirmDelete": "Bekräfta borttagning",
|
||||
"cancelDelete": "Avbryt borttagning",
|
||||
"deleteCommander": "Ta bort commander",
|
||||
"none": "Inga commanders här än.",
|
||||
"startOne": "Starta en",
|
||||
"confirmDeleteNamed": "Bekräfta borttagning av {{name}}",
|
||||
"deleteNamed": "Ta bort {{name}}",
|
||||
"categories": "Kategorier",
|
||||
"noCategories": "Inga kategorier än",
|
||||
"newCategory": "Ny kategori",
|
||||
"allCommanders": "Alla commanders",
|
||||
"categoryName": "Kategorinamn",
|
||||
"guide": {
|
||||
"whatIsIt": "Vad en commander är",
|
||||
"starting": "Starta en",
|
||||
"examples": "Exempel på ett första meddelande",
|
||||
"whileRunning": "Medan den körs",
|
||||
"whatIsItBody1": "En agent som fortsätter arbeta när du inte är här — den väntar på att något ska hända, agerar på det, och rapporterar tillbaka.",
|
||||
"whatIsItBody2": "Det är en vanlig konversation. Du börjar med att berätta vad den ska bevaka och vad den ska göra åt det, med dina egna ord.",
|
||||
"startingBody1": "Beskriv jobbet här, precis som du skulle till vem som helst. Säg vad den ska bevaka, var, och vad den ska göra när det utlöses.",
|
||||
"startingBody2": "TODO: de konkreta stegen, när skapandeflödet är fastställt. Detta ersätter ”öppna bakgrundsjobbsdialogen och aktivera en bevakare”, vilket ingen kunde förväntas gissa sig till.",
|
||||
"examplesBody1": "TODO: två eller tre exempel på inledande prompter, när skapandeflödet är fastställt. De är det som gör ”beskriv jobbet” till något en förstagångsanvändare kan agera på, i stället för bara en instruktion.",
|
||||
"whileRunningBody1": "TODO: vad ägaren ser — var kostnaden visas, hur man läser vad dess startade agenter gör, och hur man stoppar den.",
|
||||
"draft": "utkast"
|
||||
},
|
||||
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
|
||||
"startBlocked": "{{missing}} — sedan startar detta den.",
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}"
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Ny chatt",
|
||||
"tryAgain": "Försök igen",
|
||||
"refresh": "Uppdatera",
|
||||
"refreshSessions": "Uppdatera sessioner",
|
||||
"newChatInDir": "Ny chatt i en katalog…",
|
||||
"loadFailed": "Det gick inte att läsa in sessioner",
|
||||
"reading": "Läser transkript…",
|
||||
"noSessions": "Inga sessioner än",
|
||||
"liveFailed": "Det gick inte att läsa live-sessioner",
|
||||
"asking": "Frågar agenten…",
|
||||
"tabs": {
|
||||
"tree": "Träd",
|
||||
"latest": "Senaste",
|
||||
"live": "Live",
|
||||
"agents": "Agenter",
|
||||
"commanders": "Commanders"
|
||||
},
|
||||
"notifyPreference": "Notisinställning",
|
||||
"notify": {
|
||||
"everyTurn": "Vid varje turs slut",
|
||||
"inputNeeded": "Bara när indata behövs",
|
||||
"none": "Ingen"
|
||||
},
|
||||
"notificationsLabel": "Notiser: {{choice}}",
|
||||
"chooseDir": "Välj en arbetskatalog",
|
||||
"create": "Skapa",
|
||||
"newFolder": "Ny mapp",
|
||||
"selectFolder": "Välj denna mapp",
|
||||
"newFolderName": "Nytt mappnamn",
|
||||
"noSubfolders": {
|
||||
"one": "Inga synliga undermappar — {{count}} dold mapp är filtrerad bort",
|
||||
"other": "Inga synliga undermappar — {{count}} dolda mappar är filtrerade bort"
|
||||
},
|
||||
"showHidden": "Visa dolda",
|
||||
"showHiddenCount": "Visa dolda ({{count}})",
|
||||
"selectSession": "Välj en session att visa",
|
||||
"endSession": "Avsluta session — dödar en pågående tur och släpper den så den kan återupptas någon annanstans",
|
||||
"noReadable": "Inga läsbara konversationer här.",
|
||||
"noConversations": "Inga konversationer än",
|
||||
"newChatIn": "Ny chatt i {{path}}",
|
||||
"sessionTitle": "Sessionstitel",
|
||||
"saveTitle": "Spara titel",
|
||||
"cancelRename": "Avbryt namnändring",
|
||||
"confirmDelete": "Bekräfta borttagning",
|
||||
"cancelDelete": "Avbryt borttagning",
|
||||
"rename": "Byt namn",
|
||||
"deleteSession": "Ta bort session",
|
||||
"renameFailed": "Det gick inte att byta namn på sessionen: {{reason}}",
|
||||
"deleteFailed": "Det gick inte att ta bort sessionen: {{reason}}",
|
||||
"deleteAll": "Ta bort alla {{count}}?",
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}",
|
||||
"renameNamed": "Byt namn på {{name}}",
|
||||
"deleteNamed": "Ta bort {{name}}",
|
||||
"newChatTitle": "Ny chatt",
|
||||
"renameHint": "Klicka för att byta namn på denna chatt",
|
||||
"hideHidden": "Dölj dolda"
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "Ny chatt",
|
||||
"rename": "Byt namn",
|
||||
"deleteQ": "Ta bort ”{{title}}”?",
|
||||
"duplicateAgent": "Det finns redan en agent som heter ”{{name}}” på denna dashboard",
|
||||
"composerPlaceholder": "Vad vill du jobba på nu?",
|
||||
"turnFinished": "Turen avslutad · inget avslutande meddelande",
|
||||
"job": {
|
||||
"title": "Bakgrundsjobb",
|
||||
"runsIn": "Körs i",
|
||||
"command": "Kommando — kör exakt detta",
|
||||
"prompt": "Prompt — beskriv det, agenten löser det",
|
||||
"watch": "Bevaka något",
|
||||
"watchHint": "I löptext. Agenten frågar innan den aktiveras om något är oklart.",
|
||||
"handBack": "Avsluta och lämna tillbaka till agenten, aktivera sedan igen",
|
||||
"keepGoing": "Fortsätt — agera varje gång och kör vidare",
|
||||
"stopAfter": "Stoppa efter",
|
||||
"start": "Starta",
|
||||
"watchExample": "bevaka våra Gitea-förvar; vid varje ny push, hämta den och push:a till GitHub-spegeln",
|
||||
"startJob": "Starta ett bakgrundsjobb eller en bevakare"
|
||||
},
|
||||
"tasks": {
|
||||
"restart": "Starta om",
|
||||
"pinned": "Fäst",
|
||||
"clearFinished": "Rensa alla avslutade",
|
||||
"clearFinishedTasks": "Rensa alla avslutade uppgifter",
|
||||
"stopJob": "Stoppa detta jobb",
|
||||
"dismiss": "Avfärda",
|
||||
"rerun": "Stoppa det och kör samma kommando igen",
|
||||
"collapse": "Fäll ihop",
|
||||
"collapseDetail": "Fäll ihop uppgiftsdetaljer",
|
||||
"stopNamed": "Stoppa {{label}}",
|
||||
"dismissNamed": "Avfärda {{label}}",
|
||||
"status": "Uppgift {{status}}."
|
||||
},
|
||||
"usage": {
|
||||
"noSignIn": "Ingen sparad inloggning — kör /login för att använda den här",
|
||||
"updating": "Uppdaterar…",
|
||||
"unreadable": "Kunde inte läsa användning på den här maskinen.",
|
||||
"loading": "Läser in…",
|
||||
"limits": "Gränser",
|
||||
"byDay": "Tokens per dag",
|
||||
"byModel": "Tokens per modell",
|
||||
"title": "Användning och gränser",
|
||||
"modelBreakdown": "In {{input}} · ut {{output}} · cacheläsning {{cacheRead}} · cacheskrivning {{cacheWrite}}",
|
||||
"switchTo": "Byt till {{account}}",
|
||||
"switchToOrg": "Byt till {{account}} ({{org}})",
|
||||
"signInExpired": "{{account}} — inloggningen har gått ut, kör /login"
|
||||
},
|
||||
"attach": {
|
||||
"image": "Bild",
|
||||
"textFile": "Textfil",
|
||||
"webpageUrl": "Webbsidans URL",
|
||||
"attach": "Bifoga",
|
||||
"readFailed": "Det gick inte att läsa {{name}}: {{reason}}",
|
||||
"unknownError": "okänt fel",
|
||||
"webpageTitle": "Bifoga webbsida",
|
||||
"webpageHint": "Ange en URL att skrapa och bifoga som kontext.",
|
||||
"scrapeFailed": "Det gick inte att skrapa webbsidan",
|
||||
"uploadFailed": "Det gick inte att ladda upp bilden",
|
||||
"dropImages": "Släpp bilder för att bifoga"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Logga in på Claude",
|
||||
"done": "Inloggad. Nya turer använder dessa inloggningsuppgifter — ingen omstart behövs.",
|
||||
"asking": "Frågar CLI:n efter en inloggningslänk…",
|
||||
"openHint": "Öppna den här, godkänn, och klistra tillbaka koden du får.",
|
||||
"authorise": "Godkänn på claude.com",
|
||||
"copyLink": "Kopiera länken, för att öppna den i en annan webbläsare eller på din telefon",
|
||||
"pasteCode": "klistra in koden"
|
||||
},
|
||||
"message": {
|
||||
"copyCode": "Kopiera koden",
|
||||
"copyMarkdown": "Kopiera som ett markdown-block",
|
||||
"showMore": "Visa mer ({{count}} rader till)",
|
||||
"subagent": "Underagent",
|
||||
"outputError": "Utdata — fel",
|
||||
"contextCleared": "Kontexten rensad — inget ovanför detta finns kvar i minnet",
|
||||
"interrupted": "Avbruten av användaren",
|
||||
"agentRestarted": "Agenten omstartad — turen avbröts",
|
||||
"retry": "Försök igen",
|
||||
"planUnchanged": "Planläge oförändrat",
|
||||
"approve": "Godkänn",
|
||||
"frontmatter": "Frontmatter",
|
||||
"systemPrompt": "Systemprompt",
|
||||
"reviseHint": "Vad ska ändras? Agenten omarbetar planen utifrån detta.",
|
||||
"waiting": "Väntar på ett svar",
|
||||
"narrationFailed": "Uppläsningen genererades men kunde inte spelas upp.",
|
||||
"readAloudFailed": "Det gick inte att läsa upp detta meddelande: {{reason}}",
|
||||
"compacted": "Kontexten komprimerad",
|
||||
"compactedAuto": "Kontexten komprimerad automatiskt",
|
||||
"answerSent": "Svar skickat — väntar på agenten",
|
||||
"other": "Annat..."
|
||||
},
|
||||
"composer": {
|
||||
"stop": "Stoppa",
|
||||
"sendIntoTurn": "Skicka dessa till turen som redan körs",
|
||||
"removeFromQueue": "Ta bort från kön",
|
||||
"send": "Skicka",
|
||||
"startHint": "Skicka ett meddelande för att börja",
|
||||
"jumpToLatest": "Hoppa till senaste meddelandet",
|
||||
"noAudio": "Inget ljud spelades in"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "Den här panelens agent finns inte längre.",
|
||||
"forget": "Glöm den",
|
||||
"name": "Namnge denna agent",
|
||||
"peers": "Motparter: {{names}}"
|
||||
},
|
||||
"copy": "Kopiera"
|
||||
},
|
||||
"files": {
|
||||
"folderGone": "Den mappen är borta — tillbaka till starten",
|
||||
"searchFailed": "Sökningen misslyckades",
|
||||
"createFolderFailed": "Det gick inte att skapa mappen",
|
||||
"createFileFailed": "Det gick inte att skapa filen",
|
||||
"uploadFailed": "Det gick inte att ladda upp filerna",
|
||||
"renameFailed": "Det gick inte att byta namn",
|
||||
"deleteFailed": "Det gick inte att ta bort",
|
||||
"deleteSomeFailed": "Det gick inte att ta bort vissa objekt",
|
||||
"downloadFailed": "Det gick inte att ladda ner",
|
||||
"pathCopied": "Sökvägen kopierad",
|
||||
"generatingSpeech": "Genererar talljud…",
|
||||
"speechFailed": "Det gick inte att generera talljud",
|
||||
"extracting": "Extraherar arkiv…",
|
||||
"extractFailed": "Det gick inte att extrahera arkivet",
|
||||
"cloned": "Förvaret klonat",
|
||||
"cloneFailed": "Det gick inte att klona förvaret",
|
||||
"cloning": "Klonar…",
|
||||
"duplicateFailed": "Det gick inte att duplicera",
|
||||
"compressing": "Komprimerar…",
|
||||
"compressFailed": "Det gick inte att komprimera",
|
||||
"pasteFailed": "Det gick inte att klistra in",
|
||||
"pasteNeedsSecure": "Att klistra in från urklipp kräver en säker kontext (https). Dra in filen i stället.",
|
||||
"copiedItems": {
|
||||
"one": "Kopierade {{count}} objekt",
|
||||
"other": "Kopierade {{count}} objekt"
|
||||
},
|
||||
"cutItems": {
|
||||
"one": "Klippte ut {{count}} objekt",
|
||||
"other": "Klippte ut {{count}} objekt"
|
||||
},
|
||||
"pastedItems": {
|
||||
"one": "Klistrade in {{count}} objekt",
|
||||
"other": "Klistrade in {{count}} objekt"
|
||||
},
|
||||
"uploadedFiles": {
|
||||
"one": "Laddade upp {{count}} fil",
|
||||
"other": "Laddade upp {{count}} filer"
|
||||
},
|
||||
"deleteItemsQ": {
|
||||
"one": "Ta bort {{count}} objekt?",
|
||||
"other": "Ta bort {{count}} objekt?"
|
||||
},
|
||||
"deletedItems": {
|
||||
"one": "Tog bort {{count}} objekt",
|
||||
"other": "Tog bort {{count}} objekt"
|
||||
},
|
||||
"createdFolder": "Skapade mappen ”{{name}}”",
|
||||
"created": "Skapade ”{{name}}”",
|
||||
"renamedTo": "Bytte namn till ”{{name}}”",
|
||||
"deleteQ": "Ta bort ”{{name}}”?",
|
||||
"deleted": "Tog bort ”{{name}}”",
|
||||
"extractedTo": "Extraherad till ”{{name}}”",
|
||||
"duplicated": "Duplicerade ”{{name}}”",
|
||||
"pastedInto": "Klistrade in i ”{{name}}”",
|
||||
"unpinned": "Lossade ”{{name}}”",
|
||||
"pinned": "Fäste ”{{name}}”",
|
||||
"menu": {
|
||||
"copyPath": "Kopiera sökväg",
|
||||
"chatAboutThis": "Chatta om detta",
|
||||
"paste": "Klistra in",
|
||||
"newFile": "Ny fil",
|
||||
"newFolder": "Ny mapp",
|
||||
"uploadFiles": "Ladda upp filer",
|
||||
"uploadFolder": "Ladda upp mapp",
|
||||
"cloneRepository": "Klona förvar",
|
||||
"hideHiddenFiles": "Dölj dolda filer",
|
||||
"showHiddenFiles": "Visa dolda filer",
|
||||
"createDashboardHere": "Skapa dashboard här",
|
||||
"downloadVideo": "Ladda ner video",
|
||||
"dictate": "Diktera",
|
||||
"dropToUpload": "Släpp filer för att ladda upp",
|
||||
"openFolder": "Öppna mapp",
|
||||
"showLocation": "Visa plats",
|
||||
"download": "Ladda ner",
|
||||
"noResults": "Inga resultat hittades",
|
||||
"open": "Öppna",
|
||||
"openInEditor": "Öppna i editorn",
|
||||
"openInEditorTooLarge": "Öppna i editorn (för stor)",
|
||||
"pasteIntoFolder": "Klistra in i mapp",
|
||||
"readAloud": "Läs upp",
|
||||
"extract": "Extrahera",
|
||||
"runTask": "Kör uppgift",
|
||||
"runAgent": "Kör agent",
|
||||
"cut": "Klipp ut",
|
||||
"copy": "Kopiera",
|
||||
"duplicate": "Duplicera",
|
||||
"rename": "Byt namn",
|
||||
"compress": "Komprimera",
|
||||
"compressSelection": "Komprimera markering",
|
||||
"unpin": "Lossa",
|
||||
"pin": "Fäst",
|
||||
"chat": "Chatta...",
|
||||
"delete": "Ta bort",
|
||||
"gitClone": "Git-klon",
|
||||
"searchFiles": "Sök filer...",
|
||||
"clearSelection": "Rensa markering",
|
||||
"selected": {
|
||||
"one": "vald",
|
||||
"other": "valda"
|
||||
},
|
||||
"refresh": "Uppdatera"
|
||||
},
|
||||
"grid": {
|
||||
"empty": "Denna mapp är tom",
|
||||
"sort": "Sortera:",
|
||||
"folderType": "Mapp",
|
||||
"name": "Namn",
|
||||
"type": "Typ",
|
||||
"size": "Storlek",
|
||||
"date": "Datum"
|
||||
}
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stoppa",
|
||||
"taskFailed": "Uppgiften misslyckades",
|
||||
"taskComplete": "Uppgiften klar",
|
||||
"noSubtitles": "Inga undertexter",
|
||||
"none": "Ingen",
|
||||
"noVideos": "Inga videor hittades i denna mapp.",
|
||||
"differentLayout": "Annan layout — kör dessa separat:",
|
||||
"skipped": "Hoppade över (konvertera separat):",
|
||||
"analyzing": "Analyserar filer…",
|
||||
"close": "Stäng",
|
||||
"runningOn": "Körs på",
|
||||
"queue": "Kö",
|
||||
"concurrency": "Samtidighet",
|
||||
"model": "Modell",
|
||||
"steps": "Steg",
|
||||
"stopPipeline": "Stoppa pipeline",
|
||||
"pipelineFailed": "Pipeline misslyckades",
|
||||
"pipelineComplete": "Pipeline klar",
|
||||
"viewInJobs": "Visa i Jobb",
|
||||
"copyOutput": "Kopiera utdata",
|
||||
"track": "Spår {{n}}",
|
||||
"runFromStep": "Kör från steg {{n}}"
|
||||
},
|
||||
"newEntry": {
|
||||
"nameReserved": "Det namnet är reserverat",
|
||||
"nameSlash": "Namnet får inte innehålla \"/\"",
|
||||
"nameExists": "\"{{name}}\" finns redan här",
|
||||
"urlSpaces": "En URL kan inte innehålla mellanslag",
|
||||
"cancel": "Avbryt",
|
||||
"file": {
|
||||
"title": "Ny fil",
|
||||
"description": "Inkludera filändelsen — det är vad editorn använder för att välja språk. Den öppnas när den skapats.",
|
||||
"label": "Filnamn",
|
||||
"placeholder": "notes.txt",
|
||||
"submit": "Skapa och öppna"
|
||||
},
|
||||
"folder": {
|
||||
"title": "Ny mapp",
|
||||
"description": "Skapas i mappen du tittar på.",
|
||||
"label": "Mappnamn",
|
||||
"placeholder": "documents",
|
||||
"submit": "Skapa mapp"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Klona förvar",
|
||||
"description": "Klonas till mappen du tittar på, under sitt eget namn.",
|
||||
"label": "Förvarets URL",
|
||||
"placeholder": "https://github.com/owner/repo.git",
|
||||
"submit": "Klona"
|
||||
}
|
||||
},
|
||||
"videoDownload": {
|
||||
"title": "Ladda ner video",
|
||||
"descriptionInput": "Klistra in en video- eller spellista-URL — den hämtar detaljer innan nedladdning.",
|
||||
"descriptionPreview": "Granska och ladda ner.",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=… eller …/playlist?list=…",
|
||||
"audioOnly": "Extrahera endast ljud (mp3)",
|
||||
"fetch": "Hämta",
|
||||
"back": "Tillbaka",
|
||||
"audioOnlyLabel": "Endast ljud",
|
||||
"video": "Video",
|
||||
"itemCount": {
|
||||
"one": "{{count}} objekt",
|
||||
"other": "{{count}} objekt"
|
||||
},
|
||||
"subfolderPlaceholder": "Undermapp (valfritt) — lämna tomt för denna mapp",
|
||||
"downloadAll": "Ladda ner alla",
|
||||
"couldNotFetch": "Det gick inte att hämta",
|
||||
"downloading": "Laddar ner…",
|
||||
"saving": "Sparar…",
|
||||
"saved": "Sparad",
|
||||
"retry": "Försök igen",
|
||||
"download": "Ladda ner",
|
||||
"couldNotFetchInfo": "Det gick inte att hämta information",
|
||||
"timedOut": "Tidsgränsen överskreds",
|
||||
"downloadFailed": "Nedladdningen misslyckades",
|
||||
"couldNotStart": "Det gick inte att starta nedladdningen"
|
||||
},
|
||||
"fileBrowserWidget": {
|
||||
"title": "Filhanterare",
|
||||
"search": "Sök...",
|
||||
"browse": "Bläddra",
|
||||
"recent": "Senaste",
|
||||
"pinned": "Fäst",
|
||||
"noResults": "Inga resultat",
|
||||
"emptyDirectory": "Tom katalog",
|
||||
"noPinnedFiles": "Inga fästa filer",
|
||||
"noRecentFiles": "Inga senaste filer",
|
||||
"uploading": "Laddar upp...",
|
||||
"empty": "Denna mapp är tom",
|
||||
"sort": "Sortera:"
|
||||
},
|
||||
"agentRunner": {
|
||||
"agentRunning": "Agenten körs",
|
||||
"keepsGoing": "Den fortsätter oavsett om den här är öppen. Följ den i chatten — senaste sessionen överst.",
|
||||
"openChat": "Öppna chatt",
|
||||
"target": "Mål",
|
||||
"starting": "Startar…",
|
||||
"runAgent": "Kör agent",
|
||||
"startFailed": "Det gick inte att starta agenten"
|
||||
},
|
||||
"dictate": {
|
||||
"title": "Diktera",
|
||||
"description": "Spelar in ljud. Tryck Enter eller klicka Stoppa för att spara.",
|
||||
"recording": "Spelar in...",
|
||||
"startingStatus": "Startar...",
|
||||
"stopSave": "Stoppa och spara",
|
||||
"noAudioCaptured": "Inget ljud spelades in",
|
||||
"micAccessFailed": "Det gick inte att komma åt mikrofonen",
|
||||
"recordingFailed": "Inspelningen misslyckades"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user