Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0376ec39 | ||
|
|
66234cd3e0 | ||
|
|
ad8ed88519 | ||
|
|
7ce5af88aa | ||
|
|
6bfc1d38f7 | ||
|
|
c10dae3e6e | ||
|
|
41fffbebe9 | ||
|
|
c3c62828e3 | ||
|
|
926034750f | ||
|
|
6d8709c05a | ||
|
|
2765e241d3 | ||
|
|
36e18f935d | ||
|
|
0ddcdcbdbf | ||
|
|
99a78f3c7f | ||
|
|
845699e697 | ||
|
|
fe000541b7 | ||
|
|
dfbf9be61c | ||
|
|
be29ae7aa7 | ||
|
|
e3e790fd2b | ||
|
|
2b0b671895 | ||
|
|
2ec3f11cdd | ||
|
|
5ce1a2928e | ||
|
|
339d31f05c | ||
|
|
a444280cb4 | ||
|
|
c0788d15c0 | ||
|
|
7704867e05 | ||
|
|
04a4ab33dd | ||
|
|
b640ec486d | ||
|
|
0bd8c6c16a | ||
|
|
6ac9187c52 | ||
|
|
dcd11a3b17 | ||
|
|
3dd37d053b | ||
|
|
64894ad96e | ||
|
|
0a8fe1c2c8 | ||
|
|
2137d06498 | ||
|
|
ca7d3c657b | ||
|
|
b22e9d86c0 | ||
|
|
2095b6a717 | ||
|
|
4ea25d76c8 | ||
|
|
b94a5e90b8 | ||
|
|
510fa3f524 | ||
|
|
b5d9f591ee | ||
|
|
e6f1388aa6 | ||
|
|
dee0b89bd7 | ||
|
|
aa35428dde | ||
|
|
854f957058 | ||
|
|
edeb434233 | ||
|
|
8b4a5b3b0b | ||
|
|
730aeab6c5 | ||
|
|
2651c45733 | ||
|
|
91bc4ac74f | ||
|
|
a51a969cd0 | ||
|
|
5c3ffa7a06 | ||
|
|
1155449e4b | ||
|
|
a2ddf258b6 | ||
|
|
5c802b07b5 | ||
|
|
383df51a99 | ||
|
|
3551a3af71 | ||
|
|
038251f9c9 | ||
|
|
4f590f8879 | ||
|
|
c04fa15eb5 | ||
|
|
2aef23c8ba | ||
|
|
eefcb06e35 | ||
|
|
8ac3ca7fe5 | ||
|
|
03e7ca39c3 | ||
|
|
3254925506 | ||
|
|
a3fd3ade01 | ||
|
|
3837af657d | ||
|
|
749c80c877 | ||
|
|
af918251d3 | ||
|
|
ae27478a27 | ||
|
|
0bfad0e592 | ||
|
|
aad382f4be | ||
|
|
fc8ce04f2b | ||
|
|
cb51f35cad | ||
|
|
a6e7f64ef2 | ||
|
|
f065e2c0f8 | ||
|
|
1c47d0bba7 | ||
|
|
2bd8701b15 | ||
|
|
d08ae41f1f | ||
|
|
a356656a60 | ||
|
|
1c7619e184 | ||
|
|
3f1ba38f19 | ||
|
|
577ebde902 | ||
|
|
59f600d34d | ||
|
|
cbedfed2ba | ||
|
|
f9a3699603 | ||
|
|
75affec091 | ||
|
|
de9d229cfa | ||
|
|
cabc4825ef | ||
|
|
87ff6ed326 | ||
|
|
6d6e8a841e | ||
|
|
b7112f6e5d | ||
|
|
ee9106ef54 | ||
|
|
e4f1b75f9b | ||
|
|
4a206086af | ||
|
|
29d998b6c3 | ||
|
|
228f2f4638 |
@@ -113,6 +113,19 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
||||
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
||||
first. The source build also needs `build-essential` and `python3`.
|
||||
|
||||
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
|
||||
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
|
||||
watched. Two places carry that number and they move together: `BUN_SERIES` in
|
||||
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
|
||||
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
|
||||
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
|
||||
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
|
||||
|
||||
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
|
||||
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
|
||||
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
|
||||
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
|
||||
|
||||
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
||||
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
||||
- **Backend**: Hono
|
||||
@@ -194,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). 27 entries as of 2026-08-13.
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
@@ -224,9 +237,20 @@ bunx tsgo # typecheck (not tsc)
|
||||
bun test # tests
|
||||
bun format # prettier over every dirty file — see the note below before running it
|
||||
bun db:push # apply the schema to Postgres
|
||||
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
```
|
||||
|
||||
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
|
||||
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
|
||||
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
|
||||
place to drift, and it drifts in the same edit that misses the doc.
|
||||
|
||||
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
|
||||
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
|
||||
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
|
||||
written had been wrong for over a week.
|
||||
|
||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||
@@ -453,6 +477,13 @@ explaining why it was safe.
|
||||
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
|
||||
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
|
||||
remember, and the credential-resolution work that has to land first
|
||||
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
|
||||
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
|
||||
because otherwise "add a role" and "become the owner" are the same feature
|
||||
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
|
||||
web UI to more than one language. What exists and what it cannot do yet, why the server should return
|
||||
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
|
||||
naming convention, and the four phases. Read before adding a user-facing string
|
||||
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
|
||||
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
|
||||
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# SendOff — mobile's answer
|
||||
|
||||
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
|
||||
|
||||
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
|
||||
|
||||
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
|
||||
Good catch flagging the case explicitly; the app did build that path itself and would have opened
|
||||
an empty sibling folder.
|
||||
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
|
||||
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
|
||||
being siblings called "devices" would have bitten us first.
|
||||
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
|
||||
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
|
||||
carries ids — one address format, no translation layer.
|
||||
|
||||
One request for `/targets` when the send screen gets built here: include each person's display name
|
||||
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
|
||||
so the picker can honestly say where the files will appear on the other side.
|
||||
|
||||
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
|
||||
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
|
||||
iOS now so the corrected paths are on the device.
|
||||
@@ -0,0 +1,40 @@
|
||||
# SendOff — `/targets` now serves the folder name
|
||||
|
||||
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
|
||||
|
||||
Request granted, and you were right to ask for it served rather than derived. Two fields:
|
||||
|
||||
```
|
||||
GET /api/sendoff/targets
|
||||
→ {
|
||||
devices: [{ id, name, platform, lastSeenAt }],
|
||||
people: [{ id, email, name, displayName }], ← displayName is new
|
||||
myFolder: "Pastilhas" ← this is new
|
||||
}
|
||||
```
|
||||
|
||||
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
|
||||
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
|
||||
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
|
||||
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
|
||||
needs to change, it changes in one place and both sides move together.
|
||||
|
||||
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
|
||||
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
|
||||
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
|
||||
|
||||
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
|
||||
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
|
||||
that showed `displayName` as the destination folder would be naming the wrong directory.
|
||||
|
||||
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
|
||||
what you see is what appears on disk.
|
||||
|
||||
## Also since the last note
|
||||
|
||||
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
|
||||
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
|
||||
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
|
||||
|
||||
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
|
||||
asked for, and it is there now rather than when you get to it.
|
||||
@@ -0,0 +1,141 @@
|
||||
# SendOff — the backend is in. What mobile needs to change.
|
||||
|
||||
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
|
||||
draft; this file is what actually shipped, and it differs in two places.
|
||||
|
||||
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
|
||||
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
|
||||
exercised against the live server before this note was written. The web send form is **not** built yet;
|
||||
that is on this side and does not block anything below.
|
||||
|
||||
## The convention on disk
|
||||
|
||||
```
|
||||
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
|
||||
<home>/Documents/SendOff/<sender>/ — files another account sent you
|
||||
```
|
||||
|
||||
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
|
||||
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
|
||||
|
||||
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
|
||||
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
|
||||
relative to the right one.
|
||||
|
||||
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
|
||||
sender's name alone.
|
||||
|
||||
## Two changes from the draft spec
|
||||
|
||||
**1. Device registration moved under the feature.**
|
||||
|
||||
```
|
||||
was: POST /api/devices/register { id, name, platform }
|
||||
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
|
||||
```
|
||||
|
||||
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
|
||||
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
|
||||
`DELETE /api/sendoff/devices/:id`.
|
||||
|
||||
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
|
||||
top-level noun collides with the **push token** registry that already exists at
|
||||
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
|
||||
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
|
||||
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
|
||||
an afternoon.
|
||||
|
||||
**2. `toUser` is an account id, not a username.**
|
||||
|
||||
```
|
||||
was: meta { "toUser": "<username>" }
|
||||
now: form field toUser=<account id, integer>
|
||||
```
|
||||
|
||||
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
|
||||
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
|
||||
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
|
||||
either; it is a small change here and a smaller one than guessing.
|
||||
|
||||
## The send endpoint
|
||||
|
||||
```
|
||||
POST /api/sendoff/send
|
||||
multipart/form-data:
|
||||
file — repeatable, any number of parts
|
||||
machine — a name of your own } exactly one of these
|
||||
toUser — an account id }
|
||||
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
|
||||
```
|
||||
|
||||
Notes that matter for a client:
|
||||
|
||||
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
|
||||
A file over the limit is a `400` naming the file.
|
||||
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
|
||||
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
|
||||
they were about to destroy something. Show the returned name, not the local one.
|
||||
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
|
||||
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
|
||||
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
|
||||
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
|
||||
one refused you. Do not present the two differently, because they are not distinguishable.
|
||||
|
||||
## Who may send to whom
|
||||
|
||||
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
|
||||
handshake: I add you, and only then do you see me as a target.
|
||||
|
||||
```
|
||||
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
|
||||
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
|
||||
POST /api/sendoff/senders { userId }
|
||||
DELETE /api/sendoff/senders/:userId
|
||||
```
|
||||
|
||||
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
|
||||
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
|
||||
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
|
||||
their say-so, so nobody lands files in the owner's tree uninvited.
|
||||
|
||||
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
|
||||
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
|
||||
|
||||
## The push
|
||||
|
||||
```
|
||||
code: officer/core:sendoff.landed
|
||||
id: the folder name — a username, or one of your own machine names
|
||||
n: how many files landed
|
||||
```
|
||||
|
||||
The catalogue entry renders `{n} file(s) from {id}` and its route is:
|
||||
|
||||
```
|
||||
officer://files?path=Documents/SendOff/{id}
|
||||
```
|
||||
|
||||
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
|
||||
from the server — which is the whole point of that design. What the app does need is to keep parsing
|
||||
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
|
||||
|
||||
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
|
||||
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
|
||||
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
|
||||
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
|
||||
conversation rather than a patch.
|
||||
|
||||
## What is NOT built
|
||||
|
||||
- **The web send form.** This side, in progress. It changes no API.
|
||||
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
|
||||
blocks it — `POST /sendoff/send` takes multipart from any client.
|
||||
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
|
||||
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
|
||||
month.
|
||||
|
||||
## If something here is wrong for mobile
|
||||
|
||||
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
|
||||
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
|
||||
ends up carrying a translation layer nobody meant to write.
|
||||
@@ -20,39 +20,39 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**87 open, 25 closed**, across 29 themes.
|
||||
**90 open, 25 closed**, across 29 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 16 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 3 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 16 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
|
||||
## Records — no items, kept for the reasoning
|
||||
|
||||
@@ -63,13 +63,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
|
||||
- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
|
||||
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
|
||||
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
|
||||
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
|
||||
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
|
||||
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
|
||||
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
|
||||
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
|
||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
||||
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
|
||||
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
|
||||
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"hooks": "*",
|
||||
"i18n": "*",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.4",
|
||||
@@ -271,6 +272,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"hooks": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
"state": "workspace:*",
|
||||
"widgets": "workspace:*",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# 2026.08.22.02 — deploy report
|
||||
|
||||
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
|
||||
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
|
||||
|
||||
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
|
||||
`package.json` `2026.08.22.02`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
|
||||
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
|
||||
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
|
||||
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
|
||||
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
|
||||
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
|
||||
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
|
||||
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
|
||||
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
|
||||
|
||||
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
|
||||
|
||||
## Per host
|
||||
|
||||
### `officer-dev` — SKIPPED, and current
|
||||
|
||||
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
|
||||
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
|
||||
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
|
||||
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
|
||||
|
||||
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
|
||||
the tree, the schema and the running process are all already on this release.
|
||||
|
||||
### `alpha` — done
|
||||
|
||||
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
|
||||
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
|
||||
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
|
||||
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
|
||||
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
|
||||
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
|
||||
|
||||
### `fofos` — done
|
||||
|
||||
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
|
||||
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
|
||||
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
|
||||
all 8 processes `online`. `theme_favorites` present.
|
||||
|
||||
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
|
||||
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
|
||||
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
|
||||
use `POSTGRES_URL`.
|
||||
|
||||
### `macbookpro` — done, awake
|
||||
|
||||
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
|
||||
like the rest. Pulled straight to `68d60e40`.
|
||||
|
||||
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
|
||||
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
|
||||
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
|
||||
|
||||
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
|
||||
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
|
||||
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
|
||||
|
||||
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
|
||||
unrelated to this release, but it appears in the raw output above every command.
|
||||
|
||||
### `edge-pertento-bridge` — done
|
||||
|
||||
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
|
||||
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
|
||||
processes `online`. `theme_favorites` present.
|
||||
|
||||
### `production-pertento-bridge` — done
|
||||
|
||||
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
|
||||
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
|
||||
`theme_favorites` present.
|
||||
|
||||
### `deadpool-rezio-bridge` — done
|
||||
|
||||
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
|
||||
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
|
||||
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
|
||||
|
||||
## What did not happen
|
||||
|
||||
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
|
||||
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
|
||||
what this agent executes.
|
||||
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
|
||||
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
|
||||
here rather than reporting only `officer` is the plan's explicit instruction.
|
||||
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
|
||||
Everything else was verified exactly as written.
|
||||
|
||||
## Claim record
|
||||
|
||||
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
|
||||
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
|
||||
cannot cause a second rollout.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 2026.08.23.01
|
||||
|
||||
The commander creation flow, rebuilt end to end — named, given a directory, and started by a button that
|
||||
makes it interview you rather than waiting for a prompt somebody else wrote. Plus the delete path that
|
||||
actually removes one, `/minesweeper`, and `docs:check` guarding the documentation's checkable claims.
|
||||
|
||||
**SCHEMA CHANGE.** `bun db:push` is a step. `command_hq_spawned` is new and `command_hq_sessions` gains
|
||||
`name`. Without the push, Command HQ fails on a missing column the first time it lists a commander.
|
||||
|
||||
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.22.02..HEAD`. `package.json`
|
||||
moved only to add `engines.bun` and two scripts. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **A commander is created, not configured.** Name it, choose its working directory, press Start. It reads
|
||||
the directory, works out whether the job is already written down there, and either reads its
|
||||
understanding back for confirmation or asks what it should be watching. The name is the brief — it is the
|
||||
only thing the owner says before anything else, and it now reaches the model instead of only titling the
|
||||
transcript.
|
||||
- **`command_hq_sessions.name`** — the platform owns the name. It was only ever in the transcript's summary
|
||||
record, which the harness cannot write until the first turn ends, so the list showed the opening brief
|
||||
while the header showed the real name.
|
||||
- **Deleting a commander removes it.** Four defects, each of which left something behind: the session kill
|
||||
ran in the wrong process and always failed; the transcript was deleted while the harness was still
|
||||
writing it, so it came straight back as an untitled chat; `chat_session_events` is keyed on officer's
|
||||
session key while everything else uses the transcript id, so its rows were never matched; and the key was
|
||||
resolved from an in-memory map that is empty for anything older than the process. The event rows carry
|
||||
both ids, so they are asked when the maps cannot answer.
|
||||
- **`GET /chat/live?kind=`** — `agent` (the default), `commander`, or `all`, with `counts` always
|
||||
describing the whole set. Defaulting to agents means a client that forgets cannot be handed a commander;
|
||||
the web list asks for `all` explicitly.
|
||||
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
|
||||
error — so answering a question during the reconnect window after a restart vanished and the card span
|
||||
forever. Question answers and plan decisions now report it.
|
||||
- **`/minesweeper`** — a wine session streamed to the browser over RFB. The session is not running on
|
||||
`officer-dev`, so the route renders its "not running" state with a Start button. That is expected.
|
||||
- **Usage limits are read live.** `limitsCache` was keyed on nothing, so switching Anthropic account showed
|
||||
the previous account's percentages under the new name. The cache remains for the failure path only, keyed
|
||||
per account.
|
||||
- **`bun docs:check`** compares documented claims against values derived at run time — the permission
|
||||
count, the PM2 entry count, the generated platform skill. It found both claims it was written to check
|
||||
already wrong. **Required before tagging a release.**
|
||||
- **Empty transcripts no longer list.** A file holding only harness metadata and no turns is not a
|
||||
conversation; four had accumulated as "(untitled) · 0 msgs".
|
||||
- **Bun is held at the 1.3 series** — `engines.bun` and `BUN_SERIES` move together. Do not run
|
||||
`bun upgrade` on a host.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git pull --ff-only`
|
||||
2. **`bun db:push`** — new this release
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||
the report rather than being omitted.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
|
||||
`docs/servers.md`.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
This is the machine the rollout runs from, and restarting `officer` here ends the agent performing it.
|
||||
Name it as skipped in the report. It is already current.
|
||||
@@ -0,0 +1,134 @@
|
||||
# 2026.08.23.01 — deploy report
|
||||
|
||||
**4 of 6 target hosts succeeded.** `officer-dev` skipped by the plan (the commander runs there) and
|
||||
verified already current. Two hosts were **not reached at all**, both for access reasons that have nothing
|
||||
to do with this release: `edge-pertento-bridge` will not complete an SSH handshake, and
|
||||
`production-pertento-bridge` presented a **changed host key**, which is not something a deploy agent
|
||||
clears. Neither was pulled, pushed to, or restarted. No host failed a step; nothing came back `errored`.
|
||||
|
||||
Deployed 2026-08-23 from `officer-dev`. Tag `deploy/2026.08.23.01` → `dee0b89b`, matching `package.json`
|
||||
`2026.08.23.01`.
|
||||
|
||||
**Hosts landed on `e6f1388a`, one commit past the tag.** The plan says `git pull --ff-only`, which follows
|
||||
`master`, and `master` had moved on by one commit — `plan: localizing the web UI, and the scanner that
|
||||
sizes it`, three files: `CLAUDE.md`, `docs/localization.md`, `scripts/i18n/scan-hardcoded.ts`. Documentation
|
||||
and a standalone script, no runtime code, no schema and no dependency. Recorded here because "deployed the
|
||||
tag" would not be true of any host.
|
||||
|
||||
## Summary
|
||||
|
||||
| Host | HEAD before | HEAD after | Steps | `officer` restart | Schema |
|
||||
| ---------------------------- | ----------- | ---------- | -------- | ----------------- | ------- |
|
||||
| `officer-dev` | `e6f1388a` | — | skipped | not touched (237) | ✓ |
|
||||
| `alpha` | `68d60e40` | `e6f1388a` | all 4 | 66 → 67, online | ✓ |
|
||||
| `fofos` | `68d60e40` | `e6f1388a` | all 4 | 20 → 21, online | ✓ |
|
||||
| `macbookpro` | `8b4a5b3b` | `e6f1388a` | all 4 | 7 → 8, online | ✓ |
|
||||
| `edge-pertento-bridge` | unknown | — | **none** | not touched | unknown |
|
||||
| `production-pertento-bridge` | unknown | — | **none** | not touched | unknown |
|
||||
| `deadpool-rezio-bridge` | `68d60e40` | `e6f1388a` | all 4 | 5 → 6, online | ✓ |
|
||||
|
||||
Checkout paths came from each `officer` process's `pm_cwd`, not from a hard-coded list. "Schema ✓" is a
|
||||
live check, not an inference from `db:push` exiting zero: `command_hq_spawned` exists and
|
||||
`command_hq_sessions.name` exists, queried per host against that host's own database.
|
||||
|
||||
## Per host
|
||||
|
||||
### `officer-dev` — SKIPPED, and current
|
||||
|
||||
The commander session runs inside `officer-claude-code` here, so the whole host was skipped as the plan
|
||||
requires: no pull, no `db:push`, no `plugins:update`, no restart. It was already at `e6f1388a` and both
|
||||
schema objects are present, so it is running this release rather than lagging. `officer` shows 237 restarts,
|
||||
untouched by this deploy. Nothing is pending on it — the owner restarts it deliberately if they want to.
|
||||
|
||||
### `alpha` — done
|
||||
|
||||
Tree clean, tracked and untracked. Pulled `68d60e40` → `e6f1388a`. `bun db:push` applied changes;
|
||||
`backfill-session-models` reported 0 seeded. `bun run plugins:update` returned **12/12 updated in 48s**,
|
||||
exit 0, and restarted all twelve plugin sidecars itself — atelier, bitcoin-wallet, email, gitea, invoices,
|
||||
jellyfin, music, offscale, photos, soulseek, transmission, vault. Then `pm2 restart officer`: 66 → 67,
|
||||
`online`. Every other PM2 entry `online`.
|
||||
|
||||
### `fofos` — done
|
||||
|
||||
Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied changes. `plugins:update`: **1/1 in 8s**
|
||||
(gitea), which restarted `officer-gitea`. `pm2 restart officer`: 20 → 21, `online`. No trace of the
|
||||
`dubious ownership` / `EACCES` pair that broke its capabilities sync for three deploys — `capabilities:
|
||||
pulled` came back clean, as it has since the 2026-08-21 `chown`.
|
||||
|
||||
### `macbookpro` — done
|
||||
|
||||
Awake and reachable. Tree clean. Pulled `8b4a5b3b` → `e6f1388a` — two releases behind, so it picked up
|
||||
2026.08.22.01 and .02 as well. `db:push` applied changes (3 plugin schemas generated: atelier, kiosk,
|
||||
music). `plugins:update`: `no plugins installed — nothing to update`, exit 0. `pm2 restart officer` was run
|
||||
as a **separate** command per `docs/servers.md`: 7 → 8, `online`.
|
||||
|
||||
`PATH` was appended, never replaced — `export PATH="$PATH:/opt/homebrew/bin"` inside the interactive shell,
|
||||
so `bun` resolves and `pm2` stays where nvm put it. `.zshrc` still fails on a missing `starship` on every
|
||||
non-interactive login; noise, not a fault, and it has been there for at least three deploys.
|
||||
|
||||
### `deadpool-rezio-bridge` — done
|
||||
|
||||
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied
|
||||
changes. `plugins:update`: **2/2 in 8s** (email, offscale), restarting both sidecars. `pm2 restart officer`:
|
||||
5 → 6, `online`.
|
||||
|
||||
### `edge-pertento-bridge` — NOT REACHED
|
||||
|
||||
**Four attempts, ConnectTimeout up to 60s: `Connection timed out during banner exchange`.** The TCP
|
||||
connection succeeds — `nc -z 100.64.0.15 22` reports the port open and `ssh -v` logs `Connection
|
||||
established` — and then sshd never sends its version string. Tailscale shows the node `active; direct`, so
|
||||
the tunnel is up and this is not a tailnet problem.
|
||||
|
||||
That is a host-side sshd or load problem, not a credential one and not a rate-limit one. Nothing was run
|
||||
against it: it is still on whatever it was before this release, which I could not read, because reading it
|
||||
needs the same handshake. It also did not answer HTTPS on its tailnet address during the probe.
|
||||
|
||||
**This host needs a look before the next release**, and the check is one command from anywhere on the
|
||||
tailnet — `ssh edge-pertento-bridge hostname`. If it still hangs, the box wants a console.
|
||||
|
||||
### `production-pertento-bridge` — NOT REACHED, host key changed
|
||||
|
||||
```
|
||||
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
|
||||
The fingerprint for the ED25519 key sent by the remote host is
|
||||
SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww
|
||||
Offending ECDSA key in /home/pastilhas/.ssh/known_hosts:37
|
||||
```
|
||||
|
||||
**I did not clear the entry and did not connect.** Accepting a new host key is a trust decision, and a
|
||||
deploy agent that removes a `known_hosts` line to get its release out has removed the only warning the
|
||||
owner would ever get. `StrictHostKeyChecking` did exactly what it is for.
|
||||
|
||||
What is known, all read locally without touching the host:
|
||||
|
||||
- `known_hosts` holds three keys for this name: ED25519 `SHA256:dw7kG2MDftuLDT1CduEO+OVYuMVHsx02Cnwikj7GS/4`
|
||||
(line 33), RSA (line 36), ECDSA (line 37, the one ssh names as offending).
|
||||
- The host now offers ED25519 `SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww` — a different key from
|
||||
the ED25519 already trusted.
|
||||
- Tailscale shows it `active; direct`, port 22 accepts TCP, and it did not answer HTTPS on 100.64.0.12
|
||||
during the probe.
|
||||
|
||||
The benign explanation is a rebuild or a regenerated host key — this is `PPROD`, a bridge that has been
|
||||
reinstalled before. The other explanation is the one the warning is about. **Settle it out of band**: read
|
||||
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the machine's own console, compare it to
|
||||
`63S0GK…`, and only then drop the stale lines with
|
||||
`ssh-keygen -f ~/.ssh/known_hosts -R production-pertento-bridge`. Do not compare it over SSH; that is the
|
||||
channel in question.
|
||||
|
||||
Until then this host is a release behind — it was at `68d60e40` as of the 2026.08.22.02 report.
|
||||
|
||||
## What I did not do
|
||||
|
||||
- **Did not deploy `edge-pertento-bridge` or `production-pertento-bridge`**, and did not force either.
|
||||
Neither was pulled, neither had a process restarted, and neither has this release's schema.
|
||||
- **Did not touch `~/.ssh/known_hosts`.**
|
||||
- **Did not run `bun install`** — the plan forbids it and `bun.lock` is unchanged across
|
||||
`deploy/2026.08.22.02..HEAD`.
|
||||
- **Did not run `bun run docs:check`** — not the commander's job; it is run before the tag.
|
||||
- **Did not restart anything the plan did not name.** `officer` only, per host. The plugin sidecars that
|
||||
bounced were restarted by `plugins:update` itself, which is that script's own behaviour: 12 on `alpha`,
|
||||
1 on `fofos`, 2 on `deadpool-rezio-bridge`.
|
||||
- **Did not touch `officer-dev`** at all.
|
||||
|
||||
The local record `data/deploys/2026.08.23.01.done` was written before this report, as the protocol requires:
|
||||
the record is what prevents a second attempt, and pushing this file is paperwork that is allowed to fail.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 2026.08.23.02
|
||||
|
||||
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
|
||||
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
|
||||
|
||||
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
|
||||
push, `/sendoff` fails on a missing relation the first time it lists anything.
|
||||
|
||||
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
|
||||
moved only its version. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
|
||||
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
|
||||
browses — the owner's real login home, a member's managed home — because those are different directories
|
||||
and writing to the wrong one makes a delivery invisible rather than wrong.
|
||||
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
|
||||
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
|
||||
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
|
||||
accounts. The owner bypasses this as a SENDER only.
|
||||
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
|
||||
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
|
||||
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
|
||||
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
|
||||
the catalogue syncs.
|
||||
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
|
||||
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
|
||||
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
|
||||
days, and the last rollout spent four attempts and a minute apiece discovering that.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
|
||||
2. **`bun db:push`** — new this release
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||
the report rather than being omitted.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## Hosts to expect trouble from
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
|
||||
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
|
||||
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
|
||||
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026.08.23.02 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
|
||||
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
|
||||
|
||||
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
|
||||
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
|
||||
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
|
||||
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
|
||||
|
||||
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
|
||||
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
|
||||
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
|
||||
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
|
||||
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
|
||||
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
|
||||
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
|
||||
| `officer-dev` | — | — | — | — | — | SKIPPED |
|
||||
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
|
||||
|
||||
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
|
||||
`sendoff_senders` are new tables, so the diff was additive.
|
||||
|
||||
## What restarted, beyond what the plan asked for
|
||||
|
||||
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
|
||||
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
|
||||
not a fault, and it is the larger part of what moved:
|
||||
|
||||
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
|
||||
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
|
||||
- `fofos` — one (`officer-gitea`).
|
||||
- `production-pertento-bridge` — one (`officer-gitea`).
|
||||
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
|
||||
- `macbookpro` — none; no plugins are installed there.
|
||||
|
||||
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
|
||||
on any host was `errored`.
|
||||
|
||||
## Hosts the plan flagged
|
||||
|
||||
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
|
||||
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
|
||||
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
|
||||
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
|
||||
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
|
||||
steps, with `pm2` run separately through `$SHELL -ic`.
|
||||
|
||||
## Things worth knowing, none of them failures
|
||||
|
||||
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
|
||||
it, and it was left alone.
|
||||
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
|
||||
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
|
||||
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
|
||||
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
|
||||
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
|
||||
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
|
||||
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
|
||||
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
|
||||
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 2026.08.23.03
|
||||
|
||||
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
|
||||
changes and nothing reads the new table yet.
|
||||
|
||||
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
|
||||
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
|
||||
existing data.
|
||||
|
||||
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
|
||||
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
|
||||
`bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
|
||||
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
|
||||
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
|
||||
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
|
||||
`db:push` and is a later release.
|
||||
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
|
||||
step here. It never overwrites an existing row.
|
||||
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
|
||||
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
|
||||
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
|
||||
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
|
||||
(`en` → `en-GB`), with no database migration.
|
||||
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
|
||||
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
|
||||
create.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
|
||||
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
|
||||
exists and holds four rows on that host. `db:push` can fail and still exit 0.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||
the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## The check that matters this release
|
||||
|
||||
```sql
|
||||
select count(*) from roles; -- expect 4
|
||||
```
|
||||
|
||||
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
|
||||
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
|
||||
per host in the report.
|
||||
|
||||
No direct SQL is needed and none is authorised for this release.
|
||||
|
||||
## Hosts to expect trouble from
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The rollout runs from here. It already holds the release and the four rows, verified locally.
|
||||
@@ -0,0 +1,42 @@
|
||||
# 2026.08.23.03 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
|
||||
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
|
||||
attempted, nothing spent on it).
|
||||
|
||||
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
|
||||
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
|
||||
authorised and none was needed.
|
||||
|
||||
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
|
||||
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
|
||||
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
|
||||
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
|
||||
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
|
||||
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
|
||||
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
|
||||
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
|
||||
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
|
||||
|
||||
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
|
||||
`online`** — not just `officer`, every entry.
|
||||
|
||||
## Notes, and what was not done
|
||||
|
||||
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
|
||||
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
|
||||
restarting `officer` on it is the owner's deliberate last step.
|
||||
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
|
||||
never have collided with it, so it is not a reason to skip a host.
|
||||
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
|
||||
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
|
||||
before `officer` itself was touched.
|
||||
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
|
||||
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
|
||||
- **Nothing was retried and nothing was repaired.** No step failed on any host.
|
||||
@@ -57,6 +57,25 @@ so the walk exists — this is a second consumer of it, not a new class of work.
|
||||
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
|
||||
still null contributes to no directory (see above).
|
||||
|
||||
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
|
||||
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
|
||||
accepted because the web client's own type is already `'agents' | 'commanders'`.
|
||||
|
||||
The default is the point. Commanders run background work and the phone swipes between live chats one at a
|
||||
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
|
||||
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
|
||||
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
|
||||
built to show both, and they are the ones positioned to notice if the answer is wrong.
|
||||
|
||||
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
|
||||
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
|
||||
costing more round trips than the client-side one it replaced.
|
||||
|
||||
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
|
||||
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
|
||||
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
|
||||
about which of its rows is one.
|
||||
|
||||
---
|
||||
|
||||
## Landmines
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
# Localization
|
||||
|
||||
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
|
||||
|
||||
This is an execution plan. It is written to be handed to an agent who has not had the conversation
|
||||
that produced it, so it states the decisions and the reasons for them, not just the steps.
|
||||
|
||||
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
|
||||
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
|
||||
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
|
||||
would be a guard against progress.
|
||||
|
||||
---
|
||||
|
||||
## What this is
|
||||
|
||||
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
|
||||
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
|
||||
decision, deferred, and nothing here depends on it.
|
||||
|
||||
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
|
||||
the server does instead, which is the one decision that reaches outside this boundary.
|
||||
|
||||
---
|
||||
|
||||
## What exists today, and what it cannot do
|
||||
|
||||
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
|
||||
also about **5% deployed**, and that gap is the whole job.
|
||||
|
||||
| | |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
|
||||
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
|
||||
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
|
||||
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
|
||||
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
||||
|
||||
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
|
||||
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
|
||||
typecheck failure, which is worth more than it sounds when one agent is editing 400 files.
|
||||
|
||||
**What it cannot do yet**, each of which is a task below:
|
||||
|
||||
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
|
||||
runtime and unused.
|
||||
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
|
||||
the _browser's_ locale, not the user's chosen one, so a Portuguese UI shows American dates.
|
||||
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
|
||||
from `pt`.
|
||||
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
|
||||
`officerdev` and the 91 in `components` have no catalog of their own.
|
||||
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the
|
||||
user. No warning, no test.
|
||||
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes
|
||||
including Hebrew and Greek. Choosing any of the eighteen silently yields English.
|
||||
|
||||
### The size of the remaining work
|
||||
|
||||
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
|
||||
|
||||
| area | files | occurrences |
|
||||
| --------------------- | ------- | ----------- |
|
||||
| `officer-web` (shell) | 132 | 556 |
|
||||
| `officerdev` (panels) | 213 | 567 |
|
||||
| shared `components` | 91 | 47 |
|
||||
| **total** | **436** | **1170** |
|
||||
|
||||
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
||||
|
||||
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
|
||||
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. The web UI only — and the server returns codes, not prose
|
||||
|
||||
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
|
||||
owns every word a person reads and localizes it like any other string.
|
||||
|
||||
```jsonc
|
||||
// not this
|
||||
{ "error": "The Minesweeper session is not running" }
|
||||
// this
|
||||
{ "code": "MINESWEEPER_NOT_RUNNING" }
|
||||
```
|
||||
|
||||
This is better than translating the server, for reasons that have nothing to do with translation. A
|
||||
code maps to exactly one failure site, so it is greppable and a support conversation has something
|
||||
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
|
||||
|
||||
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
|
||||
and the catalog gets an `errors.*` namespace.
|
||||
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
|
||||
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
|
||||
a raw key.
|
||||
|
||||
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where a
|
||||
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
|
||||
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
|
||||
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
|
||||
the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
|
||||
|
||||
### D2. Regional locales, BCP 47, and every catalog is complete
|
||||
|
||||
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
|
||||
it is the owner's own language, so getting it wrong is felt immediately.
|
||||
|
||||
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
|
||||
every key the UI has, translated for that locale, or it does not ship.
|
||||
|
||||
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
|
||||
carried the shared bulk and a regional file carried only the differences. That is the conventional design
|
||||
and it was rejected for a product reason that outranks the engineering one:
|
||||
|
||||
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
|
||||
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
|
||||
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
|
||||
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
|
||||
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
|
||||
variant's words.
|
||||
|
||||
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
|
||||
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
|
||||
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
|
||||
by the test rather than by a fallback quietly papering over a gap.
|
||||
|
||||
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
|
||||
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
|
||||
base-versus-overlay merge order, and no question about which file a given string lives in.
|
||||
|
||||
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
|
||||
|
||||
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
|
||||
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
|
||||
over a database migration — a stale bare code should resolve, not fail.
|
||||
|
||||
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
|
||||
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
|
||||
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
|
||||
the other.
|
||||
|
||||
### D3. RTL — prepare, do not ship
|
||||
|
||||
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
|
||||
opened once:
|
||||
|
||||
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
|
||||
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
|
||||
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
|
||||
|
||||
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
|
||||
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
|
||||
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
|
||||
|
||||
### D4. Extend the existing package. Do not adopt a library
|
||||
|
||||
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
|
||||
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
|
||||
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
|
||||
|
||||
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
|
||||
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
|
||||
lines of glue, listed in "What it cannot do yet".
|
||||
|
||||
Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
|
||||
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
|
||||
|
||||
### D5. Keys, not English source text
|
||||
|
||||
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
|
||||
|
||||
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
|
||||
renders. They also mean **editing an English word invalidates every translation of it**, and this
|
||||
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
|
||||
trading away.
|
||||
|
||||
### D6. Catalogs live per workspace, merged at the bridge
|
||||
|
||||
Not one god file. Three:
|
||||
|
||||
```
|
||||
src/apps/officer-web/locales/ → namespace app.*
|
||||
src/workspaces/officerdev/src/locales/ → namespace panel.*
|
||||
src/workspaces/components/locales/ → namespace ui.*
|
||||
```
|
||||
|
||||
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
|
||||
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
|
||||
|
||||
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
|
||||
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
|
||||
on every parallel edit** — with several agents or several days of work, a single file is a queue.
|
||||
|
||||
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
|
||||
under it must not mention a feature.
|
||||
|
||||
### D7. Plurals and formatting go through `Intl`
|
||||
|
||||
A plural value becomes an object; a string stays a string:
|
||||
|
||||
```jsonc
|
||||
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
|
||||
```
|
||||
|
||||
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
|
||||
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
|
||||
must be an open record, never a two-field type.
|
||||
|
||||
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
|
||||
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
|
||||
locale and quietly contradict the rest of the UI.
|
||||
|
||||
### D8. A missing key falls back to English and is loud in dev
|
||||
|
||||
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
|
||||
missing too does it render the key — which at that point is a bug the parity test below should have
|
||||
caught.
|
||||
|
||||
In dev, a miss logs once per key with the key and the locale. In production it is silent.
|
||||
|
||||
---
|
||||
|
||||
## The key naming convention
|
||||
|
||||
Three segments, sometimes four. **Name the role, never the text.**
|
||||
|
||||
```
|
||||
<namespace>.<area>.<component>.<element>
|
||||
|
||||
app.settings.profile.changePassword.title
|
||||
panel.files.contextMenu.rename
|
||||
panel.chat.composer.placeholder
|
||||
ui.button.cancel
|
||||
app.errors.MINESWEEPER_NOT_RUNNING
|
||||
```
|
||||
|
||||
- `namespace` — `app` / `panel` / `ui`, per D6.
|
||||
- `area` — the route or panel it belongs to; matches the directory.
|
||||
- `component` — the file, lowercased.
|
||||
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
||||
|
||||
Rules that keep 1200 keys navigable:
|
||||
|
||||
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
|
||||
`ui.button.Save`.
|
||||
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
|
||||
the extractor. Use an explicit map from the dynamic value to literal keys.
|
||||
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
|
||||
them as a prop.
|
||||
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
|
||||
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
|
||||
gets two keys. When unsure, split: merging later is safe, splitting later is not.
|
||||
|
||||
---
|
||||
|
||||
## What must never be translated
|
||||
|
||||
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
|
||||
|
||||
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
||||
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
|
||||
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
|
||||
names, database values.
|
||||
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
|
||||
reading a terminal at 4am, and they should stay greppable in one language.
|
||||
- **"Officer"**, and product names generally.
|
||||
|
||||
---
|
||||
|
||||
## Execution
|
||||
|
||||
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
|
||||
because a half-converted UI is unreviewable.
|
||||
|
||||
### Phase 0 — infrastructure, no visible change
|
||||
|
||||
Everything in "What it cannot do yet". Nothing in this phase touches a component.
|
||||
|
||||
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
|
||||
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
|
||||
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
|
||||
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
|
||||
5. Settings migration: widen stored bare codes on read.
|
||||
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
|
||||
7. Land the tooling below.
|
||||
|
||||
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
|
||||
expensive to reverse — review it before phase 1 starts.
|
||||
|
||||
### Phase 1 — shared components (91 files, ~47 strings)
|
||||
|
||||
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
|
||||
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
|
||||
|
||||
### Phase 2 — the shell (132 files, ~556 strings)
|
||||
|
||||
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
|
||||
look at first. Convert per directory, one commit per area.
|
||||
|
||||
### Phase 3 — the panels (213 files, ~567 strings)
|
||||
|
||||
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
|
||||
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
|
||||
agents in the same panel will still conflict.
|
||||
|
||||
### Then: `pt-PT`
|
||||
|
||||
Only after English is fully keyed. Translating against a moving catalog is wasted work.
|
||||
|
||||
---
|
||||
|
||||
## Tooling and guardrails
|
||||
|
||||
Without these, 1200 keys rot. All three are small and belong in phase 0.
|
||||
|
||||
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
|
||||
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
|
||||
meter: the count goes down as work lands.
|
||||
|
||||
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
|
||||
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
|
||||
`bun docs:check` fails on a stale number.
|
||||
|
||||
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
|
||||
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
|
||||
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
|
||||
|
||||
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
|
||||
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
|
||||
it globally on day one would just be red forever.
|
||||
|
||||
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
|
||||
else will ever notice.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
|
||||
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
|
||||
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
|
||||
- `bun test` — parity test green for every shipped locale.
|
||||
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
|
||||
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
|
||||
as **English, never as dot-paths**.
|
||||
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
|
||||
in English and wrong everywhere else.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
|
||||
canonicalization and redirect questions that are not worth answering yet.
|
||||
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
|
||||
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
|
||||
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||
manifest. Their strings are not counted above.
|
||||
|
||||
**One constraint that contract has to answer, found while converting the shared components
|
||||
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
|
||||
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
|
||||
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
|
||||
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
|
||||
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
|
||||
|
||||
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
|
||||
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
|
||||
provider-absent fallback inside the `components` package was considered and rejected: it would have
|
||||
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
|
||||
|
||||
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
|
||||
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
|
||||
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Roles and teams
|
||||
|
||||
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
|
||||
design, written down so the work starts from a spec rather than from a conversation.
|
||||
|
||||
## What this is
|
||||
|
||||
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
|
||||
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
|
||||
a homelab and it cannot express a company, where the same person is an administrator of one area and an
|
||||
ordinary member of another.
|
||||
|
||||
Two things are wanted, and they are separable:
|
||||
|
||||
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
|
||||
provisioning.
|
||||
- **Teams**, so a user belongs to several and holds a different role in each.
|
||||
|
||||
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
|
||||
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
|
||||
uses both. Nothing below may make teams mandatory to reach a feature.
|
||||
|
||||
---
|
||||
|
||||
## What already landed — phase 0, 2026-08-23
|
||||
|
||||
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
|
||||
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
|
||||
|
||||
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
|
||||
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
|
||||
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
|
||||
|
||||
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
|
||||
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
|
||||
Verified against a scratch database: push reported the violation, created the table, skipped the
|
||||
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. The owner is an identity. It is never a role.
|
||||
|
||||
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
|
||||
|
||||
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
|
||||
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
|
||||
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
|
||||
whatever UI manages roles.
|
||||
|
||||
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
|
||||
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
|
||||
else can wait behind it.
|
||||
|
||||
### D2. Several Super Admins are allowed, and the role provisions sudo
|
||||
|
||||
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
|
||||
a company has an owner and several administrators, and a homelab owner may add one or never do.
|
||||
|
||||
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
|
||||
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
|
||||
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
|
||||
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
|
||||
they left behind.
|
||||
|
||||
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
|
||||
the UI to say what it is rather than presenting it as one more row in a dropdown.
|
||||
|
||||
### D3. `Admin` is app-wide authority with no operating-system privilege
|
||||
|
||||
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
|
||||
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
|
||||
"administering the platform is not developing on it". This decision only writes that down.
|
||||
|
||||
### D4. A role may be deleted, but not while anyone holds it
|
||||
|
||||
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
|
||||
nobody asked for, and "absence denies" is the posture everywhere else in this system.
|
||||
|
||||
`isSystem` roles refuse both delete and rename, because the code names them by name:
|
||||
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
|
||||
`Member`. Renaming one is a code change that has not happened yet.
|
||||
|
||||
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
|
||||
|
||||
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
|
||||
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
|
||||
rename cascades to every user and every grant, atomically, with no code change.
|
||||
|
||||
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
|
||||
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
|
||||
|
||||
### D6. A team is a scope. Permissions UNION across a user's teams.
|
||||
|
||||
Admin in marketing and member in developers means marketing's admin level does not reach developers'
|
||||
files — but a user's effective permission for a given capability is the **highest level any of their teams
|
||||
grants**, plus their global role.
|
||||
|
||||
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
|
||||
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
|
||||
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
|
||||
should be refused rather than half-built.
|
||||
|
||||
### D7. The global role remains, and answers a different question
|
||||
|
||||
Two questions, two mechanisms:
|
||||
|
||||
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
|
||||
- **"What may this account do in this area?"** — team membership with a role in that team.
|
||||
|
||||
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
|
||||
has no natural team to ask it about. A homelab uses only the first and never makes a team.
|
||||
|
||||
### D8. Per-role provisioning is a script the role carries
|
||||
|
||||
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
|
||||
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
|
||||
in code that should become a row.
|
||||
|
||||
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
|
||||
rootless specifically. `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. Any
|
||||
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — roles are a table ✅ DONE 2026-08-23
|
||||
|
||||
Additive only. Nothing reads it.
|
||||
|
||||
### Phase 1 — the owner stops being a role
|
||||
|
||||
The load-bearing phase, and small.
|
||||
|
||||
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
|
||||
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
|
||||
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
|
||||
"owner or Super Admin" is a separate, reviewable change per route.
|
||||
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
|
||||
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
|
||||
|
||||
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
||||
|
||||
### Phase 2 — the foreign key, and role CRUD
|
||||
|
||||
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
|
||||
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
|
||||
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
|
||||
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
|
||||
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
|
||||
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
|
||||
replace what the compiler was doing.
|
||||
|
||||
### Phase 3 — per-role provisioning
|
||||
|
||||
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
|
||||
D8's warning in front of whoever writes the first script.
|
||||
|
||||
### Phase 4 — teams
|
||||
|
||||
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
|
||||
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
|
||||
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
|
||||
one table with a nullable `team_id`.
|
||||
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
|
||||
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
|
||||
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
|
||||
|
||||
**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.
|
||||
|
||||
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.**
|
||||
|
||||
---
|
||||
|
||||
## What must not be built
|
||||
|
||||
- **Deny rules.** See D6. Teams grant; they never restrict.
|
||||
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
|
||||
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
|
||||
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
|
||||
the answer is a `confined` grant with a Linux user, which is per-account by nature.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
|
||||
binary directly.
|
||||
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
|
||||
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
holds globally; the same question exists per team and is not answered here.
|
||||
@@ -0,0 +1,93 @@
|
||||
# SendOff — files land in the user's own tree, and a push says where
|
||||
|
||||
**From:** mobile · **Date:** 2026-08-23 · **v2, replacing this morning's spec entirely.** The v1
|
||||
design (staging area, transfer/target/file tables, ack lifecycle, TTL cleanup) is withdrawn — Andre's
|
||||
observation kills it: every user already has a filesystem on the server, a file browser on the web,
|
||||
and a Files app on the phone. SendOff is not a new storage system; it is a folder convention, one
|
||||
endpoint, and a push.
|
||||
|
||||
## The convention
|
||||
|
||||
```
|
||||
~/Downloads/sendoff/from-<username>/ ← a file another USER sent you
|
||||
~/Downloads/sendoff/<machine>/ ← a file you sent yourself, aimed at a named device
|
||||
```
|
||||
|
||||
That is the whole data model. Nothing to ack, nothing to expire, nothing to clean up — the files are
|
||||
ordinary files in the recipient's tree, visible in every file UI that already exists, managed like
|
||||
anything else they own.
|
||||
|
||||
## The endpoint
|
||||
|
||||
```
|
||||
POST /api/sendoff/send
|
||||
multipart: any number of `file` parts + a `meta` part:
|
||||
{ "toUser": "<username>" } — cross-user: writes into THEIR tree, from-<sender>/
|
||||
{ "machine": "<device name>" } — self: writes into own tree, <machine>/
|
||||
→ { path: "Downloads/sendoff/…", files: [{ name, size }] }
|
||||
```
|
||||
|
||||
Server-side because of the cross-user case: `/api/file-browser/upload` correctly writes only the
|
||||
caller's own tree, and that must not change. This endpoint is the one sanctioned door into another
|
||||
user's filesystem, and it writes ONLY under `Downloads/sendoff/from-<sender>/` — the sender's
|
||||
identity comes from their token, never from the payload, so the folder name cannot lie about who
|
||||
sent it. Filenames sanitised, collisions suffixed `(1)` — never overwritten; a second send of the
|
||||
same name is a second file.
|
||||
|
||||
Whether user A may send to user B at all is a platform policy decision (everyone on the server? a
|
||||
grant?) — flagging it rather than deciding it here.
|
||||
|
||||
## The push
|
||||
|
||||
On write, push to the recipient's devices through the existing pipeline:
|
||||
|
||||
```
|
||||
{ "officer": { "c": "officer/core:sendoff.landed", "path": "Downloads/sendoff/from-andre" } }
|
||||
alert: "photo.jpg from andre" / "3 files from omega"
|
||||
```
|
||||
|
||||
The push catalogue's route for the category:
|
||||
|
||||
```
|
||||
officer://files?path={path}
|
||||
```
|
||||
|
||||
Mobile opens routes from the catalogue verbatim (`openNotificationRoute`), and Officer now parses
|
||||
`officer://files?path=…` into the Files app at that folder. The web can deep-link its file browser
|
||||
the same way.
|
||||
|
||||
For a `machine`-targeted send, push only to that machine's device if it can be identified; to all
|
||||
the user's devices otherwise — the path in the folder name already says who it was for.
|
||||
|
||||
## Devices
|
||||
|
||||
The `machine` name needs a picker on the sending side, so a minimal registry survives from v1:
|
||||
|
||||
```
|
||||
devices: id (client uuid), user_id, name, platform, last_seen_at
|
||||
POST /api/devices/register { id, name, platform } (upsert; called on every app start)
|
||||
GET /api/devices → { devices } (the "send to my…" picker)
|
||||
PATCH /api/devices/:id { name } · DELETE /api/devices/:id
|
||||
```
|
||||
|
||||
No link to transfers — nothing references a device row except the picker and, best-effort, push
|
||||
targeting.
|
||||
|
||||
## Web UI
|
||||
|
||||
One send form: files + target (one of my machines / another user), POST, done. Received files need
|
||||
no UI at all — they are in the file browser already.
|
||||
|
||||
## What mobile already does (shipped against this)
|
||||
|
||||
- Registers the device on start (silent failure until the endpoint exists).
|
||||
- Parses `officer://files?path=…` and opens the Files app at that path — this is the piece that
|
||||
makes the push tap land somewhere.
|
||||
- SendOff screen: the device's name (the folder senders will aim at), and an explanation of where
|
||||
arrivals land. Sending FROM the phone comes later, likely through the existing share sheet.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Receiving in the browser (already exists — it is the file browser); cross-user permission model
|
||||
(platform decision); phone-side arbitrary-file sending (needs a document picker; the share sheet is
|
||||
the natural door and is a later step).
|
||||
+20
-1
@@ -29,13 +29,32 @@ Seven, all running.
|
||||
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
|
||||
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
|
||||
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||
reachable from inside only.
|
||||
|
||||
## Deliberately skipped
|
||||
|
||||
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
|
||||
and it must not be reported as one.
|
||||
|
||||
| Host | Since | Why |
|
||||
| ---------------------- | ---------- | ---------------------------------------------------------------- |
|
||||
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
|
||||
|
||||
Its row, kept so restoring it is one move:
|
||||
|
||||
```
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
```
|
||||
|
||||
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
|
||||
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
|
||||
is what this section exists to prevent: a host that is known to be down should cost a line in a report,
|
||||
not a retry budget.
|
||||
|
||||
### Names that disagree with themselves
|
||||
|
||||
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
|
||||
|
||||
+8
-3
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.22.02",
|
||||
"version": "2026.08.23.03",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"src/databases/*",
|
||||
"src/workspaces/*"
|
||||
],
|
||||
"engines": {
|
||||
"bun": "1.3.x"
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
||||
"gen:index": "bun run ./scripts/gen-index.ts",
|
||||
@@ -27,14 +30,16 @@
|
||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
|
||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
|
||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||
"setup": "bash scripts/install.sh",
|
||||
"check:theme": "bun scripts/check-theme-tokens.ts"
|
||||
"check:theme": "bun scripts/check-theme-tokens.ts",
|
||||
"docs:check": "bun run scripts/docs-check.ts",
|
||||
"skills:platform": "bun run scripts/gen-platform-skill.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -9,6 +9,27 @@ one.
|
||||
|
||||
---
|
||||
|
||||
## Arming for the first time — there is nothing to rehearse against
|
||||
|
||||
General commander doctrine says to run the action once before arming, so it is known to work unattended.
|
||||
**That does not apply here, and reading it as "replay the last release" is wrong.**
|
||||
|
||||
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
|
||||
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
|
||||
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
|
||||
release, which you must never do.
|
||||
|
||||
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
|
||||
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
|
||||
fights the claim or invents an occasion.
|
||||
|
||||
Two things follow from it, and they are the reason this is fine rather than a gap:
|
||||
|
||||
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
|
||||
What is unproven is this loop, not the rollout.
|
||||
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
|
||||
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
|
||||
|
||||
## What wakes you
|
||||
|
||||
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
|
||||
@@ -96,6 +117,21 @@ load-bearing. A missing report is a missing record; a missing lock is a second d
|
||||
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
|
||||
clone does not arrive believing it has deployed nothing.
|
||||
|
||||
## Before a release is tagged — not your job, but know it happened
|
||||
|
||||
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
|
||||
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
|
||||
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
|
||||
|
||||
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
|
||||
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
|
||||
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
|
||||
thirteen, listing eleven processes that do not run here while omitting six that do.
|
||||
|
||||
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
|
||||
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
|
||||
it would put a prose edit between two hosts.
|
||||
|
||||
## The release
|
||||
|
||||
The tag names the version. The commit it points at carries the rest:
|
||||
@@ -121,6 +157,11 @@ That document holds every machine, its tailnet name, **its login user**, its che
|
||||
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
|
||||
platform as out of scope.
|
||||
|
||||
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
|
||||
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
|
||||
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
|
||||
told. Read that section before you try to reach anything.
|
||||
|
||||
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
|
||||
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
|
||||
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
|
||||
@@ -148,7 +189,15 @@ assemble at the end from memory is a report that will be wrong about the host th
|
||||
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
|
||||
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
|
||||
discard someone's work to ship a release.
|
||||
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
|
||||
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
|
||||
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
|
||||
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
|
||||
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
|
||||
harmless and it need not have been.
|
||||
|
||||
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
|
||||
because the host is AHEAD is the same answer — report it, do not reset.
|
||||
|
||||
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
|
||||
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
|
||||
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
|
||||
@@ -164,6 +213,51 @@ assemble at the end from memory is a report that will be wrong about the host th
|
||||
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
||||
one that stopped in the middle and said nothing about the four hosts it never reached.
|
||||
|
||||
### Schema changes, and why `db:push` cannot be trusted
|
||||
|
||||
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
|
||||
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
|
||||
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
|
||||
success while the schema is half-applied.
|
||||
|
||||
So on any release whose plan says the schema changed:
|
||||
|
||||
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
|
||||
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
|
||||
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
|
||||
a guess.
|
||||
|
||||
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
|
||||
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
|
||||
table empty and adds the constraint in the same run, so every existing row violates it. There is no
|
||||
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
|
||||
|
||||
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
|
||||
|
||||
This is allowed and it is not a licence to improvise. The rules, in order:
|
||||
|
||||
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
|
||||
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
|
||||
that is a code change and it is not yours to make on five production hosts at 3am.
|
||||
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
|
||||
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
|
||||
the fleet has diverged in a way nothing will detect.
|
||||
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
|
||||
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
|
||||
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
|
||||
forensic exercise.
|
||||
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
|
||||
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
|
||||
finding for the report rather than something to push past.
|
||||
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
|
||||
the specific check, and its answer, for each.
|
||||
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
|
||||
repair you invented.
|
||||
|
||||
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
|
||||
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
|
||||
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
|
||||
|
||||
### Skip the host you are running on
|
||||
|
||||
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The wait half of the deploy commander: poll for a release tag, and when one appears that has not been
|
||||
# attempted, spawn a headless agent to deploy it.
|
||||
#
|
||||
# WHY THIS IS IN THE REPOSITORY AND NOT WRITTEN FRESH EACH ARMING
|
||||
#
|
||||
# prompts/deploy-commander.md says the watcher loop is rewritten from scratch every time one arms, and
|
||||
# treats that as a given. It is also how the same defect keeps arriving. The loop that was on disk at
|
||||
# data/deploy-watch.sh from 2026-08-21 gated on "$v.done" — a file that exists only on SUCCESS — and never
|
||||
# called deploy-claim.sh at all. That is precisely the retry storm the claim script was written to make
|
||||
# impossible, reintroduced in the one place the claim script cannot reach: the caller that decides whether
|
||||
# to invoke it. A rule enforced by a script nobody calls is not enforced.
|
||||
#
|
||||
# So the loop lives next to the claim it depends on, and changes with it.
|
||||
#
|
||||
# THE BASELINE IS THE CLAIM, NOT THE SUCCESS
|
||||
#
|
||||
# A watcher needs to know what counts as new. Here that is "no claim directory for this version" — a record
|
||||
# of what has been HANDLED, not of what has SUCCEEDED. The distinction is the whole point: a release that
|
||||
# failed is handled. It is looked at by a human, not retried by a loop.
|
||||
#
|
||||
# deploy-claim.sh honours a pre-existing .done with no .claim, so releases deployed before it existed are
|
||||
# already skipped and arming this today fires on none of them.
|
||||
#
|
||||
# THE CLAIM IS TAKEN HERE, BEFORE THE SPAWN
|
||||
#
|
||||
# An action that claims its own work has claimed nothing if it dies before getting that far. So the loop
|
||||
# claims, then spawns — which means the child must be TOLD the claim is already held. It is about to read
|
||||
# prompts/deploy-commander.md, whose claim instruction is addressed to whoever reads it, and an obedient
|
||||
# child that claims for itself gets exit 1 and correctly refuses to deploy. See PROMPT below.
|
||||
set -u
|
||||
|
||||
PLATFORM_DIR="${PLATFORM_DIR:-$HOME/officer-dev/platform}"
|
||||
# Empty in the sidecar's environment, where anything built from it resolves to /data/... and the record
|
||||
# lands where nothing reads it. A watcher whose baseline is invisible to itself has none.
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-$HOME/officer-dev}"
|
||||
DEPLOYS_DIR="${DEPLOYS_DIR:-$OFFICER_ROOT/data/deploys}"
|
||||
POLL_SECONDS="${POLL_SECONDS:-120}"
|
||||
export OFFICER_ROOT DEPLOYS_DIR
|
||||
|
||||
CLAIM_SH="$PLATFORM_DIR/scripts/deploy-claim.sh"
|
||||
|
||||
stamp() { date -Iseconds; }
|
||||
|
||||
if [ ! -x "$CLAIM_SH" ]; then
|
||||
echo "$(stamp) deploy-watch: no claim script at $CLAIM_SH — refusing to arm without one" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$DEPLOYS_DIR" || exit 2
|
||||
|
||||
echo "$(stamp) deploy-watch: armed. polling $(cd "$PLATFORM_DIR" && git remote get-url origin) every ${POLL_SECONDS}s"
|
||||
echo "$(stamp) deploy-watch: baseline is $DEPLOYS_DIR — a version with a claim is never fired again"
|
||||
|
||||
while true; do
|
||||
# origin is the LOCAL gitea over the tailnet. The public hostname arrives through a proxy where every
|
||||
# connection shares one source IP and SSH is refused for long stretches — which presents as an
|
||||
# access-rights error and is not one. Eighty minutes of detection latency on 2026-08-21.
|
||||
raw=$(cd "$PLATFORM_DIR" && timeout 30 git ls-remote --tags origin 'refs/tags/deploy/*' 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "$(stamp) tick: ls-remote failed (exit $rc) — ${raw%%$'\n'*}"
|
||||
sleep "$POLL_SECONDS"
|
||||
continue
|
||||
fi
|
||||
|
||||
versions=$(printf '%s\n' "$raw" | grep -v '\^{}' | awk '{print $2}' | sed -E 's#.*refs/tags/deploy/##' | sort -u)
|
||||
|
||||
pending=""
|
||||
for v in $versions; do
|
||||
[ -n "$v" ] || continue
|
||||
[ "$("$CLAIM_SH" state "$v" 2>/dev/null)" = unclaimed ] && pending="$pending $v"
|
||||
done
|
||||
|
||||
# Every tick leaves a line, including the quiet ones: a fresh timestamp is the only thing that tells
|
||||
# "nothing to deploy" apart from "the loop died".
|
||||
echo "$(stamp) tick: $(printf '%s\n' "$versions" | grep -c .) tags,${pending:- none} pending"
|
||||
|
||||
for v in $pending; do
|
||||
if ! "$CLAIM_SH" claim "$v"; then
|
||||
# Lost a race with another watcher, or it was claimed between the state check and here. Either way
|
||||
# it is not ours and never becomes ours.
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$(stamp) deploy/$v claimed — spawning deploy agent"
|
||||
|
||||
# The child reads the procedure, which tells ITS reader to claim first. It must not: we hold the claim
|
||||
# on its behalf, and its own claim would return exit 1 and stop the deploy it was spawned to do.
|
||||
PROMPT="Read prompts/deploy-commander.md in $PLATFORM_DIR and follow it exactly, for the release deploy/$v.
|
||||
|
||||
The claim for $v is ALREADY HELD on your behalf by the watcher that spawned you. Do NOT run
|
||||
'scripts/deploy-claim.sh claim $v' — it will refuse, correctly, because the claim is taken, and stopping
|
||||
there would abandon a deploy you were started to perform. Everything else in that file applies unchanged,
|
||||
including running 'scripts/deploy-claim.sh done $v' once the rollout has succeeded.
|
||||
|
||||
Roll it out across every host the plan names, skip the host you are running on, and write the report
|
||||
exactly as the procedure specifies."
|
||||
|
||||
raw_log="$DEPLOYS_DIR/$v.child.jsonl"
|
||||
|
||||
# Nothing links a spawned agent to its parent and a child transcript does not last, so the stream is
|
||||
# kept here as it arrives. stderr never enters the pipe: the CLI writes warnings there and one landing
|
||||
# in the stream corrupts the first jq parse.
|
||||
{
|
||||
cd "$PLATFORM_DIR" && claude -p "$PROMPT" \
|
||||
--dangerously-skip-permissions \
|
||||
--output-format=stream-json \
|
||||
--verbose
|
||||
} 2> >(while IFS= read -r l; do echo " ! $l"; done) \
|
||||
| tee -a "$raw_log" \
|
||||
| jq -r --unbuffered '
|
||||
if .type=="system" and .subtype=="init" then
|
||||
" · child session " + .session_id
|
||||
elif .type=="assistant" then
|
||||
(.message.content[]? |
|
||||
if .type=="tool_use" then
|
||||
" ⚙ " + .name + ": " + ((.input.description // .input.command // .input.pattern // (.input | tostring)))
|
||||
elif .type=="text" then
|
||||
" » " + .text
|
||||
else empty end)
|
||||
elif .type=="user" then
|
||||
(.message.content[]? | select(.type=="tool_result") |
|
||||
" " + (if (.content|type)=="string" then .content else (.content // [] | map(.text? // "") | join(" ")) end))
|
||||
elif .type=="result" then
|
||||
" = deploy agent " + .subtype + ", " + (.num_turns|tostring) + " turns, " + (.total_cost_usd|tostring) + " usd"
|
||||
else empty end
|
||||
' 2>/dev/null
|
||||
|
||||
if [ -f "$DEPLOYS_DIR/$v.done" ]; then
|
||||
echo "$(stamp) deploy/$v SUCCEEDED — recorded, transcript at $raw_log"
|
||||
else
|
||||
# The claim stays held. This is the loop's most important line and the one that used to say
|
||||
# "will retry next poll".
|
||||
echo "$(stamp) deploy/$v FAILED — no .done written. NOT retrying; the claim is held."
|
||||
echo "$(stamp) transcript: $raw_log"
|
||||
echo "$(stamp) to allow another attempt, deliberately: rm -rf $DEPLOYS_DIR/$v.claim"
|
||||
fi
|
||||
done
|
||||
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Glob } from 'bun';
|
||||
|
||||
/*
|
||||
Which settings sections actually exist, read from the screens that declare them.
|
||||
|
||||
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
|
||||
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
|
||||
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
|
||||
narrow enough to be obvious when it breaks.
|
||||
*/
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
|
||||
|
||||
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
|
||||
export async function declaredSettingsPaths(): Promise<Set<string>> {
|
||||
const paths = new Set<string>();
|
||||
const glob = new Glob('**/*.tsx');
|
||||
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
|
||||
const body = readFileSync(file, 'utf-8');
|
||||
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
|
||||
if (!base) continue;
|
||||
paths.add(base);
|
||||
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings routes Spotlight offers, and which of them nothing declares.
|
||||
*
|
||||
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
|
||||
* lucide and React.
|
||||
*/
|
||||
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
|
||||
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
|
||||
const body = readFileSync(file, 'utf-8');
|
||||
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
|
||||
const declared = await declaredSettingsPaths();
|
||||
return { offered, missing: offered.filter((p) => !declared.has(p)) };
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Does the documentation still match the code?
|
||||
|
||||
── Why a script and not a habit ──
|
||||
|
||||
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
|
||||
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
|
||||
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
|
||||
|
||||
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
|
||||
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
|
||||
number is exactly the thing nobody re-derives while editing a sentence around it.
|
||||
|
||||
Real examples this was written from, both live at the time:
|
||||
|
||||
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
|
||||
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
|
||||
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
|
||||
|
||||
── The rule for adding a check ──
|
||||
|
||||
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
|
||||
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
|
||||
the same edit that misses the doc.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
type Finding = { file: string; claim: string; documented: string; actual: string };
|
||||
type CheckResult = { findings: Finding[]; skipped?: string };
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const WORKSPACE = resolve(ROOT, '..');
|
||||
|
||||
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
|
||||
|
||||
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
|
||||
const WORDS: Record<string, number> = {
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
thirteen: 13,
|
||||
fourteen: 14,
|
||||
fifteen: 15,
|
||||
sixteen: 16,
|
||||
seventeen: 17,
|
||||
eighteen: 18,
|
||||
nineteen: 19,
|
||||
twenty: 20,
|
||||
'twenty-one': 21,
|
||||
'twenty-two': 22,
|
||||
'twenty-three': 23,
|
||||
'twenty-four': 24,
|
||||
'twenty-five': 25,
|
||||
};
|
||||
const asNumber = (raw: string): number | null => {
|
||||
const digits = Number(raw);
|
||||
if (Number.isFinite(digits)) return digits;
|
||||
return WORDS[raw.toLowerCase()] ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A documented count, found by a pattern with one capture group, compared to the live value.
|
||||
*
|
||||
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
|
||||
*/
|
||||
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
|
||||
const body = read(file);
|
||||
if (!body) return { findings: [], skipped: `${file} not found` };
|
||||
const findings: Finding[] = [];
|
||||
for (const match of body.matchAll(pattern)) {
|
||||
const stated = asNumber(match[1] ?? '');
|
||||
if (stated === null || stated === actual) continue;
|
||||
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
|
||||
}
|
||||
return { findings };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const all: Finding[] = [];
|
||||
const skipped: string[] = [];
|
||||
const record = (r: CheckResult) => {
|
||||
all.push(...r.findings);
|
||||
if (r.skipped) skipped.push(r.skipped);
|
||||
};
|
||||
|
||||
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
record(
|
||||
checkCount(
|
||||
join(ROOT, 'CLAUDE.md'),
|
||||
/([0-9]+|[a-z-]+) entries as of/gi,
|
||||
PERMISSIONS.length,
|
||||
'permission registry entries',
|
||||
),
|
||||
);
|
||||
|
||||
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
|
||||
// admission that it needs a check rather than a caveat.
|
||||
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
if (pm2.exitCode === 0) {
|
||||
try {
|
||||
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
|
||||
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
|
||||
record(
|
||||
checkCount(
|
||||
join(WORKSPACE, 'CLAUDE.md'),
|
||||
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
|
||||
officer,
|
||||
'officer PM2 entries',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
skipped.push('pm2 jlist did not parse');
|
||||
}
|
||||
} else {
|
||||
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
|
||||
skipped.push('pm2 unavailable — PM2 entry count unchecked');
|
||||
}
|
||||
|
||||
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
|
||||
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
|
||||
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
|
||||
const declared = await SPOTLIGHT_SETTINGS_PATHS();
|
||||
for (const bad of declared.missing) {
|
||||
all.push({
|
||||
file: 'Spotlight/useSpotlightItems.ts',
|
||||
claim: 'settings route offered by Spotlight',
|
||||
documented: bad,
|
||||
actual: 'no screen declares that section',
|
||||
});
|
||||
}
|
||||
|
||||
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
|
||||
// an agent quotes it to a user with no way to tell it is out of date.
|
||||
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
|
||||
const expected = await renderPlatformSkill();
|
||||
const onDisk = read(SKILL_PATH);
|
||||
if (onDisk === null) {
|
||||
all.push({
|
||||
file: 'capabilities/skills/platform/SKILL.md',
|
||||
claim: 'generated platform skill',
|
||||
documented: 'missing',
|
||||
actual: 'run `bun run skills:platform`',
|
||||
});
|
||||
} else if (onDisk !== expected) {
|
||||
all.push({
|
||||
file: 'capabilities/skills/platform/SKILL.md',
|
||||
claim: 'generated platform skill is stale',
|
||||
documented: `${onDisk.split('\n').length} lines on disk`,
|
||||
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
|
||||
});
|
||||
}
|
||||
|
||||
for (const note of skipped) console.log(` skipped: ${note}`);
|
||||
if (all.length === 0) {
|
||||
console.log('docs:check — every checkable claim matches the code');
|
||||
return;
|
||||
}
|
||||
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
|
||||
for (const f of all) {
|
||||
console.log(` ${f.file}`);
|
||||
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -34,6 +34,44 @@ const fail = (what: string, fix: string) => {
|
||||
};
|
||||
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
|
||||
|
||||
console.log('\n── Runtime ──\n');
|
||||
|
||||
/**
|
||||
* bun is deliberately held at a series — `BUN_SERIES` in
|
||||
* scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while.
|
||||
*
|
||||
* The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version
|
||||
* argument (it offers latest-stable or --canary and nothing else), so it always jumps to the
|
||||
* newest release and walks a pinned machine off the pin without saying so. This is the only
|
||||
* thing that notices, which is why a version check sits in a script otherwise about accounts.
|
||||
*
|
||||
* The wanted series is READ from `engines.bun` rather than repeated here, because a second
|
||||
* hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an
|
||||
* install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is
|
||||
* the declaration and this is the enforcement.
|
||||
*/
|
||||
const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as {
|
||||
engines?: { bun?: string };
|
||||
};
|
||||
const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, '');
|
||||
const nowSeries = Bun.version.split('.').slice(0, 2).join('.');
|
||||
|
||||
if (!wantSeries) {
|
||||
fail(
|
||||
'package.json declares no engines.bun',
|
||||
'The setup scripts pin a bun series and nothing else records which one. Add it.',
|
||||
);
|
||||
} else if (nowSeries === wantSeries) {
|
||||
pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`);
|
||||
} else {
|
||||
fail(
|
||||
`bun ${Bun.version} is outside the pinned ${wantSeries} series`,
|
||||
`Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` +
|
||||
`'curl -fsSL https://bun.sh/install | bash -s bun-v<newest ${wantSeries}.x>', or raise ` +
|
||||
`engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n── Accounts ──\n');
|
||||
|
||||
const users = await getUsers();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
|
||||
|
||||
── Why generated ──
|
||||
|
||||
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
|
||||
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
|
||||
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
|
||||
|
||||
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
|
||||
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
|
||||
and the prose is limited to what no registry can express.
|
||||
|
||||
── The split, and where the line is ──
|
||||
|
||||
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
|
||||
keyboard shortcuts
|
||||
PROSE what the platform IS, what a permission KIND means for a user, where to look next
|
||||
|
||||
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
|
||||
differs, so a stale skill is a failed check rather than a confident wrong answer.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
|
||||
|
||||
const KIND_MEANING: Record<string, string> = {
|
||||
core: 'every account has it and it cannot be denied — the caller acting on themselves',
|
||||
app: 'grantable per role, at read or write',
|
||||
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
|
||||
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
|
||||
admin: 'the server owner only',
|
||||
};
|
||||
|
||||
export async function renderPlatformSkill(): Promise<string> {
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||||
|
||||
const byKind = new Map<string, typeof PERMISSIONS>();
|
||||
for (const p of PERMISSIONS) {
|
||||
const list = byKind.get(p.kind) ?? [];
|
||||
list.push(p);
|
||||
byKind.set(p.kind, list as typeof PERMISSIONS);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const out = (s = '') => lines.push(s);
|
||||
|
||||
out('---');
|
||||
out('name: Officer platform');
|
||||
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
|
||||
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
|
||||
out('description: >-');
|
||||
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
|
||||
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
|
||||
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
|
||||
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
|
||||
out('version: 1');
|
||||
out('---');
|
||||
out();
|
||||
out('<!--');
|
||||
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
|
||||
out(' differs from what the generator produces, and an edit here is lost on the next run.');
|
||||
out();
|
||||
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
|
||||
out('-->');
|
||||
out();
|
||||
out('# What Officer is');
|
||||
out();
|
||||
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
|
||||
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
|
||||
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
|
||||
out('user, and **absence denies** — there is no row meaning "no".');
|
||||
out();
|
||||
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
|
||||
out('no, and *not shareable at all*.');
|
||||
out();
|
||||
out('# Permissions');
|
||||
out();
|
||||
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
|
||||
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
|
||||
out('diligence.');
|
||||
out();
|
||||
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
|
||||
const entries = byKind.get(kind) ?? [];
|
||||
if (!entries.length) continue;
|
||||
out(`## \`${kind}\` — ${meaning}`);
|
||||
out();
|
||||
for (const p of entries) {
|
||||
const bits: string[] = [];
|
||||
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
|
||||
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
|
||||
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
|
||||
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
|
||||
if (bits.length) out(` - ${bits.join(' · ')}`);
|
||||
if (p.selfService?.length) {
|
||||
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
|
||||
}
|
||||
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
|
||||
if (p.readOnlyWrites?.length) {
|
||||
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
|
||||
}
|
||||
}
|
||||
out();
|
||||
}
|
||||
|
||||
out('# Keyboard shortcuts');
|
||||
out();
|
||||
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||||
out('list rather than remembering one.');
|
||||
out();
|
||||
for (const section of SHORTCUT_SECTIONS) {
|
||||
out(`## ${section.title}`);
|
||||
if (section.note) {
|
||||
out();
|
||||
out(section.note);
|
||||
}
|
||||
out();
|
||||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
|
||||
out();
|
||||
}
|
||||
|
||||
out('# What is NOT in here');
|
||||
out();
|
||||
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
|
||||
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
|
||||
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
|
||||
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
|
||||
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
|
||||
out(' commander is, how one is started, and how to help somebody shape one.');
|
||||
out();
|
||||
out('# When you are about to guess');
|
||||
out();
|
||||
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
|
||||
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
|
||||
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
|
||||
out('certain as one quoting a true one.');
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const body = await renderPlatformSkill();
|
||||
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
|
||||
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
|
||||
if (existing === body) {
|
||||
console.log('skills:platform — already current');
|
||||
} else {
|
||||
writeFileSync(SKILL_PATH, body);
|
||||
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
|
||||
}
|
||||
}
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Counts candidate hardcoded user-facing strings in the web UI.
|
||||
*
|
||||
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
|
||||
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
|
||||
*
|
||||
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
|
||||
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
|
||||
* progress; a zero means "nothing this scanner can see", not "nothing left".
|
||||
*
|
||||
* bun scripts/i18n/scan-hardcoded.ts # summary by area
|
||||
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
|
||||
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
|
||||
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const AREAS = [
|
||||
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
|
||||
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
|
||||
{ label: 'shared components', root: 'src/workspaces/components' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
|
||||
* zero — enabling every area on day one would just be red for weeks and get ignored.
|
||||
*
|
||||
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
|
||||
* that must NEVER be translated; they are reported but excluded from the check, which is why the
|
||||
* neverTranslate split below has to exist for this entry to be possible at all.
|
||||
*/
|
||||
const ZEROED: string[] = ['shared components'];
|
||||
|
||||
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||
|
||||
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
|
||||
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
|
||||
|
||||
/**
|
||||
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
|
||||
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
|
||||
*
|
||||
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
|
||||
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
|
||||
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
|
||||
*/
|
||||
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||
// >Some visible words<
|
||||
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
||||
// placeholder="..." and friends
|
||||
{
|
||||
kind: 'attributes',
|
||||
rx: new RegExp(
|
||||
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
|
||||
'g',
|
||||
),
|
||||
},
|
||||
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
|
||||
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
|
||||
{
|
||||
kind: 'object labels',
|
||||
rx: new RegExp(
|
||||
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
|
||||
'g',
|
||||
),
|
||||
},
|
||||
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
|
||||
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
|
||||
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
|
||||
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
];
|
||||
|
||||
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
|
||||
function isHuman(value: string | undefined): value is string {
|
||||
if (!value) return false;
|
||||
const s = value.trim();
|
||||
if (s.length < 3) return false;
|
||||
if (!/[A-Za-z]{3}/.test(s)) return false;
|
||||
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
|
||||
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
|
||||
if (/^[A-Z0-9_]+$/.test(s)) return false;
|
||||
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
|
||||
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
|
||||
// followed by a credential is never something a person reads.
|
||||
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
|
||||
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
|
||||
// `description: 'group-[.toast]:text-muted-foreground'`.
|
||||
//
|
||||
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
|
||||
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
|
||||
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
|
||||
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
|
||||
const tokens = s.split(/\s+/);
|
||||
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
|
||||
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
|
||||
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
|
||||
*
|
||||
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
|
||||
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
|
||||
*/
|
||||
const stripComments = (source: string): string =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
|
||||
/**
|
||||
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
|
||||
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
|
||||
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
|
||||
* thing it documents.
|
||||
*/
|
||||
const stripCodeElements = (source: string): string =>
|
||||
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry);
|
||||
if (SKIP.test(path)) continue;
|
||||
let isDir = false;
|
||||
try {
|
||||
isDir = statSync(path).isDirectory();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (isDir) yield* walk(path);
|
||||
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
|
||||
}
|
||||
}
|
||||
|
||||
type Hit = { kind: string; value: string; file: string };
|
||||
|
||||
function scan(root: string): { files: number; hits: Hit[] } {
|
||||
const hits: Hit[] = [];
|
||||
let files = 0;
|
||||
|
||||
for (const file of walk(root)) {
|
||||
files++;
|
||||
let source: string;
|
||||
try {
|
||||
source = stripCodeElements(stripComments(readFileSync(file, 'utf8')));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const { kind, rx } of PATTERNS) {
|
||||
rx.lastIndex = 0;
|
||||
for (const match of source.matchAll(rx)) {
|
||||
const value = match.slice(1).find((group) => group !== undefined);
|
||||
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, hits };
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
|
||||
|
||||
const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
|
||||
const kinds = PATTERNS.map((p) => p.kind);
|
||||
const pad = (s: string | number, n: number) => String(s).padStart(n);
|
||||
|
||||
console.log(
|
||||
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
|
||||
);
|
||||
for (const r of results) {
|
||||
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
|
||||
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
|
||||
}
|
||||
|
||||
const all = results.flatMap((r) => r.hits);
|
||||
const distinct = new Set(all.map((h) => h.value));
|
||||
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
|
||||
|
||||
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
|
||||
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
|
||||
|
||||
console.log(
|
||||
`${'TOTAL'.padEnd(22)} ${pad(
|
||||
results.reduce((n, r) => n + r.files, 0),
|
||||
5,
|
||||
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
|
||||
);
|
||||
console.log();
|
||||
console.log(`occurrences ${all.length}`);
|
||||
console.log(` convertible ${convertible(all).length}`);
|
||||
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
|
||||
console.log(`distinct strings ${distinct.size}`);
|
||||
console.log(` needing params ${interpolated.size}`);
|
||||
|
||||
if (args.has('--files')) {
|
||||
console.log('\nworst files:');
|
||||
const perFile = new Map<string, number>();
|
||||
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
|
||||
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
|
||||
console.log(` ${pad(n, 4)} ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (listPrefix) {
|
||||
console.log(`\nstrings in files matching "${listPrefix}":`);
|
||||
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
|
||||
}
|
||||
|
||||
if (args.has('--check')) {
|
||||
const regressed = results
|
||||
.filter((r) => ZEROED.includes(r.label))
|
||||
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
|
||||
.filter((r) => r.hits.length > 0);
|
||||
if (regressed.length > 0) {
|
||||
console.error('\nThese areas were converted and have regressed:');
|
||||
for (const r of regressed) {
|
||||
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
|
||||
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
|
||||
}
|
||||
console.error('\nUse a catalog key. See docs/localization.md.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Seed the `roles` table with the roles the platform's own code names.
|
||||
*
|
||||
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
|
||||
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
|
||||
* mechanism `backfill-session-models.ts` relies on.
|
||||
*
|
||||
* ── 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.
|
||||
*
|
||||
* ── 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.
|
||||
*/
|
||||
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||
|
||||
const SEED: { name: string; description: string }[] = [
|
||||
{ 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: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await waitForDatabase();
|
||||
let created = 0;
|
||||
for (const role of SEED) {
|
||||
if (await seedRole({ ...role, isSystem: true })) created += 1;
|
||||
}
|
||||
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
|
||||
}
|
||||
|
||||
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
|
||||
// a successful push look like a failed one — the rows are additive and the next push tries again.
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
|
||||
})
|
||||
.finally(() => process.exit(0));
|
||||
@@ -206,7 +206,7 @@ nvim_clone_config() {
|
||||
# every process here. officer-pty imports node-pty, a native addon with
|
||||
# no Linux prebuild — it compiles against the installed Node on every
|
||||
# machine. Either one alone makes Node load-bearing.
|
||||
# bun the platform itself and nineteen of the twenty pm2 apps.
|
||||
# bun the platform itself, and every pm2 app except officer-pty.
|
||||
# pm2 the process manager the ecosystem files are written for.
|
||||
#
|
||||
# Deno is not. Nothing in the platform imports it — checked across the whole
|
||||
@@ -243,6 +243,69 @@ install_node() {
|
||||
npm config set prefix /usr/local >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ── Held at the 1.3 series, deliberately and temporarily ──
|
||||
#
|
||||
# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is
|
||||
# newest, so a machine provisioned two days later puts a brand-new release under
|
||||
# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched
|
||||
# for a while. That is the owner's call and it is meant to be revisited in a few
|
||||
# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it.
|
||||
#
|
||||
# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be
|
||||
# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick"
|
||||
# exactly the way setup_22.x did for Node.
|
||||
#
|
||||
# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade`
|
||||
# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which
|
||||
# offers latest-stable or --canary and nothing else — so it always jumps to the
|
||||
# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a
|
||||
# setup script can prevent that. `bun run doctor` is where that drift is caught.
|
||||
BUN_SERIES="${BUN_SERIES:-1.3}"
|
||||
|
||||
# The newest stable release inside the series.
|
||||
#
|
||||
# Asked of the npm registry rather than GitHub's releases API, which is the obvious
|
||||
# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per
|
||||
# IP, and it returned 504 twice while this was being written. An empty answer here
|
||||
# means no bun gets installed at all (see install_bun), so where it comes from is
|
||||
# load-bearing rather than a matter of taste. bun publishes every release to npm.
|
||||
#
|
||||
# `sort -V` rather than trusting the order the registry returns them in, and the
|
||||
# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists.
|
||||
bun_series_latest() {
|
||||
curl -fsSL https://registry.npmjs.org/bun 2>/dev/null |
|
||||
jq -r '.versions | keys[]' 2>/dev/null |
|
||||
grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" |
|
||||
sort -V | tail -1
|
||||
}
|
||||
|
||||
# The major.minor of a full version — "1.3.14" is in series "1.3".
|
||||
bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; }
|
||||
|
||||
# Where an installed bun sits relative to the pin: in, above, below, or unknown.
|
||||
#
|
||||
# Above and below are different problems and get different answers — above is a
|
||||
# machine that has upgraded itself past the pin, below is one that is simply old —
|
||||
# so this reports which rather than a yes/no. `sort -V` does the comparing, so
|
||||
# nothing here has to parse a version.
|
||||
bun_series_position() {
|
||||
local now
|
||||
now="$(bun_series_of "${1:-}")"
|
||||
[[ -n "$now" ]] || {
|
||||
echo unknown
|
||||
return
|
||||
}
|
||||
[[ "$now" == "$BUN_SERIES" ]] && {
|
||||
echo in
|
||||
return
|
||||
}
|
||||
if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then
|
||||
echo above
|
||||
else
|
||||
echo below
|
||||
fi
|
||||
}
|
||||
|
||||
# Present anywhere: on PATH for this root shell, or in the account's own
|
||||
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
|
||||
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
|
||||
@@ -260,8 +323,8 @@ bun_version() {
|
||||
# The system-wide link, ensured on every run rather than only after an install.
|
||||
#
|
||||
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
|
||||
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
|
||||
# then fails to start on reboot while working perfectly when started by hand. A
|
||||
# PATH — and every ecosystem app that says `script: 'bun'` then fails to start on
|
||||
# reboot while working perfectly when started by hand. A
|
||||
# machine that already had bun before this script ran would never get the link if
|
||||
# it were only made as part of installing.
|
||||
#
|
||||
@@ -280,8 +343,22 @@ ensure_bun_symlink() {
|
||||
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
|
||||
# symlink every bun-based sidecar fails to start on reboot and works fine when
|
||||
# started by hand, which is a miserable thing to debug.
|
||||
#
|
||||
# The version goes in as the installer's first positional argument, which it
|
||||
# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a
|
||||
# releases/download/<tag>/ URL. `bash -s` is what carries an argument through a pipe.
|
||||
# The installer handles being pointed at an older tag on purpose — it re-probes AVX2
|
||||
# rather than assuming the baseline the newest release was built for — so this both
|
||||
# installs and pins back down.
|
||||
#
|
||||
# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach
|
||||
# the registry, and installing whatever is newest is the single outcome the pin
|
||||
# exists to prevent, so a machine with no bun is the better failure: it is loud, and
|
||||
# officer-setup's pre-flight stops on a missing bun rather than building on 1.4.
|
||||
install_bun() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
|
||||
local tag="${1:-}"
|
||||
[[ -n "$tag" ]] || return 1
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||
}
|
||||
|
||||
|
||||
@@ -1981,14 +1981,18 @@ step "JavaScript runtimes"
|
||||
if ! skip; then
|
||||
NODE_LTS="$(node_lts_major)"
|
||||
NODE_NOW="$(node_installed_major)"
|
||||
# Resolved before the banner so it can state the pin rather than only the version
|
||||
# that happens to be here. One network call, same as node_lts_label above it.
|
||||
BUN_WANT="$(bun_series_latest)"
|
||||
BUN_NOW="$(bun_version)"
|
||||
|
||||
echo ""
|
||||
info "JavaScript runtimes — required, so these are installed rather than offered"
|
||||
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
|
||||
echo " pm2 is a Node application, and officer-pty compiles node-pty"
|
||||
echo " against whatever Node is installed — there is no Linux prebuild."
|
||||
echo " bun $(bun_installed && bun_version || echo 'not installed')"
|
||||
echo " the platform itself, and nineteen of the twenty pm2 apps."
|
||||
echo " bun ${BUN_NOW:-not installed} — pinned to the ${BUN_SERIES} series${BUN_WANT:+, newest is ${BUN_WANT}}"
|
||||
echo " the platform itself, and every pm2 app except officer-pty."
|
||||
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
|
||||
echo " supervises all of them, and the ecosystem files are written for it."
|
||||
echo ""
|
||||
@@ -2014,18 +2018,93 @@ if ! skip; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── bun ──
|
||||
if bun_installed; then
|
||||
ok "bun $(bun_version) already installed"
|
||||
else
|
||||
info " installing bun..."
|
||||
if install_bun && bun_installed; then
|
||||
# ── bun, held at the pinned series ──
|
||||
#
|
||||
# Four states, because "installed" is not one answer. A machine can be inside the
|
||||
# pin, behind it, or past it — and past it is the case worth catching, since that
|
||||
# is what a `bun upgrade` leaves behind and nothing else reports it.
|
||||
if [[ -z "$BUN_WANT" ]]; then
|
||||
# No pin resolved means no install: taking whatever bun.sh serves is the one
|
||||
# thing the pin exists to stop.
|
||||
warn "could not reach the npm registry to find the newest bun ${BUN_SERIES}.x"
|
||||
if bun_installed; then
|
||||
echo " leaving bun ${BUN_NOW} where it is"
|
||||
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; left ${BUN_NOW} alone")
|
||||
else
|
||||
echo " nothing installed — this will not install an unpinned bun, and"
|
||||
echo " officer-setup stops on a missing bun rather than building on 1.4."
|
||||
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; nothing installed")
|
||||
fi
|
||||
|
||||
elif ! bun_installed; then
|
||||
info " installing bun ${BUN_WANT}, pinned to the ${BUN_SERIES} series..."
|
||||
if install_bun "bun-v${BUN_WANT}" && bun_installed; then
|
||||
ok "bun $(bun_version)"
|
||||
SUMMARY+=("Bun: $(bun_version)")
|
||||
SUMMARY+=("Bun: $(bun_version) (pinned to ${BUN_SERIES}.x)")
|
||||
else
|
||||
warn "the bun install did not complete"
|
||||
ERRORS+=("Bun: install failed")
|
||||
fi
|
||||
|
||||
else
|
||||
case "$(bun_series_position "$BUN_NOW")" in
|
||||
in)
|
||||
if [[ "$BUN_NOW" == "$BUN_WANT" ]]; then
|
||||
ok "bun ${BUN_NOW} — the newest in the pinned ${BUN_SERIES} series"
|
||||
else
|
||||
info "bun ${BUN_NOW} is inside the pin, and ${BUN_WANT} is out"
|
||||
echo " A patch inside the series is what the pin is meant to let"
|
||||
echo " through. Note that 'bun upgrade' would not stop there — it"
|
||||
echo " takes the newest release, which is past the pin."
|
||||
if confirm "Move to ${BUN_WANT}?"; then
|
||||
if install_bun "bun-v${BUN_WANT}"; then
|
||||
ok "bun $(bun_version)"
|
||||
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
|
||||
else
|
||||
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||
ERRORS+=("Bun: could not move to ${BUN_WANT}")
|
||||
fi
|
||||
else
|
||||
echo " left at ${BUN_NOW}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
above)
|
||||
# The `bun upgrade` case. Reported rather than corrected silently: moving
|
||||
# somebody's runtime backwards is a decision, and this is the only place
|
||||
# that knows it happened.
|
||||
warn "bun ${BUN_NOW} is PAST the pinned ${BUN_SERIES} series"
|
||||
echo " Most likely a 'bun upgrade', which takes no version argument and"
|
||||
echo " always jumps to the newest release. The platform and every pm2"
|
||||
echo " app except officer-pty run on this."
|
||||
if confirm "Pin it back to ${BUN_WANT}?"; then
|
||||
if install_bun "bun-v${BUN_WANT}"; then
|
||||
ok "bun $(bun_version) — back inside the pin"
|
||||
SUMMARY+=("Bun: pinned back, ${BUN_NOW} -> $(bun_version)")
|
||||
else
|
||||
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||
ERRORS+=("Bun: could not pin ${BUN_NOW} back to ${BUN_WANT}")
|
||||
fi
|
||||
else
|
||||
warn "left at ${BUN_NOW}, outside the pin"
|
||||
SUMMARY+=("Bun: ${BUN_NOW}, PAST the ${BUN_SERIES} pin by choice")
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
info "bun ${BUN_NOW} is older than the pinned ${BUN_SERIES} series"
|
||||
if confirm "Move to ${BUN_WANT}?"; then
|
||||
if install_bun "bun-v${BUN_WANT}"; then
|
||||
ok "bun $(bun_version)"
|
||||
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
|
||||
else
|
||||
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||
ERRORS+=("Bun: could not move to ${BUN_WANT}")
|
||||
fi
|
||||
else
|
||||
echo " left at ${BUN_NOW}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Ensured every run, not only after an install. A machine that already had bun
|
||||
|
||||
@@ -760,7 +760,7 @@ if ! skip; then
|
||||
echo " The ecosystem file is GENERATED, not checked in. It describes this"
|
||||
echo " install and nothing else, so nothing in git can drift from it."
|
||||
echo ""
|
||||
echo " six processes:"
|
||||
echo " ${#CORE_PROCESSES[@]} processes:"
|
||||
for entry in "${CORE_PROCESSES[@]}"; do
|
||||
IFS='|' read -r _name _script _args <<<"$entry"
|
||||
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
|
||||
@@ -772,7 +772,7 @@ if ! skip; then
|
||||
if confirm "Write it and start them?"; then
|
||||
write_ecosystem
|
||||
ok "written — $(ecosystem_file)"
|
||||
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
report_changed "wrote $(ecosystem_file) — ${#CORE_PROCESSES[@]} pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
|
||||
# Starting against a database that is not answering is not fatal — the server
|
||||
# waits and the agent retries forever — but it makes the Verify section below
|
||||
@@ -792,13 +792,13 @@ if ! skip; then
|
||||
if pm2_enable_startup; then
|
||||
ok "pm2 will resurrect them at boot"
|
||||
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
|
||||
SUMMARY+=("Services: 6 processes started, enabled at boot")
|
||||
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started, enabled at boot")
|
||||
else
|
||||
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
||||
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
|
||||
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; boot hook NOT enabled")
|
||||
fi
|
||||
else
|
||||
SUMMARY+=("Services: 6 processes started; not enabled at boot")
|
||||
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; not enabled at boot")
|
||||
fi
|
||||
else
|
||||
warn "pm2 did not start cleanly"
|
||||
@@ -945,7 +945,7 @@ fi
|
||||
#
|
||||
# Root, and that surprises people — reasonably, because everything this script just
|
||||
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
|
||||
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
|
||||
# node_modules, .env, the secret store and every pm2 process are theirs. Root was
|
||||
# the installer's privilege, never the platform's.
|
||||
#
|
||||
# Saying so matters for two things that are invisible until they bite:
|
||||
|
||||
@@ -59,7 +59,7 @@ tool_why() {
|
||||
case "$1" in
|
||||
git) echo "to clone and update the platform" ;;
|
||||
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
||||
bun) echo "the platform itself and nineteen of the twenty processes" ;;
|
||||
bun) echo "the platform itself and every process except the pty sidecar" ;;
|
||||
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
||||
docker) echo "Postgres, and anything the app store provisions" ;;
|
||||
*) echo "" ;;
|
||||
|
||||
@@ -106,6 +106,8 @@ export function App() {
|
||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Bug } from 'lucide-react';
|
||||
import { useBugReport } from './use-bug-report';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { BugReportDialog } from './BugReportDialog';
|
||||
|
||||
export const BugReportButton = () => {
|
||||
const bugReport = useBugReport();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={bugReport.handleOpen}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
|
||||
aria-label="Report a bug"
|
||||
aria-label={t('bugReport.label')}
|
||||
>
|
||||
<Bug size={16} />
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type BugReportDialogProps = {
|
||||
open: boolean;
|
||||
@@ -21,6 +22,7 @@ export const BugReportDialog = ({
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: BugReportDialogProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
||||
@@ -36,7 +38,7 @@ export const BugReportDialog = ({
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report a Bug</DialogTitle>
|
||||
<DialogTitle>{t('bugReport.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -46,34 +48,32 @@ export const BugReportDialog = ({
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
|
||||
<img src={previewUrl} alt={t('bugReport.screenshot')} className="max-h-48 w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||
No screenshot captured
|
||||
{t('bugReport.noScreenshot')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Describe what went wrong..."
|
||||
placeholder={t('bugReport.describePlaceholder')}
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Console logs, browser info, and recent API errors are collected automatically.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('bugReport.collectedNote')}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Submit
|
||||
{submitting ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t('bugReport.submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { ErrorDetails } from 'hooks/useClient';
|
||||
import { getConsoleBuffer } from '@/lib/console-buffer';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const useBugReport = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -14,6 +15,7 @@ export const useBugReport = () => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const client = useClient();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
@@ -57,15 +59,15 @@ export const useBugReport = () => {
|
||||
}
|
||||
|
||||
await client.post('/bug-report', formData);
|
||||
toast.success('Bug report submitted');
|
||||
toast.success(t('bugReport.submitted'));
|
||||
handleClose();
|
||||
} catch {
|
||||
toast.error('Failed to submit bug report');
|
||||
toast.error(t('bugReport.failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[screenshot, client, user, apiError, handleClose],
|
||||
[screenshot, client, user, apiError, handleClose, t],
|
||||
);
|
||||
|
||||
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
|
||||
import { Dock, useDockItems, defaultDockPaths } from './Dock';
|
||||
import { Spotlight } from './Spotlight/Spotlight';
|
||||
import { ShortcutsModal } from './ShortcutsModal';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
@@ -24,10 +24,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
// will then slam is worse than one that never showed it.
|
||||
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
|
||||
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
|
||||
const permitted = useMemo(
|
||||
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
|
||||
[canVisit, plugins],
|
||||
);
|
||||
const allDockItems = useDockItems(plugins);
|
||||
const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
|
||||
const { items: visibleItems } = useDock(
|
||||
permitted,
|
||||
useMemo(() => defaultDockPaths(plugins), [plugins]),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { resolveIcon } from 'officerdev';
|
||||
import type { PluginManifest } from 'hooks/usePermissions';
|
||||
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||
|
||||
export type DockItem = {
|
||||
/** Already resolved for the active locale — see `useDockItems`. */
|
||||
label: string;
|
||||
to: string;
|
||||
color: string;
|
||||
@@ -155,6 +157,7 @@ import {
|
||||
Store,
|
||||
Puzzle,
|
||||
Radar,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -168,18 +171,22 @@ import {
|
||||
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
|
||||
* the light profile because it fronts a remote instance and installs nothing locally.
|
||||
*/
|
||||
export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
||||
{ labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
||||
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
// Core because it is the door into another account's filesystem, and the permission that gates it is
|
||||
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
|
||||
// every other grantable tile here gets.
|
||||
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
|
||||
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
|
||||
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
|
||||
// while the dock and the settings list went silent about them, which is exactly the shape of failure
|
||||
@@ -189,16 +196,16 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
|
||||
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
|
||||
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
|
||||
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
||||
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
||||
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
||||
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
||||
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
||||
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
||||
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||
// things that disappears when uninstalled.
|
||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
{ labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
||||
{ label: 'Marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
|
||||
{ labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -208,6 +215,30 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
|
||||
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
|
||||
*/
|
||||
/**
|
||||
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
|
||||
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
|
||||
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
|
||||
*/
|
||||
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
|
||||
|
||||
/**
|
||||
* The composed dock, with the shell's own labels in the active locale.
|
||||
*
|
||||
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
|
||||
* spreading the same two sources, and each would otherwise have to resolve labels itself.
|
||||
*/
|
||||
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(
|
||||
() => [
|
||||
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
|
||||
...dockItemsFromPlugins(plugins),
|
||||
],
|
||||
[t, plugins],
|
||||
);
|
||||
}
|
||||
|
||||
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
||||
return plugins.flatMap((plugin) => {
|
||||
const tile = (t: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RescanButton } from '../Rescan/RescanButton';
|
||||
import { usePageTitle } from '@/state/usePageTitle';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||
|
||||
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
||||
@@ -20,14 +21,15 @@ import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
||||
// the field hand the tab back to the route's own name.
|
||||
function EditablePageTitle() {
|
||||
const [title, rename] = usePageTitle();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<EditableTitle
|
||||
value={title}
|
||||
onCommit={rename}
|
||||
allowEmpty
|
||||
ariaLabel="Tab name"
|
||||
hint="Click to rename this tab — clear it to go back to the page name"
|
||||
ariaLabel={t('header.tabName')}
|
||||
hint={t('header.tabNameHint')}
|
||||
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
|
||||
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
|
||||
/>
|
||||
@@ -48,6 +50,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isTouch = useIsTouch();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
|
||||
const logo = useThemeAsset('--logo-src');
|
||||
|
||||
@@ -81,7 +84,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
||||
*/}
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Officer"
|
||||
aria-label={t('header.logoAlt')}
|
||||
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
|
||||
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
|
||||
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
|
||||
@@ -119,7 +122,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetTitle>{t('header.navigation')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1 mt-4 px-2">
|
||||
{dockItems.map((item) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
|
||||
import { Loader2, ListOrdered } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||
|
||||
@@ -15,6 +16,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||
export const JobsIndicator = () => {
|
||||
const client = useClient();
|
||||
const { can } = usePermissions();
|
||||
const { t } = useTranslation();
|
||||
const allowed = can('tasks');
|
||||
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
||||
|
||||
@@ -44,7 +46,7 @@ export const JobsIndicator = () => {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link
|
||||
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
|
||||
title={counts.running ? 'Running job' : 'Nothing running'}
|
||||
title={counts.running ? t('header.jobsRunning') : t('header.jobsNothingRunning')}
|
||||
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
|
||||
>
|
||||
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
|
||||
@@ -52,7 +54,7 @@ export const JobsIndicator = () => {
|
||||
</Link>
|
||||
<Link
|
||||
to="/jobs"
|
||||
title="Queued jobs"
|
||||
title={t('header.jobsQueued')}
|
||||
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
|
||||
>
|
||||
<ListOrdered className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
||||
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const {
|
||||
DropdownMenu,
|
||||
@@ -32,6 +33,7 @@ const {
|
||||
*/
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, themeId, setTheme, themes } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
||||
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
||||
@@ -50,7 +52,7 @@ export const ThemeSwitcher = () => {
|
||||
*/
|
||||
const ordered = useMemo(() => {
|
||||
const rotated = rotateToSelected(themes, themeId);
|
||||
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
|
||||
const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
||||
return favouritesFirst(listed, favorites);
|
||||
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
@@ -62,10 +64,11 @@ export const ThemeSwitcher = () => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
|
||||
isFavorite(themeId) ? 'unstar' : 'star'
|
||||
}`}
|
||||
aria-label={`Theme: ${theme.name}`}
|
||||
title={t('theme.pillTitle', {
|
||||
name: theme.name,
|
||||
starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
|
||||
})}
|
||||
aria-label={t('theme.pillLabel', { name: theme.name })}
|
||||
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
||||
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
||||
// the cluster stays one shape.
|
||||
@@ -89,8 +92,8 @@ export const ThemeSwitcher = () => {
|
||||
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
||||
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
||||
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
||||
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
|
||||
<span className="block">ctrl+? for every shortcut</span>
|
||||
<span className="block">{t('theme.cycleHint')}</span>
|
||||
<span className="block">{t('theme.everyShortcutHint')}</span>
|
||||
</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
@@ -105,35 +108,41 @@ export const ThemeSwitcher = () => {
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
|
||||
{onlyFavorites
|
||||
? t('theme.favouritesOnly', { count: favorites.size })
|
||||
: t('theme.favourites', { count: favorites.size })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{ordered.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
No favourites yet — star one with the button on its row.
|
||||
</p>
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
|
||||
) : (
|
||||
families.map(([family, entries]) => (
|
||||
<div key={family}>
|
||||
{entries.map((t) => (
|
||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
|
||||
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{entries.map((entry) => (
|
||||
<DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
|
||||
aria-label={
|
||||
isFavorite(entry.id)
|
||||
? t('theme.unstarNamed', { name: entry.name })
|
||||
: t('theme.starNamed', { name: entry.name })
|
||||
}
|
||||
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
toggle(entry.id);
|
||||
}}
|
||||
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
|
||||
<Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -165,22 +174,24 @@ export const ThemeSwitcher = () => {
|
||||
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
||||
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
||||
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
||||
{ordered.map((t) => {
|
||||
const isFocused = t.id === focused;
|
||||
{ordered.map((entry) => {
|
||||
const isFocused = entry.id === focused;
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
onMouseEnter={() => setFocused(t.id)}
|
||||
onMouseEnter={() => setFocused(entry.id)}
|
||||
onClick={() => {
|
||||
setTheme(t.id);
|
||||
setTheme(entry.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
{entry.id === themeId && (
|
||||
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
|
||||
)}
|
||||
{/*
|
||||
A span, not a button: this sits inside the row's own <button>, and a button inside
|
||||
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
||||
@@ -190,18 +201,24 @@ export const ThemeSwitcher = () => {
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
|
||||
aria-label={
|
||||
isFavorite(entry.id)
|
||||
? t('theme.unstarNamed', { name: entry.name })
|
||||
: t('theme.starNamed', { name: entry.name })
|
||||
}
|
||||
title={
|
||||
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
|
||||
}
|
||||
onClick={(ev) => {
|
||||
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||
// the opposite of what starring is for.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
toggle(entry.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
|
||||
<Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -210,9 +227,7 @@ export const ThemeSwitcher = () => {
|
||||
</ul>
|
||||
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
||||
header does. */}
|
||||
<p className="mt-6 text-center text-xs opacity-60">
|
||||
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
|
||||
</p>
|
||||
<p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -27,13 +27,13 @@ export function UserMenu() {
|
||||
<DropdownMenuContent align="end" className="z-10 w-48">
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<User className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.profile')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/system">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<Settings className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.systemSettings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -42,14 +42,14 @@ export function UserMenu() {
|
||||
(navigation-audit M8.) */}
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/ai">
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
<Bot className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.ai')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/integrations">
|
||||
<Puzzle className="mr-2 h-4 w-4" />
|
||||
Integrations
|
||||
<Puzzle className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.integrations')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* Owner only. This is tidiness, not access control — the route stays reachable and its
|
||||
@@ -58,15 +58,15 @@ export function UserMenu() {
|
||||
{user?.role === 'Super Admin' && (
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/user-management">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
User Management
|
||||
<UserCog className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.userManagement')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/auth/signout">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<LogOut className="me-2 h-4 w-4" />
|
||||
{t('header.userMenu.signOut')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -4,9 +4,29 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||
|
||||
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
||||
|
||||
/**
|
||||
* One key per counted noun rather than one sentence with six numbers in it.
|
||||
*
|
||||
* Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with
|
||||
* more than two categories needs it for every value. Assembling the sentence in code and translating only
|
||||
* the frame would have hardcoded English plural rules into the frame.
|
||||
*
|
||||
* Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats
|
||||
* the type checker and cannot be grepped (see the key naming convention in docs/localization.md).
|
||||
*/
|
||||
const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [
|
||||
['tasks', 'rescan.counts.tasks'],
|
||||
['agents', 'rescan.counts.agents'],
|
||||
['tools', 'rescan.counts.tools'],
|
||||
['skills', 'rescan.counts.skills'],
|
||||
['processes', 'rescan.counts.processes'],
|
||||
['affordances', 'rescan.counts.affordances'],
|
||||
];
|
||||
|
||||
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the
|
||||
// Automation and Capability pages).
|
||||
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
|
||||
@@ -15,6 +35,7 @@ export function RescanButton() {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
|
||||
@@ -32,32 +53,28 @@ export function RescanButton() {
|
||||
// you next open that page. This refreshes every item cache immediately, wherever you press it.
|
||||
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
|
||||
const c = res.counts ?? {};
|
||||
toast.success(
|
||||
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`,
|
||||
{
|
||||
// Two things said here rather than left to be discovered.
|
||||
//
|
||||
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
|
||||
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
|
||||
// agent, because the loader skips it: a line that does not say when to reach for something
|
||||
// costs context in every conversation and earns nothing. So a skill can be present, listed and
|
||||
// editable while being invisible to every agent, and the only symptom is two numbers
|
||||
// disagreeing where nobody is comparing them.
|
||||
description:
|
||||
[
|
||||
c.affordances || c.skillsOffered
|
||||
? 'Applies to new chats — an open one keeps what it started with.'
|
||||
: null,
|
||||
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
|
||||
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined,
|
||||
},
|
||||
);
|
||||
const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', ');
|
||||
toast.success(t('rescan.done', { summary }), {
|
||||
// Two things said here rather than left to be discovered.
|
||||
//
|
||||
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
|
||||
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
|
||||
// agent, because the loader skips it: a line that does not say when to reach for something
|
||||
// costs context in every conversation and earns nothing. So a skill can be present, listed and
|
||||
// editable while being invisible to every agent, and the only symptom is two numbers
|
||||
// disagreeing where nobody is comparing them.
|
||||
description:
|
||||
[
|
||||
c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null,
|
||||
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
|
||||
? t('rescan.noDescription', { count: c.skills - c.skillsOffered })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to rescan items');
|
||||
toast.error(t('rescan.failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -68,8 +85,8 @@ export function RescanButton() {
|
||||
onClick={rescan}
|
||||
disabled={loading}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
|
||||
aria-label="Rescan items"
|
||||
title="Rescan officer-items (apply changes without restarting)"
|
||||
aria-label={t('rescan.label')}
|
||||
title={t('rescan.title')}
|
||||
>
|
||||
<RotateCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
||||
|
||||
@@ -18,6 +19,7 @@ type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
||||
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
@@ -46,36 +48,34 @@ export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps)
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold">This screen failed to render</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
|
||||
untouched.
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold">{t('screenError.title')}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t('screenError.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="bg-muted/40 rounded-md border p-3 text-sm">
|
||||
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary>
|
||||
<summary className="cursor-pointer select-none font-medium">
|
||||
{error.message || t('screenError.unknownError')}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>Try again</Button>
|
||||
<Button onClick={reset}>{t('screenError.tryAgain')}</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.reload()}>
|
||||
Reload
|
||||
{t('screenError.reload')}
|
||||
</Button>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to="/">Go home</Link>
|
||||
<Link to="/">{t('screenError.goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's
|
||||
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
|
||||
{t('screenError.resetHint')} <strong>{t('screenError.dashboardsSafe')}</strong>
|
||||
</p>
|
||||
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
|
||||
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'}
|
||||
{resetting ? t('screenError.resetting') : confirming ? t('screenError.resetConfirm') : t('screenError.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
/*
|
||||
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
||||
@@ -27,6 +28,7 @@ const Row = ({ keys, description }: { keys: string; description: string }) => (
|
||||
|
||||
export const ShortcutsModal = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
@@ -57,8 +59,8 @@ export const ShortcutsModal = () => {
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
|
||||
<h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
|
||||
<span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
|
||||
</div>
|
||||
|
||||
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
|
||||
import { Search } from 'lucide-react';
|
||||
import { rank } from './match';
|
||||
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// Spotlight: type a few letters, go somewhere.
|
||||
//
|
||||
@@ -181,6 +182,7 @@ export const Spotlight = () => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const items = useSpotlightItems();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const results = useMemo(() => rank(items, query), [items, query]);
|
||||
|
||||
@@ -287,14 +289,14 @@ export const Spotlight = () => {
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
placeholder="Go to…"
|
||||
placeholder={t('spotlight.placeholder')}
|
||||
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-2">
|
||||
{results.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">{t('spotlight.noResults', { query })}</p>
|
||||
) : (
|
||||
results.map((result, i) => (
|
||||
<Row
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
|
||||
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||
import { useDockItems } from '../Dock';
|
||||
|
||||
// What Spotlight can take you to.
|
||||
//
|
||||
@@ -40,6 +41,7 @@ import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
|
||||
export type SpotlightItem = {
|
||||
/** Stable across renders — used as the React key and for the recency store. */
|
||||
id: string;
|
||||
/** Resolved for the active locale, because `rank()` matches what the reader can actually see. */
|
||||
label: string;
|
||||
/** Where it goes. Matched against `canVisit` for anything not already filtered. */
|
||||
to: string;
|
||||
@@ -78,64 +80,137 @@ export type SpotlightItem = {
|
||||
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
|
||||
* does not say where it lives.
|
||||
*/
|
||||
const SETTINGS_ITEMS: SpotlightItem[] = [
|
||||
type SettingsItem = Omit<SpotlightItem, 'label' | 'hint'> & {
|
||||
labelKey: AppTranslationKey;
|
||||
hintKey?: AppTranslationKey;
|
||||
};
|
||||
|
||||
const SETTINGS_ITEMS: SettingsItem[] = [
|
||||
// Profile — ProfileSettings/index.tsx
|
||||
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
|
||||
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock },
|
||||
{
|
||||
id: 'set:profile',
|
||||
labelKey: 'settings.sections.profile.label',
|
||||
to: '/settings/profile',
|
||||
hintKey: 'settings.sections.profile.hint',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
id: 'set:password',
|
||||
labelKey: 'settings.sections.changePassword.label',
|
||||
to: '/settings/profile/change-password',
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
id: 'set:api-keys',
|
||||
label: 'API keys',
|
||||
labelKey: 'settings.sections.apiKeys.label',
|
||||
to: '/settings/profile/api-keys',
|
||||
hint: 'keys that sign in as you',
|
||||
hintKey: 'settings.sections.apiKeys.hint',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
id: 'set:languages',
|
||||
label: 'Languages',
|
||||
labelKey: 'settings.sections.languages.label',
|
||||
to: '/settings/profile/languages',
|
||||
hint: 'spoken and translation',
|
||||
hintKey: 'settings.sections.languages.hint',
|
||||
icon: Globe,
|
||||
},
|
||||
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 },
|
||||
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid },
|
||||
{
|
||||
id: 'set:voice',
|
||||
labelKey: 'settings.sections.voice.label',
|
||||
to: '/settings/profile/voice',
|
||||
hintKey: 'settings.sections.voice.hint',
|
||||
icon: Volume2,
|
||||
},
|
||||
{
|
||||
id: 'set:dock',
|
||||
labelKey: 'settings.sections.dock.label',
|
||||
to: '/settings/profile/dock',
|
||||
hintKey: 'settings.sections.dock.hint',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
// AI — AISettings.tsx
|
||||
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles },
|
||||
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles },
|
||||
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles },
|
||||
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot },
|
||||
{
|
||||
id: 'set:ai-my-models',
|
||||
labelKey: 'settings.sections.myModels.label',
|
||||
to: '/settings/ai/my-models',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'set:ai-channel-models',
|
||||
labelKey: 'settings.sections.channelModels.label',
|
||||
to: '/settings/ai/member-models',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'set:ai-default-models',
|
||||
labelKey: 'settings.sections.defaultModels.label',
|
||||
to: '/settings/ai/default-models',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'set:ai-harnesses',
|
||||
labelKey: 'settings.sections.providers.label',
|
||||
to: '/settings/ai/ai-harnesses',
|
||||
hintKey: 'settings.sections.providers.hint',
|
||||
icon: Bot,
|
||||
},
|
||||
// System — SystemSettings.tsx
|
||||
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 },
|
||||
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic },
|
||||
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText },
|
||||
{ id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
|
||||
{ id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
|
||||
{ id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
|
||||
{
|
||||
id: 'set:maintenance',
|
||||
label: 'Rebuild & restart',
|
||||
labelKey: 'settings.sections.maintenance.label',
|
||||
to: '/settings/system/maintenance',
|
||||
hint: 'maintenance',
|
||||
hintKey: 'settings.sections.maintenance.hint',
|
||||
icon: Cpu,
|
||||
},
|
||||
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail },
|
||||
{
|
||||
id: 'set:smtp',
|
||||
labelKey: 'settings.sections.smtp.label',
|
||||
to: '/settings/system/smtp',
|
||||
hintKey: 'settings.sections.smtp.hint',
|
||||
icon: Mail,
|
||||
},
|
||||
// Integrations — IntegrationsSettings
|
||||
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound },
|
||||
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench },
|
||||
{
|
||||
id: 'set:google-oauth',
|
||||
labelKey: 'settings.sections.googleOauth.label',
|
||||
to: '/settings/integrations/google-oauth',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
|
||||
{
|
||||
id: 'set:dav',
|
||||
label: 'Calendar & Contacts sync',
|
||||
labelKey: 'settings.sections.davSync.label',
|
||||
to: '/settings/integrations/dav-app-passwords',
|
||||
hint: 'app passwords',
|
||||
hintKey: 'settings.sections.davSync.hint',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
// Users — UserManagement
|
||||
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users },
|
||||
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck },
|
||||
{
|
||||
id: 'set:users',
|
||||
labelKey: 'settings.sections.accounts.label',
|
||||
to: '/settings/user-management/users',
|
||||
hintKey: 'settings.sections.accounts.hint',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'set:permissions',
|
||||
labelKey: 'settings.sections.permissions.label',
|
||||
to: '/settings/user-management/permissions',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
];
|
||||
|
||||
/** Everything Spotlight can offer this account, in a stable order. */
|
||||
export function useSpotlightItems(): SpotlightItem[] {
|
||||
const { canVisit, plugins } = usePermissions();
|
||||
const { t } = useTranslation();
|
||||
const dockItems = useDockItems(plugins);
|
||||
|
||||
return useMemo(() => {
|
||||
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)]
|
||||
const fromDock: SpotlightItem[] = dockItems
|
||||
.filter((item) => canVisit(item.to))
|
||||
.map((item) => ({
|
||||
id: `nav:${item.to}`,
|
||||
@@ -149,8 +224,14 @@ export function useSpotlightItems(): SpotlightItem[] {
|
||||
|
||||
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
|
||||
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
|
||||
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to));
|
||||
const settings: SpotlightItem[] = SETTINGS_ITEMS.filter((item) => canVisit(item.to)).map(
|
||||
({ labelKey, hintKey, ...item }) => ({
|
||||
...item,
|
||||
label: t(labelKey),
|
||||
...(hintKey ? { hint: t(hintKey) } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
return [...fromDock, ...settings];
|
||||
}, [canVisit, plugins]);
|
||||
}, [canVisit, dockItems, t]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /minesweeper — Minesweeper X running under wine on a virtual display, streamed over RFB.
|
||||
//
|
||||
// The pixels arrive through /api/minesweeper/ws, which is a byte relay onto x11vnc's own TCP socket on
|
||||
// loopback — the same shape as the terminal's relay onto the pty sidecar, and the reason this works at
|
||||
// all where `plugins/desktop` could not: a websocket provider is a platform concern, and a plugin
|
||||
// cannot claim one.
|
||||
//
|
||||
// Owner-only, and not because a game is dangerous: wine runs as the server owner, so the game's menus
|
||||
// are file dialogs with the owner's home behind them.
|
||||
|
||||
export const MinesweeperScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/minesweeper', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['minesweeper'], fallback: 'minesweeper' }} />;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a
|
||||
// second panel of empty space.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'minesweeper',
|
||||
appType: 'minesweeper',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './MinesweeperScreen';
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
|
||||
//
|
||||
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
|
||||
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
|
||||
// one that already works, kept in sync by hand.
|
||||
|
||||
export const SendOffScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
|
||||
// which is the file browser.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'sendoff',
|
||||
appType: 'sendoff',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SendOffScreen';
|
||||
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import type { UserSettings } from 'state/useSettings';
|
||||
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useAccessPolicy } from 'state/useAccessPolicy';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
||||
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
// --- My Models Section (per-user hidden models) ---
|
||||
|
||||
function MyModelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const visibleModels = useModels();
|
||||
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
|
||||
@@ -174,11 +176,7 @@ function MyModelsSection() {
|
||||
}, []);
|
||||
|
||||
if (providerGroups.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No models available. Configure API keys in Providers.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -199,9 +197,9 @@ function MyModelsSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DropZone label="Visible" onDrop={showModel}>
|
||||
<DropZone label={t('settings.ai.visible')} onDrop={showModel}>
|
||||
{shown.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
|
||||
)}
|
||||
{shown.map((m) => (
|
||||
<ModelPill
|
||||
@@ -215,9 +213,9 @@ function MyModelsSection() {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Hidden" onDrop={hideModel}>
|
||||
<DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
|
||||
{hidden.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
|
||||
)}
|
||||
{hidden.map((m) => (
|
||||
<ModelPill
|
||||
@@ -237,6 +235,7 @@ function MyModelsSection() {
|
||||
// --- Member Models Section (system-wide policy, admin only) ---
|
||||
|
||||
function MemberModelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { policy, savePolicy } = useAccessPolicy();
|
||||
@@ -249,9 +248,9 @@ function MemberModelsSection() {
|
||||
try {
|
||||
await client.post('/server-settings/chat-providers/local-providers/refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
|
||||
toast.success('Models refreshed');
|
||||
toast.success(t('settings.ai.refreshed'));
|
||||
} catch {
|
||||
toast.error('Failed to refresh models');
|
||||
toast.error(t('settings.ai.refreshFailed'));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -325,11 +324,7 @@ function MemberModelsSection() {
|
||||
}, []);
|
||||
|
||||
if (providerGroups.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No models available. Configure API keys in Providers.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -353,15 +348,17 @@ function MemberModelsSection() {
|
||||
onClick={refreshModels}
|
||||
disabled={refreshing}
|
||||
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
|
||||
title="Refresh models from providers"
|
||||
title={t('settings.ai.refresh')}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DropZone label="Enabled" onDrop={enableModel}>
|
||||
<DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
|
||||
{enabled.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
|
||||
{t('settings.ai.dragToEnable')}
|
||||
</span>
|
||||
)}
|
||||
{enabled.map((m) => (
|
||||
<ModelPill
|
||||
@@ -375,9 +372,9 @@ function MemberModelsSection() {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Disabled" onDrop={disableModel}>
|
||||
<DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
|
||||
{disabled.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
|
||||
)}
|
||||
{disabled.map((m) => (
|
||||
<ModelPill
|
||||
@@ -410,49 +407,49 @@ function MemberModelsSection() {
|
||||
function useAISettingsGroups(): SettingsSectionGroup[] {
|
||||
return useMemo(() => {
|
||||
const modelsGroup: SettingsSectionGroup = {
|
||||
label: 'Models',
|
||||
labelKey: 'settings.groups.models',
|
||||
icon: Eye,
|
||||
sections: [
|
||||
{
|
||||
key: 'my-models',
|
||||
icon: Eye,
|
||||
title: 'My Models',
|
||||
description: 'Show or hide models for yourself',
|
||||
titleKey: 'settings.sections.myModels.label',
|
||||
descriptionKey: 'settings.sections.myModels.description',
|
||||
content: <MyModelsSection />,
|
||||
},
|
||||
{
|
||||
key: 'member-models',
|
||||
icon: Eye,
|
||||
title: 'Channel Models',
|
||||
description: 'Models reachable by non-owner accounts',
|
||||
titleKey: 'settings.sections.channelModels.label',
|
||||
descriptionKey: 'settings.sections.channelModels.description',
|
||||
content: <MemberModelsSection />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultsGroup: SettingsSectionGroup = {
|
||||
label: 'Defaults',
|
||||
labelKey: 'settings.groups.defaults',
|
||||
icon: Sparkles,
|
||||
sections: [
|
||||
{
|
||||
key: 'default-models',
|
||||
icon: Sparkles,
|
||||
title: 'Default Models',
|
||||
description: 'Default models for chat, projects, and tasks',
|
||||
titleKey: 'settings.sections.defaultModels.label',
|
||||
descriptionKey: 'settings.sections.defaultModels.description',
|
||||
content: <AIModels />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const providersGroup: SettingsSectionGroup = {
|
||||
label: 'Providers',
|
||||
labelKey: 'settings.groups.providers',
|
||||
icon: Terminal,
|
||||
sections: [
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'Providers',
|
||||
description: 'Remote and local AI providers',
|
||||
titleKey: 'settings.sections.providers.label',
|
||||
descriptionKey: 'settings.sections.providers.description',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
],
|
||||
@@ -482,7 +479,7 @@ export const AISettings = () => {
|
||||
createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: Bot,
|
||||
sidebarLabel: 'AI',
|
||||
sidebarLabelKey: 'settings.nav.ai',
|
||||
groups,
|
||||
}),
|
||||
[groups],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
|
||||
import { ApifyConfig } from './ApifyConfig';
|
||||
import { DavAppPasswords } from './DavAppPasswords';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const BASE_PATH = '/settings/integrations';
|
||||
|
||||
@@ -17,15 +18,15 @@ const enterpriseSections: SettingsSection[] = [
|
||||
{
|
||||
key: 'google-oauth',
|
||||
icon: KeyRound,
|
||||
title: 'Google OAuth',
|
||||
description: 'Client ID and secret for Google APIs',
|
||||
titleKey: 'settings.sections.googleOauth.label',
|
||||
descriptionKey: 'settings.sections.googleOauth.description',
|
||||
content: <GoogleOAuthConfig />,
|
||||
},
|
||||
{
|
||||
key: 'apify',
|
||||
icon: Wrench,
|
||||
title: 'Apify',
|
||||
description: 'API token for web scraping actors',
|
||||
titleKey: 'settings.sections.apify.label',
|
||||
descriptionKey: 'settings.sections.apify.description',
|
||||
content: <ApifyConfig />,
|
||||
},
|
||||
];
|
||||
@@ -39,14 +40,14 @@ const personalSections: SettingsSection[] = [
|
||||
// key: 'google-account',
|
||||
// icon: UserCircle,
|
||||
// title: 'Google Account',
|
||||
// description: 'Connect your Google account',
|
||||
// descriptionKey: 'settings.sections.apify.description',
|
||||
// content: <GoogleAccount />,
|
||||
// },
|
||||
{
|
||||
key: 'dav-app-passwords',
|
||||
icon: CalendarDays,
|
||||
title: 'Calendar & Contacts sync',
|
||||
description: 'Per-device passwords for CalDAV/CardDAV clients',
|
||||
titleKey: 'settings.sections.davSync.label',
|
||||
descriptionKey: 'settings.sections.davSync.description',
|
||||
content: <DavAppPasswords />,
|
||||
},
|
||||
];
|
||||
@@ -60,6 +61,7 @@ const tabForSection = (section: string | undefined) =>
|
||||
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
|
||||
|
||||
const IntegrationsSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { section } = useParams<{ section?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const tab = tabForSection(section);
|
||||
@@ -77,22 +79,28 @@ const IntegrationsSidebar = () => {
|
||||
<div className="p-3 pb-2">
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||
<Puzzle className="h-4 w-4" />
|
||||
Integrations
|
||||
{t('settings.nav.integrations')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pb-2">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
|
||||
Enterprise
|
||||
{t('settings.integrations.enterprise')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
|
||||
Personal
|
||||
{t('settings.integrations.personal')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
|
||||
<SettingsSidebar
|
||||
basePath={BASE_PATH}
|
||||
icon={Puzzle}
|
||||
labelKey="settings.nav.integrations"
|
||||
sections={sections}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
function buildGroups(models: ModelOption[]) {
|
||||
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||
@@ -29,6 +30,7 @@ function buildGroups(models: ModelOption[]) {
|
||||
const NONE = '__none__';
|
||||
|
||||
export const AIModels = () => {
|
||||
const { t } = useTranslation();
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const piModels = useUserVisibleModels();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -54,9 +56,9 @@ export const AIModels = () => {
|
||||
chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel },
|
||||
tasks: { ...settings.tasks, defaultModel: taskModel },
|
||||
});
|
||||
toast.success('AI model defaults saved');
|
||||
toast.success(t('settings.profile.models.aiDefaultsSaved'));
|
||||
} catch {
|
||||
toast.error('Failed to save settings');
|
||||
toast.error(t('settings.profile.models.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -86,25 +88,25 @@ export const AIModels = () => {
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultChat')}</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat from the home screen
|
||||
{t('settings.profile.models.defaultChatHint')}
|
||||
</p>
|
||||
{renderModelSelect(chatModel, setChatModel, 'System default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultProject')}</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat inside a project dashboard
|
||||
{t('settings.profile.models.defaultProjectHint')}
|
||||
</p>
|
||||
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultTask')}</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when running tasks from the file browser
|
||||
{t('settings.profile.models.defaultTaskHint')}
|
||||
</p>
|
||||
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useFormat } from 'i18n';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -36,19 +38,22 @@ type Minted = { key: string; name: string };
|
||||
|
||||
const API_KEYS_QUERY_KEY = ['API_KEYS'];
|
||||
|
||||
const formatDate = (value: string | null) =>
|
||||
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
||||
|
||||
const copy = async (text: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast.success('Key copied');
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
}
|
||||
};
|
||||
// `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the
|
||||
// browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7).
|
||||
|
||||
export const ApiKeys = () => {
|
||||
const { t } = useTranslation();
|
||||
const { date: formatDate } = useFormat();
|
||||
|
||||
const copy = async (text: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast.success(t('settings.profile.apiKeys.copied'));
|
||||
} catch {
|
||||
toast.error(t('settings.profile.apiKeys.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
@@ -74,7 +79,7 @@ export const ApiKeys = () => {
|
||||
setName('');
|
||||
await refresh();
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not create the key');
|
||||
toast.error(ex instanceof Error ? ex.message : t('settings.profile.apiKeys.createFailed'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -85,17 +90,17 @@ export const ApiKeys = () => {
|
||||
await client.delete(`/api-keys/${id}/record`);
|
||||
await refresh();
|
||||
} catch {
|
||||
toast.error('Could not remove it');
|
||||
toast.error(t('settings.profile.apiKeys.forgetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: number) => {
|
||||
try {
|
||||
await client.delete(`/api-keys/${id}`);
|
||||
toast.success('Revoked — anything using that key is signed out');
|
||||
toast.success(t('settings.profile.apiKeys.revokeDone'));
|
||||
await refresh();
|
||||
} catch {
|
||||
toast.error('Could not revoke');
|
||||
toast.error(t('settings.profile.apiKeys.revokeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,7 +121,7 @@ export const ApiKeys = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.profile.apiKeys.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,15 +129,13 @@ export const ApiKeys = () => {
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not
|
||||
tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '}
|
||||
<code className="font-mono text-xs">Authorization: Bearer …</code>.
|
||||
{t('settings.profile.apiKeys.intro')} <code className="font-mono text-xs">Authorization: Bearer …</code>.
|
||||
</p>
|
||||
|
||||
{minted && (
|
||||
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
|
||||
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
Key for “{minted.name}” — shown once
|
||||
{t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
|
||||
@@ -143,30 +146,35 @@ export const ApiKeys = () => {
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
||||
{minted.key}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copy(minted.key)}
|
||||
title={t('settings.profile.apiKeys.copyKey')}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
|
||||
I've saved it
|
||||
{t('settings.profile.apiKeys.savedIt')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={create} className="flex items-end gap-2">
|
||||
<Label className="grid flex-1 gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.apiKeys.newKey')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
placeholder="iPhone, Music app, laptop CLI…"
|
||||
placeholder={t('settings.profile.apiKeys.namePlaceholder')}
|
||||
/>
|
||||
</Label>
|
||||
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Create
|
||||
<Plus className="me-1.5 h-4 w-4" />
|
||||
{t('settings.profile.apiKeys.create')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
|
||||
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence
|
||||
that would send somebody looking for a bug in the list. */}
|
||||
{allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'}
|
||||
{allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{keys.map((entry) => {
|
||||
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
|
||||
const dead = !!entry.revokedAt || expired;
|
||||
const lastUsed = formatDate(entry.lastUsedAt);
|
||||
const lastUsed = entry.lastUsedAt
|
||||
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
@@ -195,14 +205,20 @@ export const ApiKeys = () => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-duck-dark dark:text-foreground">
|
||||
{entry.name}
|
||||
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>}
|
||||
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>}
|
||||
{entry.revokedAt && (
|
||||
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.revoked')}</span>
|
||||
)}
|
||||
{!entry.revokedAt && expired && (
|
||||
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.expired')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
<code className="font-mono">{entry.prefix}…</code>
|
||||
{' · '}
|
||||
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
|
||||
{lastUsed ? `last used ${lastUsed}` : 'never used'}
|
||||
{lastUsed
|
||||
? t('settings.profile.apiKeys.lastUsed', { date: lastUsed })
|
||||
: t('settings.profile.apiKeys.neverUsed')}
|
||||
</div>
|
||||
</div>
|
||||
{dead ? (
|
||||
@@ -212,7 +228,7 @@ export const ApiKeys = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void forget(entry.id)}
|
||||
title="Remove this record"
|
||||
title={t('settings.profile.apiKeys.forget')}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -222,7 +238,7 @@ export const ApiKeys = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void revoke(entry.id)}
|
||||
title="Revoke"
|
||||
title={t('settings.profile.apiKeys.revoke')}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
@@ -241,8 +257,8 @@ export const ApiKeys = () => {
|
||||
className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70"
|
||||
>
|
||||
{showRevoked
|
||||
? 'Hide revoked and expired'
|
||||
: `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`}
|
||||
? t('settings.profile.apiKeys.hideDead')
|
||||
: t('settings.profile.apiKeys.showDead', { count: deadKeys.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { X, Plus, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDock } from 'officerdev';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
|
||||
import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type DockPillProps = {
|
||||
label: string;
|
||||
@@ -104,10 +105,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
};
|
||||
|
||||
export const DockSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
|
||||
// pin a tile that cannot appear, which reads as the setting being broken.
|
||||
const { plugins } = usePermissions();
|
||||
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
|
||||
const allDockItems = useDockItems(plugins);
|
||||
const { items, allItems, setItems, reset } = useDock(
|
||||
allDockItems,
|
||||
useMemo(() => defaultDockPaths(plugins), [plugins]),
|
||||
@@ -193,10 +195,10 @@ export const DockSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<DropZone label="Visible" onDrop={onDropVisible}>
|
||||
<DropZone label={t('settings.profile.dock.visible')} onDrop={onDropVisible}>
|
||||
{items.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
|
||||
Drag items here to show in dock
|
||||
{t('settings.profile.dock.dragToShow')}
|
||||
</span>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
@@ -216,9 +218,11 @@ export const DockSettings = () => {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Hidden" onDrop={onDropHidden}>
|
||||
<DropZone label={t('settings.profile.dock.hidden')} onDrop={onDropHidden}>
|
||||
{hiddenItems.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
|
||||
{t('settings.profile.dock.allVisible')}
|
||||
</span>
|
||||
)}
|
||||
{hiddenItems.map((item) => (
|
||||
<DockPill
|
||||
@@ -235,7 +239,7 @@ export const DockSettings = () => {
|
||||
|
||||
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Reset to defaults
|
||||
{t('settings.profile.dock.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,45 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { SHIPPED_LOCALES } from '@/lib/locales';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'pt', label: 'Portuguese' },
|
||||
{ code: 'es', label: 'Spanish' },
|
||||
{ code: 'fr', label: 'French' },
|
||||
{ code: 'de', label: 'German' },
|
||||
{ code: 'it', label: 'Italian' },
|
||||
{ code: 'nl', label: 'Dutch' },
|
||||
{ code: 'ru', label: 'Russian' },
|
||||
{ code: 'zh', label: 'Chinese' },
|
||||
{ code: 'ja', label: 'Japanese' },
|
||||
{ code: 'ko', label: 'Korean' },
|
||||
{ code: 'ar', label: 'Arabic' },
|
||||
{ code: 'hi', label: 'Hindi' },
|
||||
{ code: 'tr', label: 'Turkish' },
|
||||
{ code: 'pl', label: 'Polish' },
|
||||
{ code: 'sv', label: 'Swedish' },
|
||||
{ code: 'da', label: 'Danish' },
|
||||
{ code: 'no', label: 'Norwegian' },
|
||||
{ code: 'fi', label: 'Finnish' },
|
||||
{ code: 'uk', label: 'Ukrainian' },
|
||||
{ code: 'cs', label: 'Czech' },
|
||||
{ code: 'ro', label: 'Romanian' },
|
||||
{ code: 'el', label: 'Greek' },
|
||||
{ code: 'he', label: 'Hebrew' },
|
||||
{ code: 'th', label: 'Thai' },
|
||||
{ code: 'vi', label: 'Vietnamese' },
|
||||
{ code: 'id', label: 'Indonesian' },
|
||||
{ code: 'ms', label: 'Malay' },
|
||||
];
|
||||
/**
|
||||
* Language names come from `Intl.DisplayNames`, not from the catalog.
|
||||
*
|
||||
* The runtime already knows every language's name in every locale — "German" in English, "alemão" in
|
||||
* Portuguese — so 28 names would have been 56 hand-written catalog entries that `Intl` gets right for
|
||||
* free, including for locales we have not shipped yet. Same reasoning as D7 for dates and numbers.
|
||||
*
|
||||
* The tags themselves stay a hardcoded list: this is "which languages may I claim to speak", a product
|
||||
* choice, not something to enumerate from the runtime.
|
||||
*/
|
||||
const SPOKEN_LANGUAGE_TAGS = [
|
||||
'en',
|
||||
'pt',
|
||||
'es',
|
||||
'fr',
|
||||
'de',
|
||||
'it',
|
||||
'nl',
|
||||
'ru',
|
||||
'zh',
|
||||
'ja',
|
||||
'ko',
|
||||
'ar',
|
||||
'hi',
|
||||
'tr',
|
||||
'pl',
|
||||
'sv',
|
||||
'da',
|
||||
'no',
|
||||
'fi',
|
||||
'uk',
|
||||
'cs',
|
||||
'ro',
|
||||
'el',
|
||||
'he',
|
||||
'th',
|
||||
'vi',
|
||||
'id',
|
||||
'ms',
|
||||
] as const;
|
||||
|
||||
const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code;
|
||||
/** `Intl` yields "alemão" in pt; a picker row reads better capitalized. */
|
||||
const useLanguageName = () => {
|
||||
const { locale } = useTranslation();
|
||||
return useMemo(() => {
|
||||
const names = new Intl.DisplayNames([locale], { type: 'language', fallback: 'code' });
|
||||
return (code: string) => {
|
||||
const name = names.of(code) ?? code;
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
}, [locale]);
|
||||
};
|
||||
|
||||
export const Languages = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const languageName = useLanguageName();
|
||||
const { spoken, default: defaultLang, translateTo } = settings.languages;
|
||||
const [addingLang, setAddingLang] = useState('');
|
||||
|
||||
@@ -56,12 +78,13 @@ export const Languages = () => {
|
||||
const removeSpoken = (code: string) => {
|
||||
const next = spoken.filter((s) => s !== code);
|
||||
const updates = { ...settings.languages, spoken: next };
|
||||
if (defaultLang === code) updates.default = next[0] ?? 'en';
|
||||
// `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
|
||||
// language never leaves it without a valid value.
|
||||
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
|
||||
save(updates);
|
||||
};
|
||||
|
||||
const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code));
|
||||
const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code));
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
@@ -74,7 +97,7 @@ export const Languages = () => {
|
||||
key={code}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium"
|
||||
>
|
||||
{getLabel(code)}
|
||||
{languageName(code)}
|
||||
{spoken.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeSpoken(code)}
|
||||
@@ -94,9 +117,9 @@ export const Languages = () => {
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
<option value="">{t('settings.profile.languages.addLanguage')}</option>
|
||||
{availableToAdd.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
{availableToAdd.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{languageName(code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -104,7 +127,7 @@ export const Languages = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default language */}
|
||||
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
|
||||
<select
|
||||
@@ -112,9 +135,9 @@ export const Languages = () => {
|
||||
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{spoken.map((code) => (
|
||||
{SHIPPED_LOCALES.map(({ code, label }) => (
|
||||
<option key={code} value={code}>
|
||||
{getLabel(code)}
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -129,9 +152,9 @@ export const Languages = () => {
|
||||
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
{SPOKEN_LANGUAGE_TAGS.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{languageName(code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useUserVisibleModels, type ModelOption } from 'state/useModels';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const TaskDefaults = () => {
|
||||
const { t } = useTranslation();
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const models = useUserVisibleModels();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -44,9 +46,9 @@ export const TaskDefaults = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } });
|
||||
toast.success('Task defaults saved');
|
||||
toast.success(t('settings.profile.models.taskDefaultsSaved'));
|
||||
} catch {
|
||||
toast.error('Failed to save settings');
|
||||
toast.error(t('settings.profile.models.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -55,10 +57,10 @@ export const TaskDefaults = () => {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Default Model</span>
|
||||
<span className="text-duck-dark/70">{t('settings.profile.models.defaultModel')}</span>
|
||||
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectValue placeholder="Same as chat default" />
|
||||
<SelectValue placeholder={t('settings.profile.models.sameAsChat')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
{modelGroups.map(({ provider, models }) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type ProfileFormState = {
|
||||
name?: string;
|
||||
@@ -24,6 +25,7 @@ const readFileAsBase64 = (file: File): Promise<string> => {
|
||||
};
|
||||
|
||||
export const UserData = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, updateUser } = useAuth();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
@@ -35,12 +37,12 @@ export const UserData = () => {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
toast.error(t('settings.profile.userData.selectImage'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_AVATAR_SIZE) {
|
||||
toast.error('Image must be smaller than 384KB');
|
||||
toast.error(t('settings.profile.userData.imageTooLarge'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,11 +60,11 @@ export const UserData = () => {
|
||||
name: profileForm.state.name ?? '',
|
||||
avatar: avatarPreview ?? user?.avatar ?? '',
|
||||
});
|
||||
toast.success('Profile updated');
|
||||
toast.success(t('settings.profile.userData.updated'));
|
||||
setAvatarPreview(null);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to update profile');
|
||||
toast.error(error.message || t('settings.profile.userData.updateFailed'));
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
@@ -93,7 +95,7 @@ export const UserData = () => {
|
||||
|
||||
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<span className="text-duck-dark/70">{t('settings.profile.userData.email')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
@@ -103,7 +105,7 @@ export const UserData = () => {
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Username</span>
|
||||
<span className="text-duck-dark/70">{t('settings.profile.userData.username')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
@@ -113,12 +115,12 @@ export const UserData = () => {
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<span className="text-duck-dark/70">{t('settings.profile.userData.name')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
placeholder={t('settings.profile.userData.namePlaceholder')}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Label>
|
||||
@@ -128,7 +130,7 @@ export const UserData = () => {
|
||||
disabled={isUpdating}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Save'}
|
||||
{isUpdating ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type VoiceGroup = { label: string; voices: string[] };
|
||||
|
||||
@@ -26,6 +27,7 @@ type TtsConfig = {
|
||||
const SERVER_DEFAULT = '__server_default__';
|
||||
|
||||
export const VoicePreference = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const [voices, setVoices] = useState<string[]>([]);
|
||||
@@ -128,13 +130,13 @@ export const VoicePreference = () => {
|
||||
<div className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.voice.label')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
title={t('settings.profile.voice.refresh')}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
|
||||
@@ -144,7 +146,7 @@ export const VoicePreference = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedValue} onValueChange={handleChange}>
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
||||
<SelectValue placeholder="Server default" />
|
||||
<SelectValue placeholder={t('settings.profile.voice.serverDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
<SelectItem value={SERVER_DEFAULT}>
|
||||
@@ -175,7 +177,7 @@ export const VoicePreference = () => {
|
||||
onClick={handleListen}
|
||||
disabled={listening === 'loading' || !serverConfig}
|
||||
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
|
||||
title={listening === 'playing' ? 'Stop' : 'Listen'}
|
||||
title={listening === 'playing' ? t('settings.profile.voice.stop') : t('settings.profile.voice.listen')}
|
||||
>
|
||||
{listening === 'loading' ? (
|
||||
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
|
||||
@@ -186,9 +188,7 @@ export const VoicePreference = () => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.profile.voice.hint')}</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,12 +14,18 @@ import { VoicePreference } from './VoicePreference';
|
||||
const BASE_PATH = '/settings/profile';
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
|
||||
{
|
||||
key: 'profile',
|
||||
icon: User,
|
||||
titleKey: 'settings.sections.profile.label',
|
||||
descriptionKey: 'settings.sections.profile.description',
|
||||
content: <UserData />,
|
||||
},
|
||||
{
|
||||
key: 'change-password',
|
||||
icon: Lock,
|
||||
title: 'Change Password',
|
||||
description: 'Update your password',
|
||||
titleKey: 'settings.sections.changePassword.label',
|
||||
descriptionKey: 'settings.sections.changePassword.description',
|
||||
content: <ChangePassword />,
|
||||
},
|
||||
// Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an
|
||||
@@ -29,29 +35,29 @@ const sections: SettingsSection[] = [
|
||||
{
|
||||
key: 'api-keys',
|
||||
icon: KeyRound,
|
||||
title: 'API keys',
|
||||
description: 'Keys that sign in as you, revocable one at a time',
|
||||
titleKey: 'settings.sections.apiKeys.label',
|
||||
descriptionKey: 'settings.sections.apiKeys.description',
|
||||
content: <ApiKeys />,
|
||||
},
|
||||
{
|
||||
key: 'languages',
|
||||
icon: Globe,
|
||||
title: 'Languages',
|
||||
description: 'Spoken, default, and translation',
|
||||
titleKey: 'settings.sections.languages.label',
|
||||
descriptionKey: 'settings.sections.languages.description',
|
||||
content: <Languages />,
|
||||
},
|
||||
{
|
||||
key: 'voice',
|
||||
icon: Volume2,
|
||||
title: 'Voice',
|
||||
description: 'Text-to-speech voice preference',
|
||||
titleKey: 'settings.sections.voice.label',
|
||||
descriptionKey: 'settings.sections.voice.description',
|
||||
content: <VoicePreference />,
|
||||
},
|
||||
{
|
||||
key: 'dock',
|
||||
icon: LayoutGrid,
|
||||
title: 'Dock',
|
||||
description: 'Choose and reorder dock items',
|
||||
titleKey: 'settings.sections.dock.label',
|
||||
descriptionKey: 'settings.sections.dock.description',
|
||||
content: <DockSettings />,
|
||||
},
|
||||
];
|
||||
@@ -59,7 +65,7 @@ const sections: SettingsSection[] = [
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: User,
|
||||
sidebarLabel: 'Profile',
|
||||
sidebarLabelKey: 'settings.nav.profile',
|
||||
sections,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,17 +3,24 @@ import { useState } from 'react';
|
||||
import { NavLink, Navigate, useParams } from 'react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* Sections are declared as module-level consts holding JSX `content`, so they cannot call a hook —
|
||||
* they carry catalog KEYS and the renderers below resolve them. `titleKey` rather than `titleKeyName`
|
||||
* and deliberately not `title_key`: docs-check-settings.ts greps `key:\s*'…'` for route segments, and
|
||||
* a capital K keeps `titleKey` out of that match.
|
||||
*/
|
||||
export type SettingsSection = {
|
||||
key: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
titleKey: AppTranslationKey;
|
||||
descriptionKey: AppTranslationKey;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export type SettingsSectionGroup = {
|
||||
label: string;
|
||||
labelKey: AppTranslationKey;
|
||||
icon: LucideIcon;
|
||||
sections: SettingsSection[];
|
||||
};
|
||||
@@ -21,7 +28,7 @@ export type SettingsSectionGroup = {
|
||||
type SettingsSidebarProps = {
|
||||
basePath: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
labelKey: AppTranslationKey;
|
||||
sections: SettingsSection[];
|
||||
groups?: SettingsSectionGroup[];
|
||||
hideHeader?: boolean;
|
||||
@@ -32,44 +39,51 @@ type SettingsSidebarProps = {
|
||||
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global
|
||||
* that the content pane had to be told about separately.
|
||||
*/
|
||||
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<section.icon
|
||||
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{section.title}</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<section.icon
|
||||
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{t(section.titleKey)}</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">
|
||||
{t(section.descriptionKey)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsSidebar = ({
|
||||
basePath,
|
||||
icon: Icon,
|
||||
label,
|
||||
labelKey,
|
||||
sections,
|
||||
groups,
|
||||
hideHeader,
|
||||
}: SettingsSidebarProps) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const query = search.toLowerCase();
|
||||
// Matched against the RESOLVED words, so searching works in the language on screen.
|
||||
const matchesSearch = (s: SettingsSection) =>
|
||||
s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query);
|
||||
t(s.titleKey).toLowerCase().includes(query) || t(s.descriptionKey).toLowerCase().includes(query);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
@@ -77,13 +91,13 @@ export const SettingsSidebar = ({
|
||||
<div className="p-3 pb-2">
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
placeholder={t('settings.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
@@ -95,11 +109,11 @@ export const SettingsSidebar = ({
|
||||
const filtered = group.sections.filter(matchesSearch);
|
||||
if (filtered.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div key={group.labelKey}>
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30">
|
||||
{group.label}
|
||||
{t(group.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
{filtered.map((s) => (
|
||||
@@ -121,6 +135,7 @@ type SettingsContentProps = {
|
||||
};
|
||||
|
||||
export const SettingsContent = ({ sections }: SettingsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
// The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key.
|
||||
// They agree via the URL now, so neither has to be told about the other — and neither can be the one
|
||||
// that seeds the slot, which is what made the old defaulting order matter (see §5.6).
|
||||
@@ -130,7 +145,7 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
|
||||
if (!section) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p>
|
||||
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">{t('settings.selectSection')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,9 +154,9 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
|
||||
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
|
||||
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{t(section.titleKey)}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
|
||||
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{t(section.descriptionKey)}</p>
|
||||
<div>{section.content}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -166,7 +181,7 @@ export const SettingsRoute = ({ basePath, sections, children }: SettingsRoutePro
|
||||
type CreateSettingsPanelParams = {
|
||||
basePath: string;
|
||||
sidebarIcon: LucideIcon;
|
||||
sidebarLabel: string;
|
||||
sidebarLabelKey: AppTranslationKey;
|
||||
sections?: SettingsSection[];
|
||||
groups?: SettingsSectionGroup[];
|
||||
};
|
||||
@@ -174,7 +189,7 @@ type CreateSettingsPanelParams = {
|
||||
export const createSettingsPanelComponents = ({
|
||||
basePath,
|
||||
sidebarIcon,
|
||||
sidebarLabel,
|
||||
sidebarLabelKey,
|
||||
sections = [],
|
||||
groups,
|
||||
}: CreateSettingsPanelParams) => {
|
||||
@@ -183,7 +198,7 @@ export const createSettingsPanelComponents = ({
|
||||
<SettingsSidebar
|
||||
basePath={basePath}
|
||||
icon={sidebarIcon}
|
||||
label={sidebarLabel}
|
||||
labelKey={sidebarLabelKey}
|
||||
sections={allSections}
|
||||
groups={groups}
|
||||
/>
|
||||
|
||||
@@ -12,44 +12,56 @@ import { OCRSection } from './ServerSettings/OCRSection';
|
||||
|
||||
const groups: SettingsSectionGroup[] = [
|
||||
{
|
||||
label: 'AI',
|
||||
labelKey: 'settings.groups.ai',
|
||||
icon: Bot,
|
||||
sections: [
|
||||
{
|
||||
key: 'tts',
|
||||
icon: Volume2,
|
||||
title: 'Text to Speech',
|
||||
description: 'TTS provider and voice',
|
||||
titleKey: 'settings.sections.tts.label',
|
||||
descriptionKey: 'settings.sections.tts.description',
|
||||
content: <TTSSection />,
|
||||
},
|
||||
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> },
|
||||
{
|
||||
key: 'stt',
|
||||
icon: Mic,
|
||||
titleKey: 'settings.sections.stt.label',
|
||||
descriptionKey: 'settings.sections.stt.description',
|
||||
content: <STTSection />,
|
||||
},
|
||||
{
|
||||
key: 'ocr',
|
||||
icon: ScanText,
|
||||
title: 'OCR',
|
||||
description: 'Vision model for text extraction',
|
||||
titleKey: 'settings.sections.ocr.label',
|
||||
descriptionKey: 'settings.sections.ocr.description',
|
||||
content: <OCRSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Maintenance',
|
||||
labelKey: 'settings.groups.maintenance',
|
||||
icon: Hammer,
|
||||
sections: [
|
||||
{
|
||||
key: 'maintenance',
|
||||
icon: Hammer,
|
||||
title: 'Rebuild & restart',
|
||||
description: 'Load changes without a terminal',
|
||||
titleKey: 'settings.sections.maintenance.label',
|
||||
descriptionKey: 'settings.sections.maintenance.description',
|
||||
content: <MaintenanceSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
labelKey: 'settings.groups.email',
|
||||
icon: Mail,
|
||||
sections: [
|
||||
{ key: 'smtp', icon: Mail, title: 'SMTP', description: 'Email delivery provider', content: <SMTPSection /> },
|
||||
{
|
||||
key: 'smtp',
|
||||
icon: Mail,
|
||||
titleKey: 'settings.sections.smtp.label',
|
||||
descriptionKey: 'settings.sections.smtp.description',
|
||||
content: <SMTPSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -59,7 +71,7 @@ const BASE_PATH = '/settings/system';
|
||||
const { Sidebar, Content, allSections } = createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: Settings,
|
||||
sidebarLabel: 'System',
|
||||
sidebarLabelKey: 'settings.nav.system',
|
||||
groups,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,15 +15,15 @@ const sections: SettingsSection[] = [
|
||||
{
|
||||
key: 'users',
|
||||
icon: Users,
|
||||
title: 'Accounts',
|
||||
description: 'Who has access, and as what',
|
||||
titleKey: 'settings.sections.accounts.label',
|
||||
descriptionKey: 'settings.sections.accounts.description',
|
||||
content: <UsersSection />,
|
||||
},
|
||||
{
|
||||
key: 'permissions',
|
||||
icon: ShieldCheck,
|
||||
title: 'Permissions',
|
||||
description: 'What each role can reach',
|
||||
titleKey: 'settings.sections.permissions.label',
|
||||
descriptionKey: 'settings.sections.permissions.description',
|
||||
content: <PermissionsSection />,
|
||||
},
|
||||
];
|
||||
@@ -33,7 +33,7 @@ const BASE_PATH = '/settings/user-management';
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: UserCog,
|
||||
sidebarLabel: 'Users',
|
||||
sidebarLabelKey: 'settings.nav.users',
|
||||
sections,
|
||||
});
|
||||
|
||||
|
||||
@@ -22,3 +22,5 @@ export * from './Dashboards';
|
||||
export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './Minesweeper';
|
||||
export * from './SendOff';
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { I18nProvider, localeDirection } from 'i18n';
|
||||
import { panelCatalogs } from 'officerdev';
|
||||
import { uiCatalogs } from '@/components/locales';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import en from '../locales/en.json';
|
||||
import pt from '../locales/pt.json';
|
||||
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||
import appEnGB from '../locales/en-GB.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
|
||||
const translations: TranslationMap = { en, pt };
|
||||
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
|
||||
|
||||
/**
|
||||
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
|
||||
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||
*/
|
||||
const translations: TranslationMap = Object.fromEntries(
|
||||
SHIPPED_LOCALES.map(({ code }) => [
|
||||
code,
|
||||
{
|
||||
app: appCatalogs[code] ?? {},
|
||||
panel: panelCatalogs[code] ?? {},
|
||||
ui: uiCatalogs[code] ?? {},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
type I18nBridgeProps = {
|
||||
children: ReactNode;
|
||||
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
|
||||
const { settings } = useSettings();
|
||||
const locale = settings.languages.default;
|
||||
|
||||
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
|
||||
// RTL catalog needs no second pass over the tree.
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = localeDirection(locale);
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<I18nProvider translations={translations} locale={locale}>
|
||||
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import type { TranslationKeys } from 'i18n';
|
||||
import type { InterpolationParams } from 'i18n';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from '../locales/en.json';
|
||||
import type en from '../locales/en-GB.json';
|
||||
import type uiEn from '@/components/locales/en-GB.json';
|
||||
|
||||
type AppTranslationKey = TranslationKeys<typeof en>;
|
||||
type UiTranslationKey = TranslationKeys<typeof uiEn>;
|
||||
|
||||
/**
|
||||
* `t` for this workspace's own catalog, `tUi` for the shared-component one.
|
||||
*
|
||||
* Both are here rather than in two imports because "Cancel", "Close" and "Search" recur across the
|
||||
* shell and genuinely belong to `ui.*` — phase 1 put them there so phases 2 and 3 could reach for them
|
||||
* instead of inventing a second key for the same word. Anything feature-specific stays in `app.*`:
|
||||
* per D6 a `ui.*` key may not name a feature.
|
||||
*
|
||||
* Both are memoized. An unstable `t` is unusable in a dependency array, and this repo has already lost
|
||||
* days to exactly that shape — see platform/CLAUDE.md on `useClient()` and the Jellyfin player.
|
||||
*/
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
|
||||
return { t, locale };
|
||||
|
||||
const t = useCallback((key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params), [baseT]);
|
||||
|
||||
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
|
||||
|
||||
return { t, tUi, locale };
|
||||
};
|
||||
|
||||
export type { AppTranslationKey };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
|
||||
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
|
||||
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
|
||||
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
|
||||
*/
|
||||
export type ShippedLocale = { code: string; label: string };
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = [
|
||||
{ code: 'en-GB', label: 'English (UK)' },
|
||||
{ code: 'pt-PT', label: 'Português (Portugal)' },
|
||||
];
|
||||
|
||||
export const FALLBACK_LOCALE = 'en-GB';
|
||||
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Your name",
|
||||
"updated": "Profile updated",
|
||||
"updateFailed": "Failed to update profile",
|
||||
"selectImage": "Please select an image file",
|
||||
"imageTooLarge": "Image must be smaller than 384KB"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as",
|
||||
"loading": "Loading keys…",
|
||||
"mintedTitle": "Key for “{{name}}” — shown once",
|
||||
"savedIt": "I’ve saved it",
|
||||
"copyKey": "Copy key",
|
||||
"copied": "Key copied",
|
||||
"copyFailed": "Could not copy — select and copy manually",
|
||||
"newKey": "New key",
|
||||
"namePlaceholder": "iPhone, Music app, laptop CLI…",
|
||||
"create": "Create",
|
||||
"createFailed": "Could not create the key",
|
||||
"noneYet": "No keys yet.",
|
||||
"noneInUse": "No keys in use.",
|
||||
"revoked": "revoked",
|
||||
"expired": "expired",
|
||||
"lastUsed": "last used {{date}}",
|
||||
"neverUsed": "never used",
|
||||
"forget": "Remove this record",
|
||||
"forgetFailed": "Could not remove it",
|
||||
"revoke": "Revoke",
|
||||
"revokeDone": "Revoked — anything using that key is signed out",
|
||||
"revokeFailed": "Could not revoke",
|
||||
"hideDead": "Hide revoked and expired",
|
||||
"showDead": {
|
||||
"one": "Show {{count}} revoked or expired key",
|
||||
"other": "Show {{count}} revoked or expired keys"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voice",
|
||||
"refresh": "Refresh voices",
|
||||
"serverDefault": "Server default",
|
||||
"hint": "Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.",
|
||||
"stop": "Stop",
|
||||
"listen": "Listen"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Visible",
|
||||
"hidden": "Hidden",
|
||||
"dragToShow": "Drag items here to show in dock",
|
||||
"allVisible": "All items visible",
|
||||
"reset": "Reset to defaults"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Languages you speak",
|
||||
"addLanguage": "Add a language...",
|
||||
"defaultLabel": "Default language",
|
||||
"defaultHint": "Used for UI localization.",
|
||||
"translateToLabel": "Translate to",
|
||||
"translateToHint": "Target language when translating content you don't speak."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Current Password",
|
||||
"currentPasswordPlaceholder": "Current password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"newPasswordPlaceholder": "New password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"confirmPasswordPlaceholder": "Confirm new password",
|
||||
"changeButton": "Change Password",
|
||||
"changingButton": "Changing...",
|
||||
"passwordChanged": "Password changed",
|
||||
"passwordChangeFailed": "Failed to change password"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Default Model",
|
||||
"sameAsChat": "Same as chat default",
|
||||
"taskDefaultsSaved": "Task defaults saved",
|
||||
"saveFailed": "Failed to save settings",
|
||||
"defaultChat": "Default Chat Model",
|
||||
"defaultChatHint": "Used when starting a new chat from the home screen",
|
||||
"defaultProject": "Default Project Model",
|
||||
"defaultProjectHint": "Used when starting a new chat inside a project dashboard",
|
||||
"defaultTask": "Default Task Model",
|
||||
"defaultTaskHint": "Used when running tasks from the file browser",
|
||||
"aiDefaultsSaved": "AI model defaults saved"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profile",
|
||||
"hint": "avatar, name, email",
|
||||
"description": "Avatar, name, and email"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Change password",
|
||||
"description": "Update your password"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "API keys",
|
||||
"hint": "keys that sign in as you",
|
||||
"description": "Keys that sign in as you, revocable one at a time"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Languages",
|
||||
"hint": "spoken and translation",
|
||||
"description": "Spoken, default, and translation"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voice",
|
||||
"hint": "text-to-speech",
|
||||
"description": "Text-to-speech voice preference"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "choose and reorder",
|
||||
"description": "Choose and reorder dock items"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "My Models",
|
||||
"description": "Show or hide models for yourself"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Channel Models",
|
||||
"description": "Models reachable by non-owner accounts"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Default Models",
|
||||
"description": "Default models for chat, projects, and tasks"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Providers",
|
||||
"hint": "harnesses",
|
||||
"description": "Remote and local AI providers"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Text to Speech",
|
||||
"description": "TTS provider and voice"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Speech to Text",
|
||||
"description": "Whisper server URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Vision model for text extraction"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Rebuild & restart",
|
||||
"hint": "maintenance",
|
||||
"description": "Load changes without a terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "outgoing mail",
|
||||
"description": "Email delivery provider"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID and secret for Google APIs"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "API token for web scraping actors"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Calendar & Contacts sync",
|
||||
"hint": "app passwords",
|
||||
"description": "Per-device passwords for CalDAV/CardDAV clients"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Accounts",
|
||||
"hint": "users",
|
||||
"description": "Who has access, and as what"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Permissions",
|
||||
"description": "What each role can reach"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Search settings…",
|
||||
"selectSection": "Select a section",
|
||||
"groups": {
|
||||
"models": "Models",
|
||||
"defaults": "Defaults",
|
||||
"providers": "Providers",
|
||||
"ai": "AI",
|
||||
"maintenance": "Maintenance",
|
||||
"email": "Email"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profile",
|
||||
"ai": "AI",
|
||||
"system": "System",
|
||||
"integrations": "Integrations",
|
||||
"users": "Users"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "No models available. Configure API keys in Providers.",
|
||||
"dragToShow": "Drag models here to show",
|
||||
"allVisible": "All models visible",
|
||||
"dragToEnable": "Drag models here to enable",
|
||||
"allEnabled": "All models enabled",
|
||||
"visible": "Visible",
|
||||
"hidden": "Hidden",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"refresh": "Refresh models from providers",
|
||||
"refreshed": "Models refreshed",
|
||||
"refreshFailed": "Failed to refresh models"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Enterprise",
|
||||
"personal": "Personal"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profile",
|
||||
"systemSettings": "System Settings",
|
||||
"signOut": "Sign Out",
|
||||
"ai": "AI",
|
||||
"integrations": "Integrations",
|
||||
"userManagement": "User Management"
|
||||
},
|
||||
"tabName": "Tab name",
|
||||
"tabNameHint": "Click to rename this tab — clear it to go back to the page name",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigation",
|
||||
"jobsRunning": "Running job",
|
||||
"jobsNothingRunning": "Nothing running",
|
||||
"jobsQueued": "Queued jobs"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"files": "Files",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Browser",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Activity",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Calendar",
|
||||
"contacts": "Contacts",
|
||||
"appStore": "App store",
|
||||
"marketplace": "Marketplace"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Theme: {{name}}",
|
||||
"star": "star",
|
||||
"unstar": "unstar",
|
||||
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+space browse",
|
||||
"everyShortcutHint": "ctrl+? for every shortcut",
|
||||
"favouritesOnly": "Favourites only ({{count}})",
|
||||
"favourites": "Favourites ({{count}})",
|
||||
"noFavourites": "No favourites yet — star one with the button on its row.",
|
||||
"starNamed": "Star {{name}}",
|
||||
"unstarNamed": "Unstar {{name}}",
|
||||
"addToFavourites": "Add to favourites",
|
||||
"removeFromFavourites": "Remove from favourites",
|
||||
"addToFavouritesKey": "Add to favourites (f)",
|
||||
"removeFromFavouritesKey": "Remove from favourites (f)",
|
||||
"active": "active",
|
||||
"browseHint": "j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Go to…",
|
||||
"noResults": "Nothing matches “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"hint": "Ctrl+? · Esc to close"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "This screen failed to render",
|
||||
"description": "Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are untouched.",
|
||||
"unknownError": "Unknown error",
|
||||
"tryAgain": "Try again",
|
||||
"reload": "Reload",
|
||||
"goHome": "Go home",
|
||||
"resetHint": "If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen’s panes back to their defaults.",
|
||||
"dashboardsSafe": "Your dashboards are not touched.",
|
||||
"resetting": "Resetting…",
|
||||
"resetConfirm": "Yes — reset every screen layout",
|
||||
"reset": "Reset screen layouts"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Rescan items",
|
||||
"title": "Rescan officer-items (apply changes without restarting)",
|
||||
"failed": "Failed to rescan items",
|
||||
"done": "Rescanned items — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} task",
|
||||
"other": "{{count}} tasks"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agent",
|
||||
"other": "{{count}} agents"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} tool",
|
||||
"other": "{{count}} tools"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} process",
|
||||
"other": "{{count}} processes"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Applies to new chats — an open one keeps what it started with.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill has no description, so no agent is offered it.",
|
||||
"other": "{{count}} skills have no description, so no agent is offered them."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Report a bug",
|
||||
"title": "Report a Bug",
|
||||
"describePlaceholder": "Describe what went wrong...",
|
||||
"screenshot": "Screenshot",
|
||||
"noScreenshot": "No screenshot captured",
|
||||
"collectedNote": "Console logs, browser info, and recent API errors are collected automatically.",
|
||||
"submitted": "Bug report submitted",
|
||||
"failed": "Failed to submit bug report",
|
||||
"submit": "Submit"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"searchPlaceholder": "Search settings...",
|
||||
"sections": {
|
||||
"profileTitle": "Profile",
|
||||
"profileDescription": "Update your name and avatar.",
|
||||
"changePasswordTitle": "Change Password",
|
||||
"changePasswordDescription": "Update your account password.",
|
||||
"tasksTitle": "Tasks",
|
||||
"tasksDescription": "Default model for file browser tasks.",
|
||||
"languagesTitle": "Languages",
|
||||
"languagesDescription": "Set your spoken languages and translation preferences."
|
||||
},
|
||||
"userData": {
|
||||
"emailLabel": "Email",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Your name",
|
||||
"profileUpdated": "Profile updated",
|
||||
"profileUpdateFailed": "Failed to update profile",
|
||||
"selectImageFile": "Please select an image file",
|
||||
"imageTooLarge": "Image must be smaller than 384KB"
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Current Password",
|
||||
"currentPasswordPlaceholder": "Current password",
|
||||
"newPasswordLabel": "New Password",
|
||||
"newPasswordPlaceholder": "New password",
|
||||
"confirmPasswordLabel": "Confirm New Password",
|
||||
"confirmPasswordPlaceholder": "Confirm new password",
|
||||
"changeButton": "Change Password",
|
||||
"changingButton": "Changing...",
|
||||
"passwordChanged": "Password changed",
|
||||
"passwordChangeFailed": "Failed to change password"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Languages you speak",
|
||||
"addLanguage": "Add a language...",
|
||||
"defaultLabel": "Default language",
|
||||
"defaultHint": "Used for UI localization.",
|
||||
"translateToLabel": "Translate to",
|
||||
"translateToHint": "Target language when translating content you don't speak."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profile",
|
||||
"systemSettings": "System Settings",
|
||||
"signOut": "Sign Out"
|
||||
}
|
||||
},
|
||||
"dock": {
|
||||
"chat": "Chat",
|
||||
"files": "Files",
|
||||
"terminal": "Terminal",
|
||||
"plans": "Plans",
|
||||
"skills": "Skills",
|
||||
"tasks": "Tasks",
|
||||
"processes": "Processes",
|
||||
"logs": "Logs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
|
||||
// not a warning — there is no regional fallback to paper over the gap.
|
||||
describe('officer-web (app.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"saving": "A guardar...",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "Email",
|
||||
"username": "Nome de utilizador",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "O seu nome",
|
||||
"updated": "Perfil atualizado",
|
||||
"updateFailed": "Falha ao atualizar o perfil",
|
||||
"selectImage": "Selecione um ficheiro de imagem",
|
||||
"imageTooLarge": "A imagem tem de ser menor do que 384KB"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Uma chave de API inicia sessão como você, sem palavra-passe e sem expirar. Uma chave funciona em todo o lado — uma chave não está associada a uma aplicação. Dar a cada dispositivo a sua própria chave serve apenas para poder cortar uma sem mexer nas restantes. Envie-a como",
|
||||
"loading": "A carregar chaves…",
|
||||
"mintedTitle": "Chave para “{{name}}” — mostrada uma única vez",
|
||||
"savedIt": "Já a guardei",
|
||||
"copyKey": "Copiar chave",
|
||||
"copied": "Chave copiada",
|
||||
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
|
||||
"newKey": "Nova chave",
|
||||
"namePlaceholder": "iPhone, aplicação Música, CLI do portátil…",
|
||||
"create": "Criar",
|
||||
"createFailed": "Não foi possível criar a chave",
|
||||
"noneYet": "Ainda não há chaves.",
|
||||
"noneInUse": "Nenhuma chave em uso.",
|
||||
"revoked": "revogada",
|
||||
"expired": "expirada",
|
||||
"lastUsed": "usada pela última vez em {{date}}",
|
||||
"neverUsed": "nunca usada",
|
||||
"forget": "Remover este registo",
|
||||
"forgetFailed": "Não foi possível remover",
|
||||
"revoke": "Revogar",
|
||||
"revokeDone": "Revogada — tudo o que usava essa chave foi desligado",
|
||||
"revokeFailed": "Não foi possível revogar",
|
||||
"hideDead": "Ocultar revogadas e expiradas",
|
||||
"showDead": {
|
||||
"one": "Mostrar {{count}} chave revogada ou expirada",
|
||||
"other": "Mostrar {{count}} chaves revogadas ou expiradas"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voz",
|
||||
"refresh": "Atualizar vozes",
|
||||
"serverDefault": "Predefinição do servidor",
|
||||
"hint": "Escolha uma voz para a conversão de texto em fala. Deixe na predefinição do servidor para usar a voz configurada pelo administrador.",
|
||||
"stop": "Parar",
|
||||
"listen": "Ouvir"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Visíveis",
|
||||
"hidden": "Ocultos",
|
||||
"dragToShow": "Arraste itens para aqui para os mostrar no dock",
|
||||
"allVisible": "Todos os itens visíveis",
|
||||
"reset": "Restaurar predefinições"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Idiomas que falas",
|
||||
"addLanguage": "Adicionar idioma...",
|
||||
"defaultLabel": "Idioma predefinido",
|
||||
"defaultHint": "Usado para localização da interface.",
|
||||
"translateToLabel": "Traduzir para",
|
||||
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Palavra-passe Atual",
|
||||
"currentPasswordPlaceholder": "Palavra-passe atual",
|
||||
"newPasswordLabel": "Nova Palavra-passe",
|
||||
"newPasswordPlaceholder": "Nova palavra-passe",
|
||||
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
|
||||
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
|
||||
"changeButton": "Alterar Palavra-passe",
|
||||
"changingButton": "A alterar...",
|
||||
"passwordChanged": "Palavra-passe alterada",
|
||||
"passwordChangeFailed": "Falha ao alterar palavra-passe"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Modelo predefinido",
|
||||
"sameAsChat": "Igual à predefinição do chat",
|
||||
"taskDefaultsSaved": "Predefinições de tarefas guardadas",
|
||||
"saveFailed": "Falha ao guardar as definições",
|
||||
"defaultChat": "Modelo predefinido do chat",
|
||||
"defaultChatHint": "Usado ao iniciar um novo chat a partir do ecrã inicial",
|
||||
"defaultProject": "Modelo predefinido do projeto",
|
||||
"defaultProjectHint": "Usado ao iniciar um novo chat dentro de um painel de projeto",
|
||||
"defaultTask": "Modelo predefinido das tarefas",
|
||||
"defaultTaskHint": "Usado ao executar tarefas a partir do explorador de ficheiros",
|
||||
"aiDefaultsSaved": "Predefinições de modelos de IA guardadas"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Perfil",
|
||||
"hint": "avatar, nome, email",
|
||||
"description": "Avatar, nome e email"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Alterar palavra-passe",
|
||||
"description": "Atualize a sua palavra-passe"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Chaves de API",
|
||||
"hint": "chaves que iniciam sessão como você",
|
||||
"description": "Chaves que iniciam sessão como você, revogáveis uma a uma"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Idiomas",
|
||||
"hint": "falados e tradução",
|
||||
"description": "Falados, predefinido e tradução"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voz",
|
||||
"hint": "conversão de texto em fala",
|
||||
"description": "Preferência de voz para conversão de texto em fala"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "escolher e reordenar",
|
||||
"description": "Escolher e reordenar os itens do dock"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Os meus modelos",
|
||||
"description": "Mostrar ou ocultar modelos para si"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Modelos do canal",
|
||||
"description": "Modelos acessíveis a contas que não são do proprietário"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Modelos predefinidos",
|
||||
"description": "Modelos predefinidos para chat, projetos e tarefas"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Fornecedores",
|
||||
"hint": "harnesses",
|
||||
"description": "Fornecedores de IA remotos e locais"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Texto para fala",
|
||||
"description": "Fornecedor de TTS e voz"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Fala para texto",
|
||||
"description": "URL do servidor Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modelo de visão para extração de texto"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Reconstruir e reiniciar",
|
||||
"hint": "manutenção",
|
||||
"description": "Carregar alterações sem um terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "correio a enviar",
|
||||
"description": "Fornecedor de entrega de email"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID e secret para as APIs Google"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "Token de API para atores de web scraping"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Sincronização de Calendário e Contactos",
|
||||
"hint": "palavras-passe de aplicação",
|
||||
"description": "Palavras-passe por dispositivo para clientes CalDAV/CardDAV"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Contas",
|
||||
"hint": "utilizadores",
|
||||
"description": "Quem tem acesso, e como"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Permissões",
|
||||
"description": "O que cada função pode alcançar"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Pesquisar definições…",
|
||||
"selectSection": "Selecione uma secção",
|
||||
"groups": {
|
||||
"models": "Modelos",
|
||||
"defaults": "Predefinições",
|
||||
"providers": "Fornecedores",
|
||||
"ai": "IA",
|
||||
"maintenance": "Manutenção",
|
||||
"email": "Email"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Perfil",
|
||||
"ai": "IA",
|
||||
"system": "Sistema",
|
||||
"integrations": "Integrações",
|
||||
"users": "Utilizadores"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Nenhum modelo disponível. Configure as chaves de API em Fornecedores.",
|
||||
"dragToShow": "Arraste modelos para aqui para os mostrar",
|
||||
"allVisible": "Todos os modelos visíveis",
|
||||
"dragToEnable": "Arraste modelos para aqui para os ativar",
|
||||
"allEnabled": "Todos os modelos ativados",
|
||||
"visible": "Visíveis",
|
||||
"hidden": "Ocultos",
|
||||
"enabled": "Ativados",
|
||||
"disabled": "Desativados",
|
||||
"refresh": "Atualizar modelos a partir dos fornecedores",
|
||||
"refreshed": "Modelos atualizados",
|
||||
"refreshFailed": "Falha ao atualizar modelos"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Empresa",
|
||||
"personal": "Pessoal"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Perfil",
|
||||
"systemSettings": "Definições do Sistema",
|
||||
"signOut": "Terminar Sessão",
|
||||
"ai": "IA",
|
||||
"integrations": "Integrações",
|
||||
"userManagement": "Gestão de Utilizadores"
|
||||
},
|
||||
"tabName": "Nome do separador",
|
||||
"tabNameHint": "Clique para renomear este separador — limpe-o para voltar ao nome da página",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navegação",
|
||||
"jobsRunning": "Trabalho em execução",
|
||||
"jobsNothingRunning": "Nada em execução",
|
||||
"jobsQueued": "Trabalhos em fila"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Início",
|
||||
"files": "Ficheiros",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Trabalhos",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Navegador",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Atividade",
|
||||
"dashboards": "Painéis",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Calendário",
|
||||
"contacts": "Contactos",
|
||||
"appStore": "Loja de aplicações",
|
||||
"marketplace": "Marketplace"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "marcar",
|
||||
"unstar": "desmarcar",
|
||||
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+space explorar",
|
||||
"everyShortcutHint": "ctrl+? para todos os atalhos",
|
||||
"favouritesOnly": "Apenas favoritos ({{count}})",
|
||||
"favourites": "Favoritos ({{count}})",
|
||||
"noFavourites": "Ainda não há favoritos — marque um com o botão na sua linha.",
|
||||
"starNamed": "Marcar {{name}}",
|
||||
"unstarNamed": "Desmarcar {{name}}",
|
||||
"addToFavourites": "Adicionar aos favoritos",
|
||||
"removeFromFavourites": "Remover dos favoritos",
|
||||
"addToFavouritesKey": "Adicionar aos favoritos (f)",
|
||||
"removeFromFavouritesKey": "Remover dos favoritos (f)",
|
||||
"active": "ativo",
|
||||
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · h ou ← reiniciar · enter manter · esc cancelar"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Ir para…",
|
||||
"noResults": "Nada corresponde a “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Atalhos de teclado",
|
||||
"hint": "Ctrl+? · Esc para fechar"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Este ecrã não conseguiu ser apresentado",
|
||||
"description": "Nada foi perdido — esta é uma falha de apresentação, não de dados. As transcrições do agente, os trabalhos e os ficheiros estão intactos.",
|
||||
"unknownError": "Erro desconhecido",
|
||||
"tryAgain": "Tentar novamente",
|
||||
"reload": "Recarregar",
|
||||
"goHome": "Ir para o início",
|
||||
"resetHint": "Se falhar novamente no mesmo local, a causa habitual é um layout de painéis guardado. Reiniciar devolve os painéis de todos os ecrãs às predefinições.",
|
||||
"dashboardsSafe": "Os seus painéis não são afetados.",
|
||||
"resetting": "A reiniciar…",
|
||||
"resetConfirm": "Sim — reiniciar o layout de todos os ecrãs",
|
||||
"reset": "Reiniciar layouts dos ecrãs"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Reanalisar itens",
|
||||
"title": "Reanalisar officer-items (aplicar alterações sem reiniciar)",
|
||||
"failed": "Falha ao reanalisar itens",
|
||||
"done": "Itens reanalisados — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} tarefa",
|
||||
"other": "{{count}} tarefas"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agente",
|
||||
"other": "{{count}} agentes"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} ferramenta",
|
||||
"other": "{{count}} ferramentas"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} processo",
|
||||
"other": "{{count}} processos"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Aplica-se a novos chats — um chat aberto mantém o que tinha ao iniciar.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill não tem descrição, pelo que não é oferecida a nenhum agente.",
|
||||
"other": "{{count}} skills não têm descrição, pelo que não são oferecidas a nenhum agente."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Comunicar um erro",
|
||||
"title": "Comunicar um erro",
|
||||
"describePlaceholder": "Descreva o que aconteceu...",
|
||||
"screenshot": "Captura de ecrã",
|
||||
"noScreenshot": "Nenhuma captura de ecrã obtida",
|
||||
"collectedNote": "Os registos da consola, a informação do navegador e os erros recentes da API são recolhidos automaticamente.",
|
||||
"submitted": "Comunicação de erro enviada",
|
||||
"failed": "Falha ao enviar a comunicação de erro",
|
||||
"submit": "Enviar"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"saving": "A guardar...",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"searchPlaceholder": "Pesquisar definições...",
|
||||
"sections": {
|
||||
"profileTitle": "Perfil",
|
||||
"profileDescription": "Atualiza o teu nome e avatar.",
|
||||
"changePasswordTitle": "Alterar Palavra-passe",
|
||||
"changePasswordDescription": "Atualiza a palavra-passe da tua conta.",
|
||||
"tasksTitle": "Tarefas",
|
||||
"tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.",
|
||||
"languagesTitle": "Idiomas",
|
||||
"languagesDescription": "Define os teus idiomas e preferências de tradução."
|
||||
},
|
||||
"userData": {
|
||||
"emailLabel": "Email",
|
||||
"nameLabel": "Nome",
|
||||
"namePlaceholder": "O teu nome",
|
||||
"profileUpdated": "Perfil atualizado",
|
||||
"profileUpdateFailed": "Falha ao atualizar perfil",
|
||||
"selectImageFile": "Por favor seleciona um ficheiro de imagem",
|
||||
"imageTooLarge": "A imagem deve ter menos de 384KB"
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Palavra-passe Atual",
|
||||
"currentPasswordPlaceholder": "Palavra-passe atual",
|
||||
"newPasswordLabel": "Nova Palavra-passe",
|
||||
"newPasswordPlaceholder": "Nova palavra-passe",
|
||||
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
|
||||
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
|
||||
"changeButton": "Alterar Palavra-passe",
|
||||
"changingButton": "A alterar...",
|
||||
"passwordChanged": "Palavra-passe alterada",
|
||||
"passwordChangeFailed": "Falha ao alterar palavra-passe"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Idiomas que falas",
|
||||
"addLanguage": "Adicionar idioma...",
|
||||
"defaultLabel": "Idioma predefinido",
|
||||
"defaultHint": "Usado para localização da interface.",
|
||||
"translateToLabel": "Traduzir para",
|
||||
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Perfil",
|
||||
"systemSettings": "Definições do Sistema",
|
||||
"signOut": "Terminar Sessão"
|
||||
}
|
||||
},
|
||||
"dock": {
|
||||
"chat": "Chat",
|
||||
"files": "Ficheiros",
|
||||
"terminal": "Terminal",
|
||||
"plans": "Planos",
|
||||
"skills": "Skills",
|
||||
"tasks": "Tarefas",
|
||||
"processes": "Processos",
|
||||
"logs": "Registos"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { chatSessionEvents } from './chat-events/schema';
|
||||
import { chatSessionModels } from './chat-session-models/schema';
|
||||
import { forgetChatSessions } from './chat-session-cleanup';
|
||||
|
||||
/*
|
||||
Deleting a conversation, across the two id spaces it actually spans.
|
||||
|
||||
This file touches the real database, which the rest of the suite avoids on purpose. It has to: the defect
|
||||
it exists to catch was never in a decision, it was in which column a `delete` matched. A test built on a
|
||||
fake store would have passed against both the broken version and the fixed one.
|
||||
|
||||
Safe because every id here is a synthetic uuid in a reserved block that no harness produces, and because
|
||||
each test removes exactly what it wrote. Nothing reads live rows and nothing deletes by anything but these
|
||||
ids.
|
||||
|
||||
Two regressions are pinned here, and they are the same bug seen twice:
|
||||
|
||||
1. `chat_session_events` is keyed on officer's SESSION KEY while everything else is keyed on the TRANSCRIPT
|
||||
id. One list passed to all four tables matched nothing in this one, ever, by construction — and counting
|
||||
events by transcript id to check also returns zero by construction, so it read as clean.
|
||||
2. Resolving that key from a live in-memory map only works for a session younger than the process. After a
|
||||
restart it comes back null, the key list arrives empty, and the events survive a delete that reports
|
||||
success. The key has to come from the rows, which carry `claudeSessionId` in their payload.
|
||||
*/
|
||||
|
||||
const TRANSCRIPT = '00000000-dead-4000-8000-000000000001';
|
||||
const SESSION_KEY = '00000000-dead-4000-8000-0000000000ff';
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, [SESSION_KEY]));
|
||||
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, [TRANSCRIPT]));
|
||||
});
|
||||
|
||||
const countEvents = async () =>
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(chatSessionEvents)
|
||||
.where(inArray(chatSessionEvents.sessionId, [SESSION_KEY]))
|
||||
).length;
|
||||
|
||||
describe('forgetChatSessions', () => {
|
||||
test('removes events keyed on the session key, found via the transcript id in their payload', async () => {
|
||||
// Exactly the real shape: one `result` carries both ids, the rest carry neither.
|
||||
await db.insert(chatSessionEvents).values([
|
||||
{ sessionId: SESSION_KEY, event: { type: 'result', sessionId: SESSION_KEY, claudeSessionId: TRANSCRIPT } },
|
||||
{ sessionId: SESSION_KEY, event: { type: 'assistant:text', text: 'no ids on this one' } },
|
||||
{ sessionId: SESSION_KEY, event: { type: 'tool:start', toolName: 'Bash' } },
|
||||
]);
|
||||
expect(await countEvents()).toBe(3);
|
||||
|
||||
// No `sessionKeys` — this is the post-restart case, where the caller cannot resolve one.
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT] });
|
||||
|
||||
// All three, not just the one carrying the id: resolving the key covers the whole session.
|
||||
expect(await countEvents()).toBe(0);
|
||||
});
|
||||
|
||||
test('an explicit session key still works when no event names the transcript', async () => {
|
||||
await db
|
||||
.insert(chatSessionEvents)
|
||||
.values([{ sessionId: SESSION_KEY, event: { type: 'assistant:text', text: 'nothing identifying here' } }]);
|
||||
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT], sessionKeys: [SESSION_KEY] });
|
||||
expect(await countEvents()).toBe(0);
|
||||
});
|
||||
|
||||
test('leaves another session alone', async () => {
|
||||
const OTHER = '00000000-dead-4000-8000-0000000000ee';
|
||||
await db
|
||||
.insert(chatSessionEvents)
|
||||
.values([{ sessionId: OTHER, event: { type: 'assistant:text', text: 'theirs' } }]);
|
||||
try {
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT], sessionKeys: [SESSION_KEY] });
|
||||
const left = await db
|
||||
.select()
|
||||
.from(chatSessionEvents)
|
||||
.where(inArray(chatSessionEvents.sessionId, [OTHER]));
|
||||
expect(left.length).toBe(1);
|
||||
} finally {
|
||||
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, [OTHER]));
|
||||
}
|
||||
});
|
||||
|
||||
test('forgets the transcript-keyed tables too', async () => {
|
||||
await db.insert(chatSessionModels).values({ sessionId: TRANSCRIPT, model: 'claude-code/opus', planMode: false });
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT] });
|
||||
const left = await db
|
||||
.select()
|
||||
.from(chatSessionModels)
|
||||
.where(inArray(chatSessionModels.sessionId, [TRANSCRIPT]));
|
||||
expect(left.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { chatSessionEvents } from './chat-events/schema';
|
||||
import { chatNotificationPrefs } from './chat-notification-prefs/schema';
|
||||
import { chatSessionModels } from './chat-session-models/schema';
|
||||
import { nudges } from './nudges/schema';
|
||||
|
||||
/*
|
||||
Everything the platform stores ABOUT a conversation, forgotten together.
|
||||
|
||||
── Why this exists as one function ──
|
||||
|
||||
Four tables describe a conversation, and none has a foreign key to anything, because the thing they
|
||||
describe is a FILE — a transcript on disk, written by the harness. There is no row for a chat session to
|
||||
reference, so the database cannot cascade and the delete path has to be explicit.
|
||||
|
||||
It was not. Deleting a conversation removed the transcript and left all four behind, for ever. Found on
|
||||
2026-08-22 by counting rows after two commanders were deleted through the UI: `chat_session_models` held 36
|
||||
rows against far fewer live conversations.
|
||||
|
||||
── Why it is more than untidy ──
|
||||
|
||||
Three of the four are inert — a model, a bell preference, a durable copy of turn output that nothing will
|
||||
ask for again. `nudges` is not: it is a SCHEDULE, and the tick in `nudger.ts` reads it. A nudge left behind
|
||||
by a deleted conversation is a timer that keeps firing at something that does not exist.
|
||||
|
||||
── TWO id spaces, and this is where they were confused ──
|
||||
|
||||
Three of these tables are keyed on the TRANSCRIPT id — the harness's uuid, which is also the filename.
|
||||
`chat_session_events` is keyed on officer's own SESSION KEY, which is a different uuid for the same
|
||||
conversation. Its rows carry both (`{sessionId, claudeSessionId}`), which is how the mismatch was finally
|
||||
proved rather than argued.
|
||||
|
||||
This function used to take one list and pass it to all four. So the events delete matched nothing, ever —
|
||||
not sometimes, by construction — and a deleted conversation left its entire durable output behind. The
|
||||
verification missed it twice, because counting events BY TRANSCRIPT ID also returns zero by construction,
|
||||
and zero read as "cleaned". Found 2026-08-22 by reading an orphaned event payload instead of counting it.
|
||||
|
||||
The parameter is named for its id space so a caller cannot pass the wrong one without writing the wrong
|
||||
word. That is the entire defence: nothing else about the two distinguishes them, since both are uuids.
|
||||
|
||||
── Why each takes a list ──
|
||||
|
||||
A `/clear` chain is several transcripts presenting as one conversation, and deleting it deletes every part.
|
||||
Forgetting one id and leaving its siblings is the same bug at a smaller scale, so the caller passes the ids
|
||||
it actually removed.
|
||||
*/
|
||||
type ForgetChatSessionsParams = {
|
||||
/** Harness transcript uuids — the filenames that were removed, including every part of a `/clear` chain. */
|
||||
transcriptIds: string[];
|
||||
/**
|
||||
* Officer's session keys for the same conversations, for `chat_session_events` alone.
|
||||
*
|
||||
* Empty is legitimate and common: a session spawned by `claude -p` outside officer's websocket never had a
|
||||
* key and never wrote an event, which is exactly the case for a commander's children.
|
||||
*/
|
||||
sessionKeys?: string[];
|
||||
};
|
||||
|
||||
export async function forgetChatSessions({ transcriptIds, sessionKeys = [] }: ForgetChatSessionsParams): Promise<void> {
|
||||
// Sequential rather than a transaction: these are independent tables with no invariant between them, and
|
||||
// a partial forget is strictly better than a failed delete that leaves the transcript gone and every row
|
||||
// in place. Each is idempotent — running it twice removes nothing the second time.
|
||||
if (transcriptIds.length) {
|
||||
await db.delete(nudges).where(inArray(nudges.sessionId, transcriptIds));
|
||||
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, transcriptIds));
|
||||
await db.delete(chatNotificationPrefs).where(inArray(chatNotificationPrefs.sessionId, transcriptIds));
|
||||
}
|
||||
/*
|
||||
Events, resolved from the table's OWN data rather than from the caller's key.
|
||||
|
||||
`sessionKeys` is a fast path and not a reliable one: the caller gets it from an in-memory map that is
|
||||
empty for any session created before the last restart, so the first real test of this deleted everything
|
||||
except the events — the key came back null and the list was empty. A cleanup that only works for
|
||||
conversations younger than the process is not a cleanup.
|
||||
|
||||
So ask the rows. Every event carries `claudeSessionId` in its payload, which is durable, survives any
|
||||
restart, and is the one place the two id spaces are written down together. One id inside the JSON is
|
||||
enough to resolve the key for all of that session's rows, including the events that carry no id of their
|
||||
own — which is most of them.
|
||||
*/
|
||||
if (transcriptIds.length) {
|
||||
const keysFromEvents = await db
|
||||
.selectDistinct({ sessionId: chatSessionEvents.sessionId })
|
||||
.from(chatSessionEvents)
|
||||
.where(inArray(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptIds));
|
||||
const keys = [...new Set([...sessionKeys, ...keysFromEvents.map((r) => r.sessionId)])];
|
||||
if (keys.length) await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, keys));
|
||||
} else if (sessionKeys.length) {
|
||||
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, sessionKeys));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Officer's session key for a transcript, read from the durable event log.
|
||||
*
|
||||
* The in-memory maps (`sessionKeyForTranscript`, `findSessionKeyByClaudeSession`) only answer for a session
|
||||
* younger than the process that is asking. After a restart both return undefined, and the delete path then
|
||||
* skips the kill entirely — so the transcript is removed while the harness is still writing it, and the
|
||||
* harness writes it straight back. A deleted commander reappears as an untitled stub.
|
||||
*
|
||||
* Every `result` event carries both ids in its payload, which survives any restart. Same trick as
|
||||
* `forgetChatSessions`, and for the same reason: the rows are the only place the two id spaces are written
|
||||
* down together.
|
||||
*/
|
||||
export async function sessionKeyForTranscriptId(transcriptId: string): Promise<string | undefined> {
|
||||
const [row] = await db
|
||||
.select({ sessionId: chatSessionEvents.sessionId })
|
||||
.from(chatSessionEvents)
|
||||
.where(eq(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptId))
|
||||
.limit(1);
|
||||
return row?.sessionId;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export {
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
recordCommandHqTurn,
|
||||
ingestSpawnedChildren,
|
||||
recordSpawnedOutcome,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
listCommandHqWatcherFires,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
commandHqTags,
|
||||
commandHqTurns,
|
||||
commandHqWatcherFires,
|
||||
commandHqSpawned,
|
||||
} from './schema';
|
||||
|
||||
// ── Categories ──
|
||||
@@ -54,6 +55,8 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
|
||||
export type CommandHqSessionRow = {
|
||||
id: number;
|
||||
sessionId: 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;
|
||||
watcherArmed: boolean;
|
||||
watcherTaskId: string | null;
|
||||
@@ -66,6 +69,7 @@ export type CommandHqSessionRow = {
|
||||
const SESSION_ROW_COLUMNS = {
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||
@@ -83,10 +87,11 @@ export async function createCommandHqSession(
|
||||
userId: number,
|
||||
sessionId: string,
|
||||
categoryId: number | null = null,
|
||||
name: string | null = null,
|
||||
): Promise<CommandHqSessionRow> {
|
||||
const [row] = await db
|
||||
.insert(commandHqSessions)
|
||||
.values({ userId, sessionId, categoryId })
|
||||
.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 —
|
||||
@@ -405,3 +410,68 @@ export async function removeCommandHqSessionTag(sessionRowId: number, tagId: num
|
||||
.delete(commandHqSessionTags)
|
||||
.where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
/*
|
||||
Spawned children. The file is the inbox; these rows are the record — see the schema note.
|
||||
*/
|
||||
|
||||
export type SpawnedChild = {
|
||||
spawnedSessionId: string;
|
||||
firstSeenAt: string;
|
||||
finishedAt: string | null;
|
||||
totalUsd: number | null;
|
||||
outcome: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ingest ids the loop appended, idempotently, and return the commander's children.
|
||||
*
|
||||
* One statement for the insert because the loop can repeat itself after a restart, so a duplicate append is
|
||||
* expected rather than exceptional. `onConflictDoNothing` on the unique index makes re-ingesting the whole
|
||||
* file cost nothing and change nothing, which is what lets the caller ingest on every read instead of
|
||||
* running a cron that can silently stop.
|
||||
*/
|
||||
export async function ingestSpawnedChildren(sessionRowId: number, ids: string[]): Promise<SpawnedChild[]> {
|
||||
if (ids.length) {
|
||||
await db
|
||||
.insert(commandHqSpawned)
|
||||
.values(ids.map((spawnedSessionId) => ({ sessionRowId, spawnedSessionId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(commandHqSpawned)
|
||||
.where(eq(commandHqSpawned.sessionRowId, sessionRowId))
|
||||
.orderBy(commandHqSpawned.firstSeenAt);
|
||||
return rows.map((r) => ({
|
||||
spawnedSessionId: r.spawnedSessionId,
|
||||
firstSeenAt: r.firstSeenAt.toISOString(),
|
||||
finishedAt: r.finishedAt?.toISOString() ?? null,
|
||||
totalUsd: r.totalUsd,
|
||||
outcome: r.outcome,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a child cost, once something knows.
|
||||
*
|
||||
* Separate from the ingest because the two facts arrive at different times from different parties: the loop
|
||||
* knows the id at spawn, and only the child knows what it cost when it exits. Null cost stays null — an
|
||||
* unknown total must not be reported as zero.
|
||||
*/
|
||||
export async function recordSpawnedOutcome(
|
||||
sessionRowId: number,
|
||||
spawnedSessionId: string,
|
||||
outcome: { totalUsd?: number | null; outcome?: string | null },
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(commandHqSpawned)
|
||||
.set({
|
||||
finishedAt: new Date(),
|
||||
...(outcome.totalUsd === undefined ? {} : { totalUsd: outcome.totalUsd }),
|
||||
...(outcome.outcome === undefined ? {} : { outcome: outcome.outcome }),
|
||||
})
|
||||
.where(
|
||||
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@ export const commandHqSessions = pgTable(
|
||||
* id spaces both agree on only sometimes.
|
||||
*/
|
||||
sessionId: text('session_id').notNull(),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
* The summary is the harness's, and it can only be written once the transcript file exists — which is
|
||||
* after the first turn. So a list reading the transcript showed the opening brief until that turn ended,
|
||||
* while the header beside it showed the name from local state. Two names for one commander, and every
|
||||
* fix for it was a cache-invalidation trick timed against somebody else's write.
|
||||
*
|
||||
* The row is written when the commander is filed, before any of that. Nullable for the rows that predate
|
||||
* this, which fall back to the transcript.
|
||||
*/
|
||||
name: text('name'),
|
||||
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
|
||||
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
|
||||
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
|
||||
@@ -195,3 +207,53 @@ export const commandHqSessionTags = pgTable(
|
||||
},
|
||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per agent a commander spawned.
|
||||
*
|
||||
* ── Why the database, when the loop writes a file ──
|
||||
*
|
||||
* The file is an INBOX, not the record. A commander's loop is bash: it can append a session id with a shell
|
||||
* redirect and it cannot reach Postgres, and giving it a credential so it could would put an authenticated
|
||||
* writer inside a background job — worse than the problem. So the loop appends, and the platform ingests
|
||||
* into this table whenever anything asks. The file is allowed to be lossy; this is not.
|
||||
*
|
||||
* What the table buys that the file cannot:
|
||||
*
|
||||
* - the LINK, enforced. `onDelete: 'cascade'` means deleting a commander cannot leave orphaned children
|
||||
* behind, which is the whole reason this became important.
|
||||
* - queries. Cost per commander, how many agents a trigger spawned, which ones are still running — none of
|
||||
* which a newline-delimited file can answer without reading every one.
|
||||
* - a place for what the loop cannot know at append time: when the child finished, what it cost, whether
|
||||
* it succeeded. Those arrive later, from the child itself, and a file appended by a shell cannot be
|
||||
* updated in place.
|
||||
*
|
||||
* ── Why the child's own id is the key and not a serial ──
|
||||
*
|
||||
* Because the same child must not be ingested twice — the file is appended to by a loop that may repeat
|
||||
* itself after a restart, and the ingest is idempotent on `(session_row_id, spawned_session_id)`.
|
||||
*/
|
||||
export const commandHqSpawned = pgTable(
|
||||
'command_hq_spawned',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The commander. Cascades: its children go with it, which is the point. */
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
/** The child's own transcript id, as the harness wrote it. */
|
||||
spawnedSessionId: text('spawned_session_id').notNull(),
|
||||
/** When the platform first saw it in the inbox — not when the child started, which nothing records. */
|
||||
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/**
|
||||
* Filled in later, by whoever finds out. Null means unknown rather than zero — a commander whose
|
||||
* children's costs are unknown must not display a confident total that happens to be too low, which is
|
||||
* the failure this table exists to end.
|
||||
*/
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||
totalUsd: doublePrecision('total_usd'),
|
||||
outcome: text('outcome'),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
(table) => [uniqueIndex('uq_command_hq_spawned').on(table.sessionRowId, table.spawnedSessionId)],
|
||||
);
|
||||
|
||||
@@ -39,6 +39,9 @@ export * from './nudges';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||
export * from './sendoff';
|
||||
export * from './roles';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { roles } from './schema';
|
||||
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { roles } from './schema';
|
||||
|
||||
export type RoleRow = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
provisioningScript: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every role, with how many accounts hold each.
|
||||
*
|
||||
* The count is here rather than in a second query because every caller needs it: a picker wants to show
|
||||
* it, and the delete guard is defined in terms of it. One left join beats two round trips and a race
|
||||
* between them.
|
||||
*/
|
||||
export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: roles.name,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
provisioningScript: roles.provisioningScript,
|
||||
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)
|
||||
.orderBy(asc(roles.name));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||
const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function seedRole(row: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isSystem?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning({ name: roles.name });
|
||||
return inserted.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* The roles this server has, as data rather than a TypeScript union.
|
||||
*
|
||||
* ── Why the NAME is the primary key ──
|
||||
*
|
||||
* `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off
|
||||
* the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one
|
||||
* file standing between a Member and a shell — to buy nothing the name does not already give, because
|
||||
* Postgres will cascade a rename for us. So both columns become foreign keys onto this one with
|
||||
* `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code
|
||||
* 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.
|
||||
*
|
||||
* ── What is NOT here ──
|
||||
*
|
||||
* **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this
|
||||
* table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that
|
||||
* making roles editable would have made "add a role" and "become the owner" the same feature. Several
|
||||
* accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it.
|
||||
*
|
||||
* **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and
|
||||
* its provisioning, not its grants.
|
||||
*/
|
||||
export const roles = pgTable('roles', {
|
||||
/** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */
|
||||
name: text('name').primaryKey(),
|
||||
/** One line, for a picker. Never load-bearing — nothing branches on it. */
|
||||
description: text('description'),
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
provisioningScript: text('provisioning_script'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -37,6 +37,8 @@ export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage
|
||||
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
|
||||
export * from './roles/schema'; // roles
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export { sendoffDevices, sendoffSenders } from './schema';
|
||||
export {
|
||||
upsertSendoffDevice,
|
||||
listSendoffDevices,
|
||||
renameSendoffDevice,
|
||||
deleteSendoffDevice,
|
||||
listAllowedSenders,
|
||||
listMyRecipients,
|
||||
listOtherAccounts,
|
||||
allowSender,
|
||||
revokeSender,
|
||||
maySendTo,
|
||||
accountsByIds,
|
||||
type SendoffDevice,
|
||||
type SendoffPerson,
|
||||
} from './queries';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { sendoffDevices, sendoffSenders } from './schema';
|
||||
|
||||
export type SendoffDevice = { id: string; name: string; platform: string | null; lastSeenAt: Date };
|
||||
|
||||
/**
|
||||
* Register or refresh a device. Called on every app start, so it must be idempotent on the client's own id.
|
||||
*
|
||||
* `lastSeenAt` is refreshed on conflict and the name is overwritten, because the client is the authority on
|
||||
* what it calls itself — renaming on the device should show up in the picker without a second endpoint.
|
||||
*/
|
||||
export async function upsertSendoffDevice(params: {
|
||||
id: string;
|
||||
userId: number;
|
||||
name: string;
|
||||
platform?: string | null;
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.insert(sendoffDevices)
|
||||
.values({
|
||||
id: params.id,
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
platform: params.platform ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sendoffDevices.id,
|
||||
set: { name: params.name, platform: params.platform ?? null, lastSeenAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSendoffDevices(userId: number): Promise<SendoffDevice[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: sendoffDevices.id,
|
||||
name: sendoffDevices.name,
|
||||
platform: sendoffDevices.platform,
|
||||
lastSeenAt: sendoffDevices.lastSeenAt,
|
||||
})
|
||||
.from(sendoffDevices)
|
||||
.where(eq(sendoffDevices.userId, userId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Scoped to the owner of the row: a device id is a client-chosen uuid and must not be a way to reach another account's. */
|
||||
export async function renameSendoffDevice(userId: number, id: string, name: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(sendoffDevices)
|
||||
.set({ name })
|
||||
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
|
||||
.returning({ id: sendoffDevices.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function deleteSendoffDevice(userId: number, id: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(sendoffDevices)
|
||||
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
|
||||
.returning({ id: sendoffDevices.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export type SendoffPerson = { id: number; email: string; name: string | null };
|
||||
|
||||
/** The accounts I have allowed to send to me. */
|
||||
export async function listAllowedSenders(recipientUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(sendoffSenders)
|
||||
.innerJoin(users, eq(users.id, sendoffSenders.senderUserId))
|
||||
.where(eq(sendoffSenders.recipientUserId, recipientUserId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts I may send to — the inverse question, and the one the picker asks.
|
||||
*
|
||||
* Absence denies, so this returns only recipients who have named me. A sender never learns that an account
|
||||
* exists and refused them; it simply is not in the list.
|
||||
*/
|
||||
export async function listMyRecipients(senderUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(sendoffSenders)
|
||||
.innerJoin(users, eq(users.id, sendoffSenders.recipientUserId))
|
||||
.where(eq(sendoffSenders.senderUserId, senderUserId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Every account except me — what the owner's picker shows, since the owner may send to anyone. */
|
||||
export async function listOtherAccounts(exceptUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db.select({ id: users.id, email: users.email, name: users.name }).from(users);
|
||||
return rows.filter((r) => r.id !== exceptUserId);
|
||||
}
|
||||
|
||||
export async function allowSender(recipientUserId: number, senderUserId: number): Promise<void> {
|
||||
await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function revokeSender(recipientUserId: number, senderUserId: number): Promise<void> {
|
||||
await db
|
||||
.delete(sendoffSenders)
|
||||
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)));
|
||||
}
|
||||
|
||||
/** May this sender write into this recipient's tree? The owner check is the CALLER's job — see the schema. */
|
||||
export async function maySendTo(senderUserId: number, recipientUserId: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: sendoffSenders.id })
|
||||
.from(sendoffSenders)
|
||||
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Resolve accounts by id, for naming a recipient in a response without a second round trip. */
|
||||
export async function accountsByIds(ids: number[]): Promise<SendoffPerson[]> {
|
||||
if (!ids.length) return [];
|
||||
return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* A named machine, so "send it to my laptop" has something to pick from.
|
||||
*
|
||||
* Separate from `push_devices` deliberately, and the two are not the same population. A push device is a
|
||||
* token: iOS or Android only, no human name, and it exists to be pushed AT. This is a name a person chose,
|
||||
* for a machine that may never receive a push at all — a desktop, a browser, a box with no app on it. The
|
||||
* name's only job is to become a folder.
|
||||
*
|
||||
* The id is the client's own uuid rather than a serial, because registration happens on every app start and
|
||||
* has to be idempotent without the client remembering a number we gave it.
|
||||
*/
|
||||
export const sendoffDevices = pgTable('sendoff_devices', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls it. Becomes a directory name, so it is sanitised before it touches the disk. */
|
||||
name: text('name').notNull(),
|
||||
platform: text('platform'),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Who may send files to whom — held by the RECIPIENT, one row per sender they have allowed.
|
||||
*
|
||||
* This is the whole cross-user permission model and it is deliberately not a role grant. A grant answers
|
||||
* "may this role send files", which is the wrong question: the person who should decide whether Fedra can
|
||||
* put a file in your home directory is you, not whoever administers roles. So consent is data, it is
|
||||
* per-pair, and absence denies — a sender who has not been allowed does not see the recipient as a target
|
||||
* at all, rather than seeing one and being refused.
|
||||
*
|
||||
* The owner is exempt as a SENDER and only as a sender: they bypass every other check on this platform and
|
||||
* there is no version of this where they cannot reach a home directory they already own. Receiving still
|
||||
* needs their say-so, so nobody lands files in the owner's tree uninvited.
|
||||
*/
|
||||
export const sendoffSenders = pgTable(
|
||||
'sendoff_senders',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The account granting permission — the one whose tree gets written to. */
|
||||
recipientUserId: integer('recipient_user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The account allowed to send to them. */
|
||||
senderUserId: integer('sender_user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
(table) => [uniqueIndex('uq_sendoff_senders').on(table.recipientUserId, table.senderUserId)],
|
||||
);
|
||||
+4
-1
@@ -16,6 +16,7 @@ import { resolveAuthToken } from './servers/auth-token';
|
||||
import { isWsProviderAllowed } from './servers/permissions/authorize';
|
||||
import { isTokenBlacklisted, getUserById } from 'officerdb';
|
||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||
import { minesweeperWebsocket } from './servers/api/minesweeper/websocket';
|
||||
import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
|
||||
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
|
||||
@@ -159,6 +160,7 @@ const handlers: Record<string, any> = {
|
||||
'chat-notify': chatNotifyWebsocket,
|
||||
'task-runner': taskRunnerWebsocket,
|
||||
pipeline: pipelineWebsocket,
|
||||
minesweeper: minesweeperWebsocket,
|
||||
sidecar: sidecarWebsocket,
|
||||
};
|
||||
|
||||
@@ -185,6 +187,7 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
'/api/tasks/run/ws': 'task-runner',
|
||||
'/api/tasks/pipeline/ws': 'pipeline',
|
||||
'/api/terminal/ws': 'terminal',
|
||||
'/api/minesweeper/ws': 'minesweeper',
|
||||
'/api/chat/ws': 'chat',
|
||||
'/api/chat/notify/ws': 'chat-notify',
|
||||
};
|
||||
@@ -363,7 +366,7 @@ console.log(
|
||||
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
|
||||
* deliberately absent — they upgrade through their own handlers, not through here.
|
||||
*/
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'minesweeper';
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Context } from 'hono';
|
||||
import { emitToSession } from './websocket';
|
||||
import { emitToSession, sessionKeyForTranscript } from './websocket';
|
||||
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getChatNotifyPreference,
|
||||
getChatNotifyPreferences,
|
||||
setChatNotifyPreference,
|
||||
forgetChatSessions,
|
||||
} from 'officerdb';
|
||||
import type { NotifyPreference } from 'officerdb/types';
|
||||
import { setNudge, getNudge, cancelNudge } from 'officerdb';
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
stopBackgroundTask,
|
||||
claudeSessionContext,
|
||||
liveSessionTitle,
|
||||
isCommandersDir,
|
||||
} from './claude-sessions';
|
||||
import {
|
||||
listOpenCodeSessions,
|
||||
@@ -58,7 +58,7 @@ import { readSttConfig } from '../server-settings/stt';
|
||||
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
|
||||
* used to stand at the top of this router.
|
||||
*/
|
||||
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
export async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
const resolved = await resolveHomeDir(user.id);
|
||||
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
|
||||
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
|
||||
@@ -151,6 +151,12 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
|
||||
Never fatal — a database hiccup on a feature /chat does not otherwise depend on must not take the whole
|
||||
conversation list down with it, the same posture `getChatSessionModels` below already takes.
|
||||
|
||||
This route does NOT report `isCommander` on its rows, and that is not an omission. It used to: rows were
|
||||
tagged from this same set a few lines above and then removed by it here, so the flag was false on every
|
||||
row that survived — a field that could only ever lie, plus a second identical query to produce it. A list
|
||||
that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the
|
||||
flag is real, because that route keeps both kinds.
|
||||
*/
|
||||
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
|
||||
const sessions = merged.filter((s) => !filed.has(s.id));
|
||||
@@ -266,7 +272,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
|
||||
travels with the conversation.
|
||||
*/
|
||||
isCommander: isCommandersDir(who.email, detail.cwd),
|
||||
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,6 +298,7 @@ chatRouter.get('/live', async (ctx) => {
|
||||
sidecar.listLiveClaudeSessions(user.id),
|
||||
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
|
||||
]);
|
||||
const commanderIds = await listCommandHqSessionIds(user.id);
|
||||
const sessions = live.map((session) => {
|
||||
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
||||
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
||||
@@ -304,14 +311,16 @@ chatRouter.get('/live', async (ctx) => {
|
||||
harness: 'claude' as const,
|
||||
title: resolved?.title ?? null,
|
||||
cwd,
|
||||
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the
|
||||
// history listing does it: every commander shares one directory, so the directory IS the answer and
|
||||
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists.
|
||||
// From the `command_hq_sessions` rows, because a commander chooses its own working directory and the
|
||||
// directory therefore answers nothing. One query for the whole set, before the map below.
|
||||
//
|
||||
// A commander mid-first-turn has no row yet and so reports false here for one turn. That is the same
|
||||
// turn on which it must not arm anything, and it corrects itself the moment it is filed.
|
||||
//
|
||||
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
|
||||
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
|
||||
// deploy commander turning up in that swipe order is exactly what it must not do.
|
||||
isCommander: cwd ? isCommandersDir(who.email, cwd) : false,
|
||||
isCommander: transcriptId ? commanderIds.has(transcriptId) : false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -357,7 +366,42 @@ chatRouter.get('/live', async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
return ctx.json({ sessions: [...sessions, ...openCodeSessions] });
|
||||
const rows = [...sessions, ...openCodeSessions];
|
||||
|
||||
/*
|
||||
`?kind=` — the split, done here rather than by each client.
|
||||
|
||||
The classification was already server-side; what was not was the FILTER, so every client repeated it.
|
||||
That is fine for the web list, which has both tabs on screen, and wrong for a phone that swipes between
|
||||
live chats one at a time and must simply never be handed a commander.
|
||||
|
||||
**The default is `agent`, not "everything".** A caller that says nothing gets ordinary chats, so the way
|
||||
to receive a commander is to ask for one by name. That is the whole point: forgetting the parameter can
|
||||
only ever under-report, never put a deploy commander into a swipe order that has no way to skip it. It
|
||||
costs the two callers that DO want both an explicit `kind=all`, which is the right trade — they are few,
|
||||
they are on screens built for it, and they are the ones in a position to notice if it is wrong.
|
||||
|
||||
Counts always describe the WHOLE set, whatever the filter. The web tabs show `Agents (n) /
|
||||
Commanders (n)` side by side and both numbers have to be right whichever tab is open — a filter that
|
||||
also hid the other count would force a second request to render one screen, which is how a server-side
|
||||
filter ends up costing more than the client-side one it replaced.
|
||||
|
||||
An unrecognised `kind` is a 400, never a silent fallback. Plural forms are accepted because the client's
|
||||
own type is already `'agents' | 'commanders'`, and the failure silence would buy is precise: a mistyped
|
||||
`?kind=agents` resolving to something other than agents is the exact bug this exists to prevent.
|
||||
*/
|
||||
const kind = ctx.req.query('kind') ?? 'agent';
|
||||
const counts = {
|
||||
agents: rows.filter((s) => !s.isCommander).length,
|
||||
commanders: rows.filter((s) => s.isCommander).length,
|
||||
};
|
||||
if (!['agent', 'agents', 'commander', 'commanders', 'all'].includes(kind)) {
|
||||
return ctx.json({ error: `Unknown kind '${kind}' — expected 'agent', 'commander' or 'all'` }, 400);
|
||||
}
|
||||
const wantCommanders = kind === 'commander' || kind === 'commanders';
|
||||
const shown = kind === 'all' ? rows : rows.filter((s) => s.isCommander === wantCommanders);
|
||||
|
||||
return ctx.json({ sessions: shown, counts });
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
|
||||
@@ -368,8 +412,36 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
if (isOpenCodeSessionId(id)) {
|
||||
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
}
|
||||
|
||||
/*
|
||||
Stop the harness BEFORE removing its file, and wait for it.
|
||||
|
||||
The `claude` CLI owns the transcript. Deleting it while a live session is still writing removes a file
|
||||
that is written straight back, so the conversation reappears untitled on the next refresh — see
|
||||
`killClaudeAndWait`. No live session means nothing to wait for, which is the ordinary case here.
|
||||
*/
|
||||
const liveKey = sessionKeyForTranscript(id);
|
||||
if (liveKey) await sidecar.killClaudeAndWait(liveKey, ctx.get('user').id);
|
||||
|
||||
// `.length`, not truthiness: this returns the IDS it removed and an empty array is truthy in JavaScript,
|
||||
// so `if (removed)` would report a delete that found nothing as a success.
|
||||
const removed = deleteClaudeSession(who, cwd, id);
|
||||
if (!removed.length) return ctx.text('Not found', 404);
|
||||
|
||||
/*
|
||||
The transcript is gone; forget what the platform stored ABOUT it. Four tables describe a conversation with
|
||||
no foreign key — there is no row for one to reference — so nothing cascades and this has to be explicit.
|
||||
One of them, `nudges`, is a live schedule: left behind it keeps firing at a conversation that is gone.
|
||||
|
||||
Two id spaces, and they are not interchangeable: three tables are keyed on the transcript id, while
|
||||
`chat_session_events` is keyed on officer's session key. Undefined here is the normal case for an old
|
||||
conversation — no live session holds it, so nothing is keyed on a key to remove.
|
||||
*/
|
||||
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { DATA_PATH, getCommandersDir } from '../../data-path';
|
||||
import {
|
||||
appendTextAs,
|
||||
@@ -642,9 +642,12 @@ const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): stri
|
||||
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
|
||||
* part of several.
|
||||
*/
|
||||
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
|
||||
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): string[] {
|
||||
const filePath = findTranscript(who, cwd, sessionId);
|
||||
if (!filePath) return false;
|
||||
// The IDS it removed, not a boolean — empty means nothing was there. The caller needs them to forget the
|
||||
// rows keyed on each one (`forgetChatSessions`), and a `/clear` chain is several transcripts presenting as
|
||||
// one conversation: returning true would have told a caller to forget the head and orphan its parts.
|
||||
if (!filePath) return [];
|
||||
|
||||
const ownCwd = firstCwd(who.osUser, filePath);
|
||||
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
|
||||
@@ -653,7 +656,7 @@ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
|
||||
// success — the conversation reappeared on the next refresh.
|
||||
const dir = dirname(filePath);
|
||||
for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
|
||||
return true;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -984,20 +987,45 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
||||
return applyLineage(sessions);
|
||||
}
|
||||
|
||||
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
||||
/**
|
||||
* Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain.
|
||||
*
|
||||
* **Says nothing about commanders**, deliberately. It used to: every commander shared one directory, so the
|
||||
* directory was the answer and this computed it once per group. That stopped being true the moment a
|
||||
* commander could choose its own working directory — a commander in the platform checkout would have been
|
||||
* reported as an ordinary chat, and an ordinary chat in the old commanders directory as a commander.
|
||||
*
|
||||
* Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
|
||||
* It is applied by the CALLER, because that requires a database read and this function is synchronous over a
|
||||
* directory scan.
|
||||
*/
|
||||
/**
|
||||
* A transcript with no human or assistant turn in it is not a conversation.
|
||||
*
|
||||
* The harness writes metadata records — `last-prompt`, `ai-title`, `atis-latch` — into a transcript file
|
||||
* independently of the conversation, and it will happily create the file to hold them. So a session that was
|
||||
* deleted, or one that never sent anything, leaves a file containing only those three lines, which listed as
|
||||
* "(untitled) · 0 msgs" and accumulated: four of them from one evening of testing.
|
||||
*
|
||||
* Filtered at the list rather than deleted on disk. The files are the harness's, it is entitled to write
|
||||
* them, and a cleanup that raced it would be the same class of bug as the one that produced them.
|
||||
*/
|
||||
const hasConversation = (s: ClaudeSessionSummary): boolean => s.messageCount > 0;
|
||||
|
||||
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
||||
// Every commander a user has lives in ONE directory, so this is a property of the group rather than of
|
||||
// each transcript — computed once here instead of per row. See `isCommander` on the summary type for
|
||||
// why the flag has to reach the payload at all.
|
||||
const commander = isCommandersDir(who.email, cwd);
|
||||
return mergeChains(scanGroup(who, cwd))
|
||||
.map(publish)
|
||||
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
|
||||
.filter(hasConversation)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this directory the one every commander shares?
|
||||
* 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
|
||||
@@ -1011,6 +1039,36 @@ export function isCommandersDir(email: string, cwd: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-shaped summary, found by id wherever it lives.
|
||||
*
|
||||
* For callers that hold a set of ids rather than a directory — Command HQ, which reads its commanders from
|
||||
* `command_hq_sessions` and cannot scan for them any more now that each chooses its own working directory.
|
||||
*
|
||||
* Returns null when no transcript can be found, which is the same signal a directory scan gave by omission:
|
||||
* a filed row whose conversation has been deleted. `purgeCommandHqSessions` is what tidies those.
|
||||
*
|
||||
* Deliberately NOT chain-aware in the way `listClaudeSessions` is. It reports the transcript addressed by
|
||||
* this id; a `/clear` chain's head is what gets filed and what every surface addresses, so the tail is not
|
||||
* something a caller here holds an id for.
|
||||
*/
|
||||
export function loadClaudeSessionSummaryById(who: ChatIdentity, sessionId: string): ClaudeSessionSummary | null {
|
||||
const filePath = locateTranscript(who, sessionId);
|
||||
if (!filePath) return null;
|
||||
|
||||
// `mtimeMs` becomes `updatedAt`, which is what every caller sorts on — so it has to be real, not zero.
|
||||
// Taken from `listTranscriptsAs` over the transcript's OWN directory rather than a bare `statSync`: the
|
||||
// file may belong to a member, and reading a member's tree needs the as-user path. One directory listing
|
||||
// per commander, scoped to one group — the same call the old whole-directory scan made, once per row
|
||||
// instead of once for all of them, which is the cost of letting each choose where it lives.
|
||||
const group = basename(dirname(filePath));
|
||||
const entry = listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), group).find((f) => f.id === sessionId);
|
||||
if (!entry) return null;
|
||||
|
||||
const summary = summarizeTranscript(who.osUser, filePath, sessionId, entry.mtimeMs);
|
||||
return summary ? publish(summary) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every conversation in every directory, newest first.
|
||||
*
|
||||
|
||||
@@ -78,11 +78,15 @@ const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
||||
/**
|
||||
* How long a reading is reused.
|
||||
*
|
||||
* Both halves are expensive in different ways — the endpoint rate-limits, and the scan reads ~90MB of JSONL —
|
||||
* and neither changes meaningfully inside a minute. Cached SEPARATELY so a network failure does not throw
|
||||
* away a good scan, and so opening and shutting the popover is not a request per flick.
|
||||
* The transcript scan reads ~90MB of JSONL and its answer does not change meaningfully inside a minute, so
|
||||
* it is still served from cache. It is also the same answer whichever account is signed in — it counts what
|
||||
* this machine wrote, not what an account is allowed — which is why switching account does not need to
|
||||
* invalidate it.
|
||||
*
|
||||
* **The limits have no TTL and are never served from cache.** They are per-account and they are the number
|
||||
* somebody opens this panel to read, so a stale one is worse than a slow one. The store is kept for the
|
||||
* failure path only — see `lastGoodLimits`.
|
||||
*/
|
||||
const LIMITS_TTL_MS = 60_000;
|
||||
const SCAN_TTL_MS = 60_000;
|
||||
|
||||
const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR?.trim() || join(getOwnerHomeDir(''), '.claude');
|
||||
@@ -142,7 +146,21 @@ export async function readAccountEmail(): Promise<string | null> {
|
||||
|
||||
type LimitsResult = { limits: UsageLimit[]; problem?: string };
|
||||
|
||||
let limitsCache: { at: number; result: LimitsResult } | null = null;
|
||||
/*
|
||||
Last good reading PER ACCOUNT, and used only when a probe fails.
|
||||
|
||||
This was a TTL cache keyed on nothing, and the consequence was the whole feature lying. Switching account
|
||||
changes which credential `readOauthLogin` returns, but a cached entry was served regardless — so the panel
|
||||
showed the previous account's percentages under the new account's name for the length of the TTL, and the
|
||||
failure fallback served them for as long as the probe kept failing. Reported by the owner: maxed out on one
|
||||
account, switched, and the numbers did not move.
|
||||
|
||||
So there is no TTL any more. Every request probes, because these percentages are the entire point of the
|
||||
panel and a stale one is worse than a slow one. What survives is the fallback, now keyed on the account it
|
||||
was measured for, so a flaky request degrades to that account's own last reading rather than to a blank
|
||||
panel or — far worse — to somebody else's numbers.
|
||||
*/
|
||||
const lastGoodLimits = new Map<string, { at: number; result: LimitsResult }>();
|
||||
|
||||
const asPercent = (value: unknown): number => {
|
||||
const n = Number(
|
||||
@@ -303,20 +321,22 @@ async function collectLimits(): Promise<LimitsResult & { plan: string | null; me
|
||||
return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' };
|
||||
}
|
||||
|
||||
if (limitsCache && now - limitsCache.at < LIMITS_TTL_MS) {
|
||||
return { ...limitsCache.result, plan: login.plan, measuredAt: limitsCache.at };
|
||||
}
|
||||
// Which account this reading belongs to. Null would make every account share one entry, which is the bug
|
||||
// this key exists to prevent, so an unattributable reading gets no fallback rather than a shared one.
|
||||
const account = await readAccountEmail();
|
||||
|
||||
const fresh = await probeLimits(login.accessToken);
|
||||
if (fresh.limits.length) {
|
||||
limitsCache = { at: now, result: fresh };
|
||||
if (account) lastGoodLimits.set(account, { at: now, result: fresh });
|
||||
return { ...fresh, plan: login.plan, measuredAt: now };
|
||||
}
|
||||
|
||||
// A failed probe falls back to the last good reading rather than to nothing — the numbers are minutes old
|
||||
// at worst, and a panel that goes blank on one flaky request is worse than one that is slightly behind.
|
||||
const stale = limitsCache?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
|
||||
return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem };
|
||||
// A failed probe falls back to THIS account's last good reading rather than to nothing — minutes old at
|
||||
// worst, and a panel that blanks on one flaky request is worse than one slightly behind. Windows that have
|
||||
// since reset are dropped: a stale 78% would misreport an allowance that is now untouched.
|
||||
const previous = account ? lastGoodLimits.get(account) : undefined;
|
||||
const stale = previous?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
|
||||
return { limits: stale, plan: login.plan, measuredAt: previous?.at ?? now, problem: fresh.problem };
|
||||
}
|
||||
|
||||
// ── Local transcript scan ──
|
||||
|
||||
@@ -76,6 +76,20 @@ export type ClientMessage =
|
||||
* first turn is the first moment the preference can be acted on OR written down.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
commanderName?: string;
|
||||
/**
|
||||
* 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,12 +15,7 @@ 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,
|
||||
getCommandersDir,
|
||||
} from '../../../servers/data-path';
|
||||
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
|
||||
import { attachedFilePaths } from './attached-files';
|
||||
import {
|
||||
getUserSettings,
|
||||
@@ -37,6 +32,8 @@ import {
|
||||
} 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';
|
||||
@@ -154,16 +151,25 @@ async function resolveChatCwd(
|
||||
userId: number,
|
||||
home: string,
|
||||
): Promise<string> {
|
||||
// Every commander a user has shares ONE directory — never a client-chosen one, and never per-commander
|
||||
// (see `getCommandersDir`). `msg.cwd` is ignored on this branch entirely, the same posture the identity
|
||||
// half of a turn already takes: resolved by the platform, never trusted from the caller.
|
||||
//
|
||||
// `mkdirSync` — synchronous, so this returns only once the directory genuinely exists, the same pattern
|
||||
// `resolveEmailCwd` below already uses. No race: unlike the rejected per-commander design, this directory
|
||||
// is shared and only the very first commander ever needs the mkdir to do anything; `recursive: true`
|
||||
// makes every call after that a no-op stat.
|
||||
/*
|
||||
A commander runs where the owner chose, and the choice is required.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
*/
|
||||
if (msg.context === 'command-hq') {
|
||||
const dir = getCommandersDir(email);
|
||||
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
@@ -351,6 +357,15 @@ function createMessageHandler(
|
||||
resumeSessionId?: string,
|
||||
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.
|
||||
*/
|
||||
commanderName?: string,
|
||||
) {
|
||||
/*
|
||||
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
|
||||
@@ -450,17 +465,54 @@ function createMessageHandler(
|
||||
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;
|
||||
const userId = sessionManager.getSession(sessionId)?.userId;
|
||||
if (!userId) return;
|
||||
void createCommandHqSession(userId, transcriptId).catch((err: unknown) => {
|
||||
logger.warn('Could not file this session as a commander', {
|
||||
sessionId,
|
||||
transcriptId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
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),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (msg: TurnMessage, seq?: number): void => {
|
||||
@@ -511,6 +563,8 @@ function createMessageHandler(
|
||||
// written on its very first turn rather than on its second.
|
||||
if (msg.type === 'session:claude' && msg.claudeSessionId) {
|
||||
transcriptId = msg.claudeSessionId;
|
||||
// The platform's own copy of the mapping — see `transcriptIdBySessionKey`.
|
||||
transcriptIdBySessionKey.set(sessionId, msg.claudeSessionId);
|
||||
record();
|
||||
fileAsCommander();
|
||||
}
|
||||
@@ -519,6 +573,9 @@ 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
|
||||
@@ -734,6 +791,11 @@ 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;
|
||||
@@ -851,6 +913,32 @@ export type ClaudeTurnOutcome = { ok: true; transcriptId: string } | { ok: false
|
||||
* generic timeout: the reasons below (member not signed in, Claude not installed, spawn failure) are the
|
||||
* same reasons a websocket caller already sees via `sendToClient`, just mirrored to a second listener.
|
||||
*/
|
||||
/**
|
||||
* Officer's session key → Claude's transcript id, for the platform's own use.
|
||||
*
|
||||
* The sidecar keeps this mapping and the platform cannot read it: `state.ts` holds it in the sidecar's
|
||||
* memory, and re-loading its file from here does not find a live session. So the platform keeps its own,
|
||||
* filled from the `session:claude` message it already handles.
|
||||
*
|
||||
* This is the third distinct place the two ids have caused a bug today. The mapping is not derivable — it is
|
||||
* announced, once, mid-turn — so anything needing it either records it when it passes or does without.
|
||||
*/
|
||||
const transcriptIdBySessionKey = new Map<string, string>();
|
||||
|
||||
/** The mapping read backwards, for callers holding a transcript id — the delete route, mainly. */
|
||||
export function sessionKeyForTranscript(transcriptId: string): string | undefined {
|
||||
for (const [key, id] of transcriptIdBySessionKey) if (id === transcriptId) return key;
|
||||
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;
|
||||
@@ -861,6 +949,11 @@ 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;
|
||||
@@ -869,12 +962,44 @@ 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;
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
effectivePrompt: string;
|
||||
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
|
||||
}): Promise<void> {
|
||||
const { email, username, userId, ws, sessionId, model, msg, effectivePrompt, onOutcome } = 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.
|
||||
*/
|
||||
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 fail = (error: string) => {
|
||||
sendToClient(ws, { type: 'error', message: error });
|
||||
onOutcome?.({ ok: false, error });
|
||||
@@ -958,6 +1083,7 @@ export async function startClaudeTurn(params: {
|
||||
msg.resumeSessionId,
|
||||
msg.planMode,
|
||||
msg.context === 'command-hq',
|
||||
msg.commanderName,
|
||||
);
|
||||
// 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
|
||||
@@ -1035,6 +1161,11 @@ 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;
|
||||
@@ -1058,6 +1189,11 @@ 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;
|
||||
@@ -1263,13 +1399,20 @@ function handlePlanDecision(
|
||||
): void {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (!sessionId) return;
|
||||
sidecar.decideClaudePlan({
|
||||
const delivered = sidecar.decideClaudePlan({
|
||||
sessionKey: sessionId,
|
||||
userId: ws.data.userId,
|
||||
requestId: msg.requestId,
|
||||
approve: msg.approve,
|
||||
feedback: msg.feedback,
|
||||
});
|
||||
// Same drop as `handleQuestionAnswer`, same spinning card — see `sendFire`.
|
||||
if (!delivered) {
|
||||
sendToClient(ws, {
|
||||
type: 'error',
|
||||
message: 'The agent is not connected right now, so that decision did not reach it. Try again in a moment.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1283,12 +1426,29 @@ function handlePlanDecision(
|
||||
function handleQuestionAnswer(ws: ServerWebSocket<WSData>, msg: { toolCallId: string; answer: string }): void {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (!sessionId) return;
|
||||
sidecar.answerClaudeQuestion({
|
||||
/*
|
||||
A dropped answer has to reach the person who sent it.
|
||||
|
||||
`sendFire` does nothing when no sidecar is registered, and that window is real — every restart of this
|
||||
process has one while the agent reconnects. The answer vanished with no log and no error, so the
|
||||
question card spun on, which is indistinguishable from a slow agent. The only move left was to send
|
||||
another message, which is the worst possible thing to do to a waiting tool call.
|
||||
|
||||
The agent's own handler already reports the failures it CAN see (no such session, question no longer
|
||||
waiting). This is the one it cannot, because the command never arrives.
|
||||
*/
|
||||
const delivered = sidecar.answerClaudeQuestion({
|
||||
sessionKey: sessionId,
|
||||
userId: ws.data.userId,
|
||||
toolCallId: msg.toolCallId,
|
||||
answer: msg.answer,
|
||||
});
|
||||
if (!delivered) {
|
||||
sendToClient(ws, {
|
||||
type: 'error',
|
||||
message: 'The agent is not connected right now, so that answer did not reach it. Try again in a moment.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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');
|
||||
}
|
||||
@@ -5,11 +5,23 @@ import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { getChatEventsSince } from 'officerdb';
|
||||
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 type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
loadClaudeSessionById,
|
||||
loadClaudeSessionSummaryById,
|
||||
renameClaudeSession,
|
||||
deleteClaudeSession,
|
||||
type ChatIdentity,
|
||||
@@ -55,11 +67,17 @@ import {
|
||||
*
|
||||
* ── Where commanders live ──
|
||||
*
|
||||
* Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per
|
||||
* commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function
|
||||
* `/chat` uses for its own per-directory list) pointed at that one directory, filtered to sessions with a
|
||||
* `command_hq_sessions` row. No new session-scanning code exists here; this router is a client of the same
|
||||
* transcript reader `/chat` already is.
|
||||
* **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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
@@ -194,12 +212,24 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const categoryParam = ctx.req.query('category');
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
const [rows, live] = await Promise.all([
|
||||
listCommandHqSessions(user.id, categoryId),
|
||||
listClaudeSessions(who, getCommandersDir(who.email)),
|
||||
]);
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory.
|
||||
|
||||
const liveById = new Map(live.map((s) => [s.id, s]));
|
||||
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.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const summaries = rows
|
||||
.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 [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
|
||||
@@ -219,10 +249,17 @@ 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.
|
||||
...(row.name ? { title: row.name } : {}),
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
watcherArmed: row.watcherArmed,
|
||||
// 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,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -265,8 +302,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = await ctx.req
|
||||
.json<{ prompt?: string; displayText?: string; categoryId?: number | null }>()
|
||||
.catch(() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null });
|
||||
.json<{ prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }>()
|
||||
.catch(
|
||||
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
|
||||
);
|
||||
const prompt = body.prompt?.trim();
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
|
||||
@@ -302,7 +341,17 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
ws: null,
|
||||
sessionId,
|
||||
model,
|
||||
msg: { prompt, displayText: body.displayText?.trim() || undefined, context: 'command-hq' },
|
||||
msg: {
|
||||
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() } : {}),
|
||||
},
|
||||
effectivePrompt: prompt,
|
||||
onOutcome: settle,
|
||||
});
|
||||
@@ -322,7 +371,9 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: getCommandersDir(user.email) });
|
||||
// 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 });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -350,10 +401,11 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
]);
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -417,8 +469,10 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const sessionId = body.sessionId?.trim();
|
||||
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
|
||||
|
||||
const live = listClaudeSessions(who, getCommandersDir(who.email));
|
||||
if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory');
|
||||
// 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 createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
|
||||
return ctx.json(row);
|
||||
@@ -451,7 +505,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
await setCommandHqSessionCategory(user.id, sessionId, body.categoryId);
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
|
||||
// `who.home` as the HINT only — `findTranscript` locates the transcript wherever it actually is, and a
|
||||
// commander's directory is its own choice now.
|
||||
const ok = renameClaudeSession(who, who.home, sessionId, body.name.trim());
|
||||
if (!ok) throw errors.NOT_FOUND('transcript not found');
|
||||
}
|
||||
if (body.watcherArmed === false) {
|
||||
@@ -469,11 +525,25 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
|
||||
* agree by deleting the thing itself, on purpose, because the owner asked to.
|
||||
*
|
||||
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript
|
||||
* is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to
|
||||
* update once this returns, and the running background process (if any) dies on its own the same way it
|
||||
* would if the harness process were killed out from under any other conversation — not a new failure
|
||||
* mode this route has to account for.
|
||||
* ── It cascades, and it has to ──
|
||||
*
|
||||
* Deleting a commander means being finished with it and with everything it produced. So, in this order:
|
||||
*
|
||||
* 1. STOP the processes. The loop the commander started is a descendant of the commander's own `claude`,
|
||||
* and any agent the loop spawned is a descendant of the loop, so one `killTree` from the session's
|
||||
* process takes all of it. The comment here used to claim the background process "dies on its own" —
|
||||
* it does not. Nothing in this route touched a process, so a deleted commander left its loop running:
|
||||
* still polling, still able to fire, still able to spawn an agent, with no chip to stop it from and no
|
||||
* row left to disarm.
|
||||
* 2. DELETE the children's transcripts, read from the spawned record. They cannot be found any other way
|
||||
* — see `spawned.ts` for the three approaches that do not work.
|
||||
* 3. DELETE the commander's own transcript and row, as before.
|
||||
*
|
||||
* Stopping first is deliberate: a live agent writing to a transcript being deleted is a race, and the loop
|
||||
* could spawn a new one between steps 2 and 3.
|
||||
*
|
||||
* A watcher still armed is not disarmed first, and does not need to be — the row goes away entirely, and
|
||||
* `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find one to update.
|
||||
*/
|
||||
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -483,9 +553,111 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
|
||||
/*
|
||||
1. Everything this commander is still running — the SESSION first, then the loop.
|
||||
|
||||
Killing the loop alone is not enough, and its own test proved it: the loop is a grandchild of the
|
||||
commander's `claude` process, so ending the loop leaves the session alive — and a live session REWRITES
|
||||
its transcript after the file is deleted. Twice tonight a deleted commander's transcript reappeared, and
|
||||
twice it read as the delete having failed.
|
||||
|
||||
`killClaudeSession` takes officer's session key while this route holds a transcript id, so the map is
|
||||
read backwards — `findSessionKeyByClaudeSession`, which exists for the same reason a refreshed browser
|
||||
holds only the uuid. Ending the session takes its children with it, and the pid-based stop below then
|
||||
catches anything that outlived its parent.
|
||||
*/
|
||||
// Same mapping problem as the briefing: the sidecar's map is not visible from here, so the platform's own
|
||||
// is asked first and the sidecar's is kept as a fallback for a session that predates this process.
|
||||
/*
|
||||
Third fallback, and the only one that survives a restart.
|
||||
|
||||
Both maps above are in-memory and answer only for a session younger than this process. After a
|
||||
restart they both return undefined, no kill is sent, and the transcript is deleted while the harness
|
||||
is still writing — so it writes it back and the deleted commander returns as an untitled stub. Seen
|
||||
twice tonight, after the fix that was meant to prevent exactly this.
|
||||
*/
|
||||
const sessionKey =
|
||||
sessionKeyForTranscript(sessionId) ??
|
||||
findSessionKeyByClaudeSession(sessionId, user.id) ??
|
||||
(await sessionKeyForTranscriptId(sessionId).catch(() => undefined));
|
||||
/*
|
||||
Across the socket, and AWAITED — both halves matter.
|
||||
|
||||
`killClaudeSession` from `claude-manager` was called directly here, and it could only ever return false:
|
||||
the session map lives in the `officer-claude-code` process and this route runs in `officer`, so it was
|
||||
reading an empty map in the wrong process. The kill silently never happened.
|
||||
|
||||
Awaited because the next thing this route does is delete the transcript, and the `claude` CLI owns that
|
||||
file. Deleting it while the CLI is still unwinding removes a file that is written straight back — which
|
||||
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);
|
||||
|
||||
// 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(
|
||||
(c) => c.spawnedSessionId,
|
||||
);
|
||||
let childrenDeleted = 0;
|
||||
// Every transcript id actually removed — the commander's and its children's, chains included. Collected
|
||||
// rather than assumed, because a `/clear` chain is several ids behind one conversation.
|
||||
const forgotten: string[] = [];
|
||||
for (const child of children) {
|
||||
try {
|
||||
// A spawned agent runs in the platform checkout, not the commanders directory, so its transcript
|
||||
// lives in that project's group — the same lookup `/chat/sessions/:id` falls back to.
|
||||
// The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the
|
||||
// transcript wherever it actually is, which is what makes this work for a child that ran in a
|
||||
// different directory from its parent.
|
||||
//
|
||||
// `.length`, not truthiness: this returns the ids it removed, and an empty array is truthy.
|
||||
const removedChild = deleteClaudeSession(who, who.home, child);
|
||||
if (removedChild.length) {
|
||||
childrenDeleted += 1;
|
||||
forgotten.push(...removedChild);
|
||||
}
|
||||
} catch {
|
||||
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||
}
|
||||
}
|
||||
clearSpawnedRecord(who.email, sessionId);
|
||||
|
||||
// 3. The commander itself.
|
||||
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
|
||||
await deleteCommandHqSession(user.id, sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
/*
|
||||
Forget what the platform stored ABOUT every transcript that just went — the commander's and its
|
||||
children's, in one call.
|
||||
|
||||
The `command_hq_*` tables cascade from the row, because they reference it. These four do not: they are
|
||||
keyed on a transcript id, which is a FILE, so there is no row for them to reference and nothing to
|
||||
cascade from. Left behind they are mostly inert — a model, a bell preference, a durable copy of turn
|
||||
output — except `nudges`, which is a live schedule that would keep firing at a conversation that no
|
||||
longer exists.
|
||||
*/
|
||||
/*
|
||||
`sessionKeys` carries the commander's own key and nothing else, on purpose. Children are spawned by
|
||||
`claude -p` outside officer's websocket, so they never had a key and never wrote a durable event — an
|
||||
empty entry for them would be noise, not caution.
|
||||
|
||||
`sessionKey` is resolved above for the kill and reused here rather than looked up twice; when it is null
|
||||
the session was already gone, and so were its events.
|
||||
*/
|
||||
await forgetChatSessions({ transcriptIds: forgotten, sessionKeys: sessionKey ? [sessionKey] : [] });
|
||||
|
||||
// Said back rather than a bare ok: this is the one irreversible action here, and the counts are what
|
||||
// make it auditable after the fact.
|
||||
return ctx.json({
|
||||
ok: true,
|
||||
sessionKilled,
|
||||
processesStopped: stopped,
|
||||
childrenFound: children.length,
|
||||
childrenDeleted,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -498,8 +670,16 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
commandHqRouter.post('/purge', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const live = listClaudeSessions(who, getCommandersDir(who.email));
|
||||
const removed = await purgeCommandHqSessions(user.id, new Set(live.map((s) => s.id)));
|
||||
/*
|
||||
"Still exists" is now per row, wherever that row's transcript lives.
|
||||
|
||||
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.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id);
|
||||
const stillThere = new Set(rows.map((row) => row.sessionId).filter((id) => !!loadClaudeSessionSummaryById(who, id)));
|
||||
const removed = await purgeCommandHqSessions(user.id, stillThere);
|
||||
return ctx.json({ removed });
|
||||
});
|
||||
|
||||
@@ -518,6 +698,59 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/children
|
||||
*
|
||||
* The agents this commander spawned, ingested from the loop's inbox on the way through.
|
||||
*
|
||||
* Ingesting on READ rather than on a timer is deliberate: a cron for this is a moving part that can stop
|
||||
* without anybody noticing, and the read is the only moment the answer is wanted. `onConflictDoNothing` on
|
||||
* the unique index makes re-ingesting the whole file cost nothing, so this is safe to call as often as the
|
||||
* UI likes.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/children', 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');
|
||||
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
|
||||
// `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.
|
||||
const known = children.filter((c) => c.totalUsd !== null);
|
||||
return ctx.json({
|
||||
children,
|
||||
totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0),
|
||||
unknownCost: children.length - known.length,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/record-paths
|
||||
*
|
||||
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
return ctx.json({
|
||||
loopPidPath: loopPidPath(who.email, sessionId),
|
||||
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
||||
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderStateDir } from '../../data-path';
|
||||
import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
/*
|
||||
What a commander spawned, and why the platform cannot work it out for itself.
|
||||
|
||||
A commander's action is a headless agent — `claude -p`, started by the shell loop the commander itself
|
||||
started. The platform never sees that spawn: it happens inside a background job, two processes below a
|
||||
conversation, and the agent talks to Anthropic rather than to us.
|
||||
|
||||
Three ways to recover the link were tried and none works:
|
||||
|
||||
- the PROCESS holds nothing. No open transcript descriptor (it appends and closes), and nothing
|
||||
identifying in its environment. Verified 2026-08-22.
|
||||
- the TRANSCRIPT is indistinguishable. `queue-operation`, `ai-title`, `last-prompt`, `atis-latch` look
|
||||
like markers the platform injects and are the harness's own, appearing in every session equally. A
|
||||
discriminator built on them rejected a real child and reported zero when one was running.
|
||||
- CONTENT matching finds the wrong sessions. An ordinary chat that happens to quote a procedure matches
|
||||
as well as the agent following it.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
const FILE = 'spawned';
|
||||
const PID_FILE = 'loop-pid';
|
||||
|
||||
/** A session id as the harness writes them. Anything else is a corrupt line and is dropped, not trusted. */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every agent this commander recorded, oldest first, de-duplicated.
|
||||
*
|
||||
* Tolerant on purpose. The writer is a shell loop appending under whatever conditions it is in, so a
|
||||
* 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);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const seen = new Set<string>();
|
||||
for (const raw of readFileSync(path, 'utf-8').split('\n')) {
|
||||
const id = raw.trim();
|
||||
if (SESSION_ID.test(id)) seen.add(id.toLowerCase());
|
||||
}
|
||||
return [...seen];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one, idempotently. For the platform's own use — the loop appends directly, by design.
|
||||
*
|
||||
* 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 {
|
||||
if (!SESSION_ID.test(spawnedId)) return;
|
||||
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||
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 {
|
||||
try {
|
||||
rmSync(getCommanderStateDir(email, commanderSessionId), { 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.
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
── Stopping what a commander is running ──
|
||||
|
||||
The platform has no session-to-process map: `claude-manager` spawns through the SDK and keeps no pid, and
|
||||
a `claude` process cannot be attributed to a particular commander from outside — every commander's session
|
||||
runs with the same cwd, so the process table cannot tell them apart.
|
||||
|
||||
So the loop records its own pid, the same way it records what it spawns, and for the same reason: it is the
|
||||
only party that knows. From that one number the whole tree follows — the loop's children include any agent
|
||||
it spawned, and `descendantPids` was written for exactly this ("Bun's proc.kill() signals only the direct
|
||||
child, so a running grandchild is left orphaned and keeps going").
|
||||
|
||||
A commander with no recorded pid stops nothing and SAYS so. That is the honest answer for a commander armed
|
||||
before this existed, and pretending otherwise would report a clean delete over a loop still polling.
|
||||
*/
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
try {
|
||||
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
|
||||
// error — loops end — and reusing a dead pid's number would kill something unrelated.
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the loop and everything under it. Returns how many processes were signalled.
|
||||
*
|
||||
* 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);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
let signalled = 0;
|
||||
for (const target of tree) {
|
||||
try {
|
||||
process.kill(target, 'SIGTERM');
|
||||
signalled += 1;
|
||||
} catch {
|
||||
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
return signalled;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { sessionStatus, startSession, stopSession } from './session';
|
||||
|
||||
/**
|
||||
* Session control. The pixels do not come through here — they come through the websocket relay; this
|
||||
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
|
||||
* canvas.
|
||||
*/
|
||||
export const minesweeperRouter = createRouter();
|
||||
|
||||
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
|
||||
|
||||
minesweeperRouter.post('/start', async (c) => {
|
||||
const status = await sessionStatus();
|
||||
if (!status.installed) {
|
||||
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
|
||||
}
|
||||
if (status.running) return c.json(status);
|
||||
|
||||
const result = await startSession();
|
||||
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
|
||||
minesweeperRouter.post('/stop', async (c) => {
|
||||
await stopSession();
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ROOT } from '../../data-path';
|
||||
|
||||
/**
|
||||
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
|
||||
* display on loopback.
|
||||
*
|
||||
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
|
||||
* used by hand. One implementation, so a session started from the browser and one started from a
|
||||
* terminal are the same session with the same lifecycle.
|
||||
*/
|
||||
|
||||
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
|
||||
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
|
||||
|
||||
export const VNC_HOST = '127.0.0.1';
|
||||
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
|
||||
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
|
||||
|
||||
export type WindowGeometry = { width: number; height: number; x: number; y: number };
|
||||
export type SessionStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
/** Absent until the game has drawn its window. */
|
||||
window: WindowGeometry | null;
|
||||
};
|
||||
|
||||
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: MINESWEEPER_DIR,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, out: `${out}${err}` };
|
||||
};
|
||||
|
||||
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
|
||||
|
||||
/**
|
||||
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
|
||||
*
|
||||
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
|
||||
* new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop
|
||||
* until the client speaks or the peek times out. A health check that connects and says nothing is
|
||||
* therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept
|
||||
* backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too.
|
||||
*
|
||||
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
|
||||
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
|
||||
*/
|
||||
async function isServing(): Promise<boolean> {
|
||||
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
|
||||
const LISTEN = '0A';
|
||||
|
||||
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
||||
const text = await Bun.file(table)
|
||||
.text()
|
||||
.catch(() => '');
|
||||
|
||||
for (const line of text.split('\n').slice(1)) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
const local = columns[1];
|
||||
const state = columns[3];
|
||||
if (!local || state !== LISTEN) continue;
|
||||
if (local.split(':')[1] === wanted) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The game window's size, so the browser can size its canvas to the board rather than to the whole
|
||||
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
|
||||
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
|
||||
*
|
||||
* The board changes size when the difficulty changes, which is why this is polled rather than read
|
||||
* once at connect.
|
||||
*/
|
||||
export async function windowGeometry(): Promise<WindowGeometry | null> {
|
||||
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
|
||||
if (!search.ok) return null;
|
||||
|
||||
let best: WindowGeometry | null = null;
|
||||
for (const id of search.out.trim().split('\n').filter(Boolean)) {
|
||||
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
|
||||
if (!geom.ok) continue;
|
||||
|
||||
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
|
||||
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
|
||||
if (Object.values(candidate).some(Number.isNaN)) continue;
|
||||
|
||||
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
|
||||
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
|
||||
}
|
||||
return best && best.width > 1 ? best : null;
|
||||
}
|
||||
|
||||
export async function sessionStatus(): Promise<SessionStatus> {
|
||||
const installed = isInstalled();
|
||||
if (!installed) return { installed, running: false, window: null };
|
||||
|
||||
const running = await isServing();
|
||||
return { installed, running, window: running ? await windowGeometry() : null };
|
||||
}
|
||||
|
||||
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
|
||||
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import { VNC_HOST, VNC_PORT } from './session';
|
||||
|
||||
/**
|
||||
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
|
||||
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
|
||||
* reading them.
|
||||
*
|
||||
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
|
||||
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
|
||||
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
|
||||
* and chunks are forwarded exactly as they arrive.
|
||||
*/
|
||||
|
||||
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
|
||||
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, code: number, reason: string) => {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
|
||||
export const minesweeperWebsocket = {
|
||||
open(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
|
||||
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
|
||||
// version string would otherwise lose it.
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
void Bun.connect({
|
||||
hostname: VNC_HOST,
|
||||
port: VNC_PORT,
|
||||
socket: {
|
||||
open(socket) {
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
relay.queue.length = 0;
|
||||
},
|
||||
data(_socket, chunk) {
|
||||
try {
|
||||
ws.send(chunk);
|
||||
} catch {
|
||||
/* client gone; the close handler tears down the socket */
|
||||
}
|
||||
},
|
||||
close: () => closeClient(ws, 1000, 'vnc closed'),
|
||||
error: () => closeClient(ws, 1011, 'vnc error'),
|
||||
},
|
||||
}).catch(() => {
|
||||
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
|
||||
// panel can say so instead of showing an empty canvas.
|
||||
closeClient(ws, 1011, 'minesweeper session is not running');
|
||||
});
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
|
||||
// RFB is binary in both directions; a text frame is a client that is not speaking it.
|
||||
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
|
||||
if (relay.socket) relay.socket.write(bytes);
|
||||
else relay.queue.push(bytes);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { join } from 'node:path';
|
||||
import { getHomeDir, getOwnerHomeDir } from '@@/data-path';
|
||||
|
||||
/** `Documents/SendOff` under whichever home this account actually browses. */
|
||||
export const SENDOFF_SUBPATH = join('Documents', 'SendOff');
|
||||
|
||||
/**
|
||||
* The SendOff directory for an account, in the home its own file browser shows.
|
||||
*
|
||||
* The two homes are not interchangeable and picking the wrong one makes a delivery invisible: the owner
|
||||
* browses their real login home (`/home/pastilhas`), while another account browses the managed home under
|
||||
* `DATA_PATH/<email>/home`. `getOwnerHomeDir` ignores the email it is passed and always answers the owner,
|
||||
* so it can only be used once the role has been checked — which is why this takes the role rather than
|
||||
* working it out from the email.
|
||||
*/
|
||||
export function sendoffDirFor(account: { email: string; isOwner: boolean }): string {
|
||||
const home = account.isOwner ? getOwnerHomeDir(account.email) : getHomeDir(account.email);
|
||||
return join(home, SENDOFF_SUBPATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder name safe to create, from a name a person or a device chose.
|
||||
*
|
||||
* Anything that is not a letter, digit, dash, underscore or dot becomes a dash, and leading dots go — so a
|
||||
* name cannot produce `..`, a hidden directory, or a path with a separator in it. Empty after that is
|
||||
* refused by the caller rather than silently becoming a directory called `-`.
|
||||
*/
|
||||
export function safeSegment(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filename safe to write, preserving the extension.
|
||||
*
|
||||
* Same rules as a segment, with the basename taken first: a browser will happily send `../../etc/passwd`
|
||||
* as a filename and the multipart spec does not forbid it.
|
||||
*/
|
||||
export function safeFilename(name: string): string {
|
||||
const base = name.split(/[/\\]/).pop() ?? '';
|
||||
const cleaned = base
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^\.+/, '')
|
||||
.slice(0, 200);
|
||||
return cleaned || 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* The name an account appears as, and therefore the name of the folder its files land in.
|
||||
*
|
||||
* One definition, used by the writer AND by `/targets`, because the two must agree: a picker that says
|
||||
* "Fedra" while the writer creates `fedra.almeida` has told somebody the wrong place to look. Mobile asked
|
||||
* for this to be served rather than derived, which is right — three clients reimplementing a fallback is
|
||||
* how three clients disagree.
|
||||
*
|
||||
* `users.name` is nullable, so the fallback chain ends at the account id, which is always there.
|
||||
*/
|
||||
export function displayNameFor(account: { id: number; name: string | null; email: string }): string {
|
||||
const chosen = account.name?.trim();
|
||||
if (chosen) return chosen;
|
||||
const local = account.email.split('@')[0]?.trim();
|
||||
return local || `user-${account.id}`;
|
||||
}
|
||||
|
||||
/** What that name becomes on disk — the same value, through the segment sanitiser. */
|
||||
export const folderNameFor = (account: { id: number; name: string | null; email: string }): string =>
|
||||
safeSegment(displayNameFor(account)) || `user-${account.id}`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user