Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03fbb1189f | ||
|
|
21665ed024 | ||
|
|
111618bd7f | ||
|
|
9cb875ca79 | ||
|
|
c5b95becd2 | ||
|
|
f4b9596a88 | ||
|
|
09518e1e1f | ||
|
|
500bb925eb | ||
|
|
a970cb7c5f | ||
|
|
fb1cf0f6f1 | ||
|
|
a947df8d9d | ||
|
|
78bf63c209 | ||
|
|
347acf0171 | ||
|
|
766b18b7f9 | ||
|
|
e4f5e03105 | ||
|
|
3b0083aec2 | ||
|
|
58f50a5240 | ||
|
|
43a4463978 | ||
|
|
de425d9d68 | ||
|
|
22a4c65776 | ||
|
|
0d310ab7b0 | ||
|
|
16fa869260 | ||
|
|
3d936309a5 | ||
|
|
9a70089d6b | ||
|
|
e2d03394e1 | ||
|
|
cac58ea044 | ||
|
|
5c6c5b31f8 | ||
|
|
d273dc6a14 | ||
|
|
4e0a065f0e | ||
|
|
f4685eeafe | ||
|
|
87d1740caf | ||
|
|
9c8af9d424 | ||
|
|
fb0cc880ac | ||
|
|
02ad4d3bdf | ||
|
|
91147b403b | ||
|
|
ec6c793143 | ||
|
|
421e742590 | ||
|
|
6e805c3607 | ||
|
|
bf8bfa6859 | ||
|
|
9ec60d6d75 | ||
|
|
15392ae6c2 | ||
|
|
02f20d419d | ||
|
|
3b1d4cea84 | ||
|
|
3082949fac | ||
|
|
c226e8a46d | ||
|
|
f0801808c4 | ||
|
|
a873690e27 | ||
|
|
e867fe72af | ||
|
|
e6da5d8c77 | ||
|
|
0918dd7443 | ||
|
|
3fac1460cd | ||
|
|
f00abe816e | ||
|
|
6762a32ea6 | ||
|
|
68f3643df7 | ||
|
|
0d5c2c0083 | ||
|
|
b4cf1c6427 | ||
|
|
8705dc3a7c | ||
|
|
c14b686782 | ||
|
|
68d6dd942e | ||
|
|
d58f58813c | ||
|
|
276b12607a | ||
|
|
1c28a48299 | ||
|
|
5d51212cca | ||
|
|
a865bcfdfc | ||
|
|
d381140d54 | ||
|
|
2360a3ef60 | ||
|
|
185aa3168e | ||
|
|
fdf50ce2a4 | ||
|
|
52f209c69f | ||
|
|
e0df28ff5d | ||
|
|
6b5c87da10 | ||
|
|
131af603ff | ||
|
|
8a3047b06b | ||
|
|
2e7fb84454 | ||
|
|
5dc9f2d5f7 | ||
|
|
d02aac1eb8 | ||
|
|
e37fe8330e | ||
|
|
681df1c8bc | ||
|
|
7e6cfc85af | ||
|
|
be210c3ed4 | ||
|
|
d58652bf14 | ||
|
|
f6bd7564bb | ||
|
|
84d9c9d2d8 | ||
|
|
513a50f2e8 | ||
|
|
8b3ad8418a | ||
|
|
20d60cd3be | ||
|
|
f119bbdf93 | ||
|
|
de538cfc1b | ||
|
|
2faf1fa4b2 | ||
|
|
b5385ff40f | ||
|
|
0a4d7f8ad9 | ||
|
|
121a77ce7f | ||
|
|
a7c9dab22c | ||
|
|
5005726994 |
@@ -1,41 +1,594 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for any coding agent working in the Officer platform repo.
|
||||
|
||||
**The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.**
|
||||
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
Guidance for any coding agent working in the Officer platform repo. **This is the file with the
|
||||
instructions in it**; `CLAUDE.md` beside it only points here. Both exist because the two harnesses
|
||||
discover different names — opencode walks the tree for `AGENTS.md` and nothing else, Claude Code for
|
||||
`CLAUDE.md` — and one set of instructions must reach both.
|
||||
|
||||
## Orientation
|
||||
|
||||
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
|
||||
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
|
||||
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
|
||||
per-role permission grants, resolved on every request.
|
||||
Officer is built around **one owner** (user id 1, role `Super Admin`, who bypasses every permission
|
||||
check), which since 2026-08-07 also admits **additional accounts holding a strict subset of it**. What
|
||||
each may reach is decided by per-role permission grants, resolved on every request.
|
||||
|
||||
If a design question turns on "which user", the answer depends on the surface: real for the **app**
|
||||
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything
|
||||
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
|
||||
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything that
|
||||
executes code or touches the disk — terminal, chat, tasks, files, desktop and browser are
|
||||
`kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority.
|
||||
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions"
|
||||
section of `CLAUDE.md` before adding one.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the Permissions
|
||||
section below before adding one.
|
||||
|
||||
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
|
||||
management". That was written to correct an _older_ drift in the opposite direction — a fictional
|
||||
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
|
||||
paragraph above; treat the permission registry as the source of truth over either.
|
||||
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as plain
|
||||
files, and is where many changes belong — adding or changing a task needs no code change here and no
|
||||
restart.
|
||||
|
||||
This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as
|
||||
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
||||
and no restart.
|
||||
|
||||
- [`CLAUDE.md`](CLAUDE.md) — this repo's architecture, conventions and code style
|
||||
- [`docs/working-on-officer.md`](docs/working-on-officer.md) — the deployment-wide guide: which
|
||||
directory a change belongs in, how to run and verify it, the task system, and the failure modes
|
||||
worth knowing about
|
||||
directory a change belongs in, how to run and verify it, the task system, and the failure modes worth
|
||||
knowing about
|
||||
- `CONVENTIONS.md` — component organisation and React patterns, with rationale
|
||||
- `TODO.md` — current direction; takes precedence where it disagrees with anything else
|
||||
|
||||
Treat the code as the source of truth where anything disagrees with it.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
|
||||
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
|
||||
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
|
||||
behind a single web app.
|
||||
|
||||
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
|
||||
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
|
||||
written: `users` holds six rows. The accurate statement is narrower and more useful —
|
||||
|
||||
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
||||
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
||||
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
|
||||
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
|
||||
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
|
||||
home, and the kernel refusing everything above it.
|
||||
|
||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
|
||||
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
|
||||
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
|
||||
HTTP routes, the websocket doors and the dock together.
|
||||
|
||||
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
|
||||
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||
anything under `execution`.
|
||||
|
||||
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||
|
||||
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||
the counts stay right and the sentences rot.
|
||||
|
||||
## Architecture
|
||||
|
||||
One Bun process (`src/server.tsx`) serves everything:
|
||||
|
||||
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
|
||||
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
|
||||
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
||||
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
||||
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
||||
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
|
||||
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
|
||||
|
||||
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
||||
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
||||
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
||||
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
||||
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
||||
source of truth.
|
||||
|
||||
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
|
||||
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
|
||||
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
||||
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
||||
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
||||
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
|
||||
|
||||
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
|
||||
deliberate — it is the owner's own machine. Do not add a jail without being asked.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
||||
├── apps/
|
||||
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
||||
├── servers/
|
||||
│ ├── hono.ts # router composition; everything under /api
|
||||
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
|
||||
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||
│ ├── queue/ # background job engine
|
||||
│ └── sidecar/ # sidecar implementations + the wire protocol
|
||||
├── databases/officer_db/ # the only database (Postgres + Drizzle)
|
||||
├── extensions/browser-relay/
|
||||
└── workspaces/ # shared packages, each a bun workspace
|
||||
```
|
||||
|
||||
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
|
||||
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
|
||||
`src/apps/officer-web` is only the shell — screens, routing and settings.
|
||||
|
||||
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
|
||||
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
|
||||
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
|
||||
|
||||
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
|
||||
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
|
||||
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
|
||||
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
|
||||
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
|
||||
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
|
||||
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
|
||||
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
|
||||
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
|
||||
|
||||
## Data
|
||||
|
||||
Two stores, and the split matters:
|
||||
|
||||
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
||||
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
|
||||
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
|
||||
catalogue served by a process with no login, no session and no user, so sharing a connection would put
|
||||
an unauthenticated surface one query away from every private table. It runs only on the host serving
|
||||
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
|
||||
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
|
||||
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
|
||||
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
|
||||
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
|
||||
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
|
||||
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
|
||||
what is inside it.
|
||||
|
||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
|
||||
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
|
||||
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
|
||||
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
|
||||
failing.
|
||||
|
||||
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
|
||||
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
|
||||
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
|
||||
passed).
|
||||
|
||||
### Schema changes use `push`, not migrations
|
||||
|
||||
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
|
||||
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
|
||||
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
|
||||
|
||||
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
|
||||
history was deleted because it had drifted from the real schema. Treat the schema code, not those
|
||||
files, as the source of truth.
|
||||
|
||||
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
|
||||
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
|
||||
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
|
||||
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
||||
"Composite keys" before adding either.
|
||||
|
||||
## Security Model
|
||||
|
||||
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
||||
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
|
||||
rules key off it — they fail closed.
|
||||
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
|
||||
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
|
||||
what came out was documented defence in depth that was already switched off. Origin was never
|
||||
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
|
||||
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
|
||||
is a valid token on every protected route plus the permission gate below.
|
||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||
authenticated request until the server restarts.
|
||||
|
||||
### Permissions — read this before mounting a router
|
||||
|
||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
|
||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||
`userMiddleware`.
|
||||
|
||||
- `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). **20 entries as of 2026-08-29** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 5 admin. It was 21 until the todo board became the `kanban` plugin and took `todos` with it — the same shape as caldav below, and for the same reason: a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `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`.
|
||||
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
|
||||
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
|
||||
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
||||
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
||||
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
||||
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
|
||||
survive the next door; refusing to boot does.
|
||||
|
||||
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
|
||||
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
||||
is how the hole happened the first time.
|
||||
|
||||
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||
into a refusal.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun dev # the whole app — SPA + API + WebSockets, watched
|
||||
bun start # production
|
||||
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,
|
||||
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
|
||||
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
|
||||
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
|
||||
needs to, and on macOS never does, because Homebrew refuses to run as root.
|
||||
|
||||
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
||||
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
||||
|
||||
### Installs are frozen. Never resolve a dependency you did not ask for.
|
||||
|
||||
`bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and
|
||||
nothing else** — it fails rather than quietly picking up a newer version, including a transitive one
|
||||
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
|
||||
`error: lockfile had changes, but lockfile is frozen`.
|
||||
|
||||
It is config rather than a habit because a supply-chain compromise does not wait for the one time
|
||||
somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`,
|
||||
`file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were
|
||||
published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes
|
||||
credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself
|
||||
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
|
||||
unaffected only because nothing had installed since 2026-08-02.
|
||||
|
||||
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
|
||||
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
|
||||
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
|
||||
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
|
||||
in a diff is the signal this exists to produce.
|
||||
|
||||
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
|
||||
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
|
||||
|
||||
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
|
||||
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
|
||||
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
||||
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Paradigm**: functional — pure functions, immutability, composition
|
||||
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
||||
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
|
||||
- **Async**: always async/await
|
||||
- **Exports**: named only, no defaults
|
||||
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
||||
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
|
||||
|
||||
### Imports
|
||||
|
||||
Order: types → external → workspace → relative.
|
||||
|
||||
```ts
|
||||
import type { User } from 'types';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { formatDate } from 'helpers/formatters';
|
||||
import { useWebsites } from '../useWebsites';
|
||||
```
|
||||
|
||||
In app code import types from `'types'`, which re-exports the database types. Only server and
|
||||
database code imports from `officerdb/types` directly.
|
||||
|
||||
### Functions
|
||||
|
||||
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
|
||||
parameter lists — extract a params type instead.
|
||||
|
||||
```ts
|
||||
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
|
||||
export function processData({ body, query }: ProcessDataParams): Result { ... }
|
||||
```
|
||||
|
||||
### Components and hooks
|
||||
|
||||
```tsx
|
||||
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
|
||||
|
||||
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
|
||||
```
|
||||
|
||||
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
|
||||
always named `ev`, never `e`.
|
||||
|
||||
### API calls
|
||||
|
||||
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
|
||||
type parameter and let inference flow from it.
|
||||
|
||||
## Working With Me
|
||||
|
||||
- **Ask first** — confirm the approach before a significant change
|
||||
- **Explore thoroughly** — read the related files before editing
|
||||
- **Keep it simple** — no over-engineering, no premature abstraction
|
||||
- **Be explicit** — no magic, no implicit behaviour
|
||||
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
||||
- **Report honestly** — say what you verified and what you didn't
|
||||
|
||||
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
|
||||
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
|
||||
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
|
||||
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
|
||||
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
|
||||
withholding the commit until it is good.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes.
|
||||
|
||||
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
|
||||
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
|
||||
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
|
||||
the two-step form only looks like it does.
|
||||
|
||||
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
|
||||
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
|
||||
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||
```
|
||||
|
||||
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||
restart in a single night. The transcript id survived both.
|
||||
|
||||
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||
whose "why" is still answerable in six months.
|
||||
|
||||
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||
add the trailer and one that does not should leave it out rather than guess.
|
||||
|
||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||
|
||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||
|
||||
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
|
||||
`<WorkspaceView workspace={ws} locked />` where
|
||||
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
|
||||
that pins `appType`s to an allow-list. Panels are windowed apps under
|
||||
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
|
||||
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
|
||||
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
|
||||
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
|
||||
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
|
||||
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
|
||||
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
|
||||
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
|
||||
`usePublishPageTitle` in `officerdev`.
|
||||
|
||||
### The URL is the source of truth for selection
|
||||
|
||||
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
|
||||
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
|
||||
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
|
||||
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
|
||||
link-focusable). Half the app still does this; none of the new code should.
|
||||
|
||||
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
|
||||
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
|
||||
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
|
||||
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
|
||||
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
|
||||
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
|
||||
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
||||
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
||||
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
||||
_mutates_ rather than navigates stays a `<button>`.
|
||||
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
||||
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
|
||||
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
||||
canonicalises both the bare route and a bogus param.
|
||||
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
|
||||
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
|
||||
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
|
||||
Action buttons are **siblings** of the anchor, never nested inside it.
|
||||
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
|
||||
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
|
||||
when you want `isActive`.
|
||||
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
|
||||
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
||||
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
||||
|
||||
## COMMS — a channel between agents, when one is open
|
||||
|
||||
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
|
||||
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
|
||||
sessions.
|
||||
|
||||
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
|
||||
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
|
||||
convention rather than an oversight: a spent channel left in place gets read as current.
|
||||
|
||||
If you open one:
|
||||
|
||||
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
|
||||
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
|
||||
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
|
||||
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
|
||||
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
|
||||
agent read identically.
|
||||
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
|
||||
participant. "I think we're done" can close a thread with work still in it.
|
||||
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
|
||||
lands, delete the channel and move anything still open to `TODO.md`.
|
||||
|
||||
Two things that made it work, and neither is about either agent being more careful. One writes, the other
|
||||
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
|
||||
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
|
||||
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
|
||||
explaining why it was safe.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
||||
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
||||
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
|
||||
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
|
||||
a number produced by searching is confidently wrong with no signal that it is
|
||||
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
|
||||
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
|
||||
question to settle first, which is retention rather than anything technical
|
||||
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
|
||||
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
|
||||
agent. Read before writing one
|
||||
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
||||
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
||||
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
|
||||
it is what `docs/workspace-panel-todo.md` is ranked against.
|
||||
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
||||
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
||||
persistence key families. Read before building a panel app. Its defect list is
|
||||
`docs/workspace-panel-todo.md`.
|
||||
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
|
||||
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
|
||||
check turns into an endless resync between servers without it. The server identifier is an SSO decision
|
||||
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
|
||||
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
|
||||
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
|
||||
before any agent is asked to look
|
||||
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
|
||||
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
|
||||
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
|
||||
one home directory happened to be mode 751
|
||||
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
|
||||
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
|
||||
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
|
||||
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
|
||||
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
|
||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||
anything it already rules out
|
||||
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
|
||||
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
|
||||
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
|
||||
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
|
||||
`proxy-manager` ships a chat brief today)
|
||||
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
a namespace makes provenance readable, separate directories make it structural
|
||||
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
|
||||
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
|
||||
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
|
||||
get wrong — a team folder that is not `setgid` charges nothing to the team
|
||||
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
||||
- `src/apps/CLAUDE.md` — shared frontend patterns
|
||||
- `src/databases/CLAUDE.md` — database patterns
|
||||
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
|
||||
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
|
||||
|
||||
Treat the code as the source of truth where anything here disagrees with it.
|
||||
|
||||
@@ -1,563 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
|
||||
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
|
||||
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
|
||||
behind a single web app.
|
||||
|
||||
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
|
||||
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
|
||||
written: `users` holds six rows. The accurate statement is narrower and more useful —
|
||||
|
||||
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
||||
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
||||
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
|
||||
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
|
||||
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
|
||||
home, and the kernel refusing everything above it.
|
||||
|
||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
|
||||
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
|
||||
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
|
||||
HTTP routes, the websocket doors and the dock together.
|
||||
|
||||
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
|
||||
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||
anything under `execution`.
|
||||
|
||||
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||
|
||||
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||
the counts stay right and the sentences rot.
|
||||
|
||||
## Architecture
|
||||
|
||||
One Bun process (`src/server.tsx`) serves everything:
|
||||
|
||||
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
|
||||
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
|
||||
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
||||
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
||||
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
||||
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
|
||||
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
|
||||
|
||||
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
||||
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
||||
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
||||
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
||||
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
||||
source of truth.
|
||||
|
||||
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
|
||||
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
|
||||
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
||||
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
||||
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
||||
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
|
||||
|
||||
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
|
||||
deliberate — it is the owner's own machine. Do not add a jail without being asked.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
||||
├── apps/
|
||||
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
||||
├── servers/
|
||||
│ ├── hono.ts # router composition; everything under /api
|
||||
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
|
||||
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||
│ ├── queue/ # background job engine
|
||||
│ └── sidecar/ # sidecar implementations + the wire protocol
|
||||
├── databases/officer_db/ # the only database (Postgres + Drizzle)
|
||||
├── extensions/browser-relay/
|
||||
└── workspaces/ # shared packages, each a bun workspace
|
||||
```
|
||||
|
||||
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
|
||||
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
|
||||
`src/apps/officer-web` is only the shell — screens, routing and settings.
|
||||
|
||||
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
|
||||
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
|
||||
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
|
||||
|
||||
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
|
||||
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
|
||||
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
|
||||
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
|
||||
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
|
||||
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
|
||||
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
|
||||
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
|
||||
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
|
||||
|
||||
## Data
|
||||
|
||||
Two stores, and the split matters:
|
||||
|
||||
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
||||
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
|
||||
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
|
||||
catalogue served by a process with no login, no session and no user, so sharing a connection would put
|
||||
an unauthenticated surface one query away from every private table. It runs only on the host serving
|
||||
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
|
||||
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
|
||||
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
|
||||
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
|
||||
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
|
||||
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
|
||||
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
|
||||
what is inside it.
|
||||
|
||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
|
||||
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
|
||||
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
|
||||
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
|
||||
failing.
|
||||
|
||||
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
|
||||
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
|
||||
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
|
||||
passed).
|
||||
|
||||
### Schema changes use `push`, not migrations
|
||||
|
||||
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
|
||||
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
|
||||
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
|
||||
|
||||
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
|
||||
history was deleted because it had drifted from the real schema. Treat the schema code, not those
|
||||
files, as the source of truth.
|
||||
|
||||
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
|
||||
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
|
||||
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
|
||||
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
||||
"Composite keys" before adding either.
|
||||
|
||||
## Security Model
|
||||
|
||||
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
||||
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
|
||||
rules key off it — they fail closed.
|
||||
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
|
||||
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
|
||||
what came out was documented defence in depth that was already switched off. Origin was never
|
||||
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
|
||||
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
|
||||
is a valid token on every protected route plus the permission gate below.
|
||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||
authenticated request until the server restarts.
|
||||
|
||||
### Permissions — read this before mounting a router
|
||||
|
||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
|
||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||
`userMiddleware`.
|
||||
|
||||
- `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). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `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`.
|
||||
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
|
||||
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
|
||||
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
||||
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
||||
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
||||
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
|
||||
survive the next door; refusing to boot does.
|
||||
|
||||
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
|
||||
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
||||
is how the hole happened the first time.
|
||||
|
||||
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||
into a refusal.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun dev # the whole app — SPA + API + WebSockets, watched
|
||||
bun start # production
|
||||
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,
|
||||
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
|
||||
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
|
||||
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
|
||||
needs to, and on macOS never does, because Homebrew refuses to run as root.
|
||||
|
||||
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
||||
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
||||
|
||||
### Installs are frozen. Never resolve a dependency you did not ask for.
|
||||
|
||||
`bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and
|
||||
nothing else** — it fails rather than quietly picking up a newer version, including a transitive one
|
||||
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
|
||||
`error: lockfile had changes, but lockfile is frozen`.
|
||||
|
||||
It is config rather than a habit because a supply-chain compromise does not wait for the one time
|
||||
somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`,
|
||||
`file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were
|
||||
published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes
|
||||
credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself
|
||||
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
|
||||
unaffected only because nothing had installed since 2026-08-02.
|
||||
|
||||
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
|
||||
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
|
||||
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
|
||||
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
|
||||
in a diff is the signal this exists to produce.
|
||||
|
||||
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
|
||||
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
|
||||
|
||||
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
|
||||
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
|
||||
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
||||
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Paradigm**: functional — pure functions, immutability, composition
|
||||
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
||||
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
|
||||
- **Async**: always async/await
|
||||
- **Exports**: named only, no defaults
|
||||
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
||||
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
|
||||
|
||||
### Imports
|
||||
|
||||
Order: types → external → workspace → relative.
|
||||
|
||||
```ts
|
||||
import type { User } from 'types';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { formatDate } from 'helpers/formatters';
|
||||
import { useWebsites } from '../useWebsites';
|
||||
```
|
||||
|
||||
In app code import types from `'types'`, which re-exports the database types. Only server and
|
||||
database code imports from `officerdb/types` directly.
|
||||
|
||||
### Functions
|
||||
|
||||
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
|
||||
parameter lists — extract a params type instead.
|
||||
|
||||
```ts
|
||||
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
|
||||
export function processData({ body, query }: ProcessDataParams): Result { ... }
|
||||
```
|
||||
|
||||
### Components and hooks
|
||||
|
||||
```tsx
|
||||
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
|
||||
|
||||
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
|
||||
```
|
||||
|
||||
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
|
||||
always named `ev`, never `e`.
|
||||
|
||||
### API calls
|
||||
|
||||
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
|
||||
type parameter and let inference flow from it.
|
||||
|
||||
## Working With Me
|
||||
|
||||
- **Ask first** — confirm the approach before a significant change
|
||||
- **Explore thoroughly** — read the related files before editing
|
||||
- **Keep it simple** — no over-engineering, no premature abstraction
|
||||
- **Be explicit** — no magic, no implicit behaviour
|
||||
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
||||
- **Report honestly** — say what you verified and what you didn't
|
||||
|
||||
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
|
||||
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
|
||||
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
|
||||
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
|
||||
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
|
||||
withholding the commit until it is good.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes.
|
||||
|
||||
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
|
||||
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
|
||||
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
|
||||
the two-step form only looks like it does.
|
||||
|
||||
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
|
||||
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
|
||||
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||
```
|
||||
|
||||
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||
restart in a single night. The transcript id survived both.
|
||||
|
||||
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||
whose "why" is still answerable in six months.
|
||||
|
||||
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||
add the trailer and one that does not should leave it out rather than guess.
|
||||
|
||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||
|
||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||
|
||||
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
|
||||
`<WorkspaceView workspace={ws} locked />` where
|
||||
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
|
||||
that pins `appType`s to an allow-list. Panels are windowed apps under
|
||||
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
|
||||
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
|
||||
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
|
||||
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
|
||||
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
|
||||
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
|
||||
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
|
||||
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
|
||||
`usePublishPageTitle` in `officerdev`.
|
||||
|
||||
### The URL is the source of truth for selection
|
||||
|
||||
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
|
||||
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
|
||||
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
|
||||
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
|
||||
link-focusable). Half the app still does this; none of the new code should.
|
||||
|
||||
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
|
||||
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
|
||||
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
|
||||
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
|
||||
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
|
||||
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
|
||||
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
||||
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
||||
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
||||
_mutates_ rather than navigates stays a `<button>`.
|
||||
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
||||
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
|
||||
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
||||
canonicalises both the bare route and a bogus param.
|
||||
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
|
||||
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
|
||||
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
|
||||
Action buttons are **siblings** of the anchor, never nested inside it.
|
||||
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
|
||||
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
|
||||
when you want `isActive`.
|
||||
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
|
||||
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
||||
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
||||
|
||||
## COMMS — a channel between agents, when one is open
|
||||
|
||||
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
|
||||
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
|
||||
sessions.
|
||||
|
||||
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
|
||||
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
|
||||
convention rather than an oversight: a spent channel left in place gets read as current.
|
||||
|
||||
If you open one:
|
||||
|
||||
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
|
||||
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
|
||||
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
|
||||
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
|
||||
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
|
||||
agent read identically.
|
||||
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
|
||||
participant. "I think we're done" can close a thread with work still in it.
|
||||
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
|
||||
lands, delete the channel and move anything still open to `TODO.md`.
|
||||
|
||||
Two things that made it work, and neither is about either agent being more careful. One writes, the other
|
||||
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
|
||||
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
|
||||
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
|
||||
explaining why it was safe.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
||||
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
||||
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
|
||||
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
|
||||
a number produced by searching is confidently wrong with no signal that it is
|
||||
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
|
||||
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
|
||||
question to settle first, which is retention rather than anything technical
|
||||
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
|
||||
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
|
||||
agent. Read before writing one
|
||||
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
||||
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
||||
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
|
||||
it is what `docs/workspace-panel-todo.md` is ranked against.
|
||||
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
||||
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
||||
persistence key families. Read before building a panel app. Its defect list is
|
||||
`docs/workspace-panel-todo.md`.
|
||||
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
|
||||
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
|
||||
check turns into an endless resync between servers without it. The server identifier is an SSO decision
|
||||
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
|
||||
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
|
||||
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
|
||||
before any agent is asked to look
|
||||
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
|
||||
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
|
||||
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
|
||||
one home directory happened to be mode 751
|
||||
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
|
||||
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
|
||||
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
|
||||
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
|
||||
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
|
||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||
anything it already rules out
|
||||
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
|
||||
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
|
||||
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
|
||||
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
|
||||
`proxy-manager` ships a chat brief today)
|
||||
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
a namespace makes provenance readable, separate directories make it structural
|
||||
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
|
||||
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
|
||||
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
|
||||
get wrong — a team folder that is not `setgid` charges nothing to the team
|
||||
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
||||
- `src/apps/CLAUDE.md` — shared frontend patterns
|
||||
- `src/databases/CLAUDE.md` — database patterns
|
||||
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
|
||||
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
|
||||
|
||||
Treat the code as the source of truth where anything here disagrees with it.
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
@@ -22,43 +22,44 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**100 open, 31 closed**, across 33 themes.
|
||||
**106 open, 33 closed**, across 34 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 4 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 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/) | 5 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 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 |
|
||||
| [SendOff](todo/sendoff/) | 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/) | 0 | 2 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||
| [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 |
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 5 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Calendar and contacts](todo/calendar/) | 1 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 2 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 6 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 1 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 |
|
||||
| [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 |
|
||||
| [SendOff](todo/sendoff/) | 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/) | 0 | 2 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||
| [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
|
||||
|
||||
@@ -69,13 +70,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
|
||||
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
|
||||
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
|
||||
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
|
||||
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
|
||||
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
|
||||
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
|
||||
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
|
||||
- `2026-08-25` **Sweep: AGENTS.md becomes the real instructions everywhere, CLAUDE.md a pointer beside it** — [docs-conventions/sweep-agents-md-primary-claude-md-pointer](todo/docs-conventions/sweep-agents-md-primary-claude-md-pointer.md)
|
||||
- `2026-08-25` **Refresh mid-turn folds the live tool call — it reads as finished while still running** — [known-bugs/refresh-mid-tool-call-renders-the-turn-folded](todo/known-bugs/refresh-mid-tool-call-renders-the-turn-folded.md)
|
||||
- `2026-08-25` **The model picker shows `claude-code` instead of the model that is running** — [known-bugs/the-model-picker-shows-the-bare-harness](todo/known-bugs/the-model-picker-shows-the-bare-harness.md)
|
||||
- `2026-08-25` **Understand how Claude and opencode do memory, then centralize both into one vector database** — [memory/understand-and-centralize-agent-memory-into-a](todo/memory/understand-and-centralize-agent-memory-into-a.md)
|
||||
- `2026-08-25` **Deep-link a todo notification straight to that item's markdown** — [mobile-apps/deep-link-a-todo-notification-to-its-file](todo/mobile-apps/deep-link-a-todo-notification-to-its-file.md)
|
||||
- `2026-08-25` **Add a favorites tab/panel to the file browser** — [multi-user/add-a-favorites-tab-panel-to-the-file-browser](todo/multi-user/add-a-favorites-tab-panel-to-the-file-browser.md)
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"shiki": "^3.22.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sounds": "workspace:*",
|
||||
@@ -365,7 +366,7 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||
|
||||
@@ -443,31 +444,43 @@
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
@@ -475,11 +488,13 @@
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
@@ -2135,7 +2150,7 @@
|
||||
|
||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
@@ -2147,7 +2162,7 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
@@ -2419,6 +2434,20 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@img/sharp-darwin-arm64/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-darwin-x64/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-linux-arm/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-linux-arm64/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-linux-x64/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
@@ -2519,6 +2548,8 @@
|
||||
|
||||
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"editorconfig/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
@@ -2557,6 +2588,8 @@
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"next/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"node-pty/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
|
||||
@@ -2597,35 +2630,23 @@
|
||||
|
||||
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
|
||||
|
||||
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
"sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
|
||||
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
|
||||
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
|
||||
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
|
||||
|
||||
"socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
@@ -2727,6 +2748,46 @@
|
||||
|
||||
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
|
||||
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
|
||||
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
|
||||
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"next/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ=="],
|
||||
@@ -2899,6 +2960,8 @@
|
||||
|
||||
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"next/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"react-email/glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
|
||||
|
||||
"wif/bs58check/bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# 2026.08.26.01 — report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`);
|
||||
neither is a failure. Every target ended at `e9f1760f` = `deploy/2026.08.26.01` exactly.
|
||||
|
||||
**No schema change in this release**, and that was verified rather than assumed — the diff between
|
||||
`deploy/2026.08.25.01` and this tag touches no `schema.ts` and leaves `bun.lock` untouched. **No
|
||||
`bun install` was run anywhere.** **No direct SQL was run on any host**; none was authorised.
|
||||
|
||||
First release under a new watcher, and the first the loop has ever executed. The claim was taken by the
|
||||
watcher before this agent was spawned; this agent recorded `done` after the rollout.
|
||||
|
||||
## One finding the owner should see: `db:push` is not a no-op on any host
|
||||
|
||||
`bun db:push` exited 0 and printed `Changes applied` on all five. Per doctrine that is not evidence, so
|
||||
each host was re-run with `--verbose` to see what push actually wanted. **Every host wants the same
|
||||
statement, every single run:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
|
||||
```
|
||||
|
||||
`alpha` additionally wants a drop-and-re-add of `pk_music_now_playing` (the music plugin's composite
|
||||
primary key).
|
||||
|
||||
**This release did not cause it and it is not damage.** The reasoning is deductive rather than a guess:
|
||||
this release changes zero `schema.ts` files, so drizzle's desired state is byte-identical before and
|
||||
after the merge — anything push wants is pre-existing drift. It was verified by query that the database
|
||||
is already in the state the schema code asks for:
|
||||
|
||||
| Host | `command_hq_sessions.reference_paths` default | `pk_music_now_playing` | `roles` |
|
||||
| ---------------------------- | --------------------------------------------- | ---------------------- | ------- |
|
||||
| `alpha` | `'{}'::text[]` — correct | present (`contype=p`) | 4 |
|
||||
| `fofos` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `production-pertento-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `deadpool-rezio-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `macbookpro` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
|
||||
So drizzle cannot match `'{}'` against the stored `'{}'::text[]` and re-emits the ALTER forever. It is
|
||||
the array-default cousin of the named-composite-constraint mis-diff already documented in
|
||||
`src/databases/CLAUDE.md` → "Composite keys", and `pk_music_now_playing` on `alpha` is that exact
|
||||
documented case.
|
||||
|
||||
**Why it matters even though it is harmless today:** it permanently spends the signal. The plan said
|
||||
"expect no changes to platform tables — if it wants to alter one, stop and report it", and that stop
|
||||
condition can never fire cleanly while a platform table is on the standing-diff list. A future release
|
||||
that really does want an unexpected ALTER will look exactly like tonight. Worth fixing at the schema
|
||||
code, not on the hosts.
|
||||
|
||||
The rollout was not stopped for it. Non-destructive, pre-existing, and the databases are in the state
|
||||
the code asks for.
|
||||
|
||||
`postdb:push` seeders ran clean everywhere: `seed:roles` 0 seeded / 4 already present on all five,
|
||||
`backfill:session-models` 0 seeded on all five.
|
||||
|
||||
## The reason for this release — verified on `fofos`
|
||||
|
||||
The plan exists because `proxy-manager` imports `ChatHeader`, which did not exist before `2ed47fa6`, so
|
||||
the plugin's frontend rebuild failed and the SPA silently kept its old bundle.
|
||||
|
||||
Verified after the restart, from the logs and the build output rather than by eye:
|
||||
|
||||
- `[plugins] frontend built in 7693ms (3 outputs)` at out-log line **1777 of 1808** — the current boot.
|
||||
- The `No matching export … for import "ChatHeader"` line is in the error log **above** the last
|
||||
`gen-index` / `server.tsx` boot markers, i.e. it belongs to the **previous** boot. No `rebuild failed`
|
||||
after the successful build.
|
||||
- `build/` was rewritten at 04:46:04, seconds after the restart, and `chunk-b7epbzfm.js` contains
|
||||
`proxy-manager` 41 times.
|
||||
|
||||
`ChatHeader` itself greps to 0 in the bundle — expected, production bundling renames it. The absence of
|
||||
the build error is the signal, not the presence of the symbol.
|
||||
|
||||
**The owner still needs a hard refresh (Ctrl/Cmd-Shift-R) before judging `/proxy-manager` in a browser**
|
||||
that is holding the old bundle. Not checked from a browser by this agent.
|
||||
|
||||
## Per host
|
||||
|
||||
Steps in plan order everywhere: ff-merge to the tag → `bun db:push` → `bun run plugins:update` →
|
||||
`pm2 restart officer`.
|
||||
|
||||
### `alpha` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Tree clean, no untracked files.
|
||||
- `db:push` exit 0. Standing diff as above; database verified correct by query.
|
||||
- `plugins:update`: **14/14 updated in 59s**, exit 0. This bounced fourteen sidecars on its own, before
|
||||
any restart the plan asked for — the report line below understates what actually moved.
|
||||
- `pm2 restart officer`: restart_time 3 → 4, `online`, uptime 14s at check.
|
||||
- All **21** PM2 entries `online`. `officer-claude-code` untouched (uptime 28h).
|
||||
|
||||
### `fofos` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Tracked-clean. **One untracked file: `scripts/chk.ts`** — noted, not a
|
||||
reason to skip, and not touched.
|
||||
- `db:push` exit 0; `proxy_manager_connection`, `proxy_manager_dns_credentials`, `proxy_manager_domains`
|
||||
all present, as the plan predicted for a proxy-manager host.
|
||||
- `plugins:update`: 3/3 updated in 28s, exit 0 (browser, gitea, proxy-manager).
|
||||
- `pm2 restart officer`: restart_time 29 → 30, `online`, uptime 26s at check.
|
||||
- All **11** PM2 entries `online`. Frontend rebuild verified — see the section above.
|
||||
- Note: this host's `.env` names the database `OFFICERDEV_POSTGRES_URL`, not `POSTGRES_URL` as on the
|
||||
others. Cost one failed probe. Worth knowing for the next orchestrator that reads `.env` directly.
|
||||
|
||||
### `production-pertento-bridge` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query. 39 tables in `public`.
|
||||
- `plugins:update`: 1/1 updated in 4s, exit 0 (gitea).
|
||||
- `pm2 restart officer`: restart_time 8 → 9, `online`, uptime 22s at check.
|
||||
- All **15** PM2 entries `online`. `officer-claude-code` untouched (uptime 76h).
|
||||
- Every command on this host prints an nvm/`.npmrc` `globalconfig` warning. Pre-existing, cosmetic,
|
||||
nothing failed because of it — but it is noise an orchestrator has to filter, and it will eventually
|
||||
hide something.
|
||||
|
||||
### `deadpool-rezio-bridge` — success
|
||||
|
||||
- Login `br`, checkout `/home/br/officerdev/platform`, taken from PM2's `pm_cwd`.
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query.
|
||||
- `plugins:update`: 2/2 updated in 8s, exit 0 (email, offscale).
|
||||
- `pm2 restart officer`: restart_time 14 → 15, `online`, uptime 22s at check.
|
||||
- All **9** PM2 entries `online`.
|
||||
|
||||
### `macbookpro` — success (it was awake)
|
||||
|
||||
- Checkout `/Users/pastilhas/projects/officer-suite/platform`. `PATH` **appended** with
|
||||
`/opt/homebrew/bin` for the `bun` commands; `pm2` run as a separate command, per `docs/servers.md`.
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query.
|
||||
- `plugins:update`: **no plugins installed — nothing to update**, exit 0.
|
||||
- `pm2 restart officer`: restart_time 16 → 17, `online`, uptime 27s at check.
|
||||
- All **6** PM2 entries `online`.
|
||||
|
||||
## Skipped
|
||||
|
||||
### `officer-dev` — skipped, by plan and by doctrine
|
||||
|
||||
Not pulled, nothing restarted. This agent runs inside `officer-claude-code` on this machine; restarting
|
||||
`officer` here ends the rollout. It already carries this code — its HEAD was `e9f1760f` before the
|
||||
rollout began.
|
||||
|
||||
**Its working tree holds another session's uncommitted work** — modified `src/servers/hono.ts` and
|
||||
`src/workspaces/hooks/src/useClient.ts`, plus an untracked `src/servers/hono-error-shape.test.ts`. Left
|
||||
alone. The report commit below names only its own path.
|
||||
|
||||
The owner restarts this host deliberately, as the last step, once happy with the rest.
|
||||
|
||||
### `edge-pertento-bridge` — skipped, by plan. Not contacted.
|
||||
|
||||
**And `docs/servers.md` disagrees with the plan about this host.** The doc's "Deliberately skipped"
|
||||
section says "None today" and lists `edge-pertento-bridge` in the platforms table as "a deploy target
|
||||
again". The plan says skip it: 300+ commits behind, **no `roles` table**, and a plain `db:push` there
|
||||
would create a foreign key onto an empty table. Its catch-up is a by-hand job the owner is doing
|
||||
separately.
|
||||
|
||||
The plan won — it is authoritative for a release, and it is the more recent statement. Nothing was sent
|
||||
to that host, not even a reachability probe. **`docs/servers.md` should be corrected**, because the next
|
||||
orchestrator that reads the doc rather than the plan will `db:push` a host that cannot take one. Not
|
||||
fixed here: a rollout does not edit documentation.
|
||||
|
||||
## What was NOT done
|
||||
|
||||
- **`officer-claude-code` was not restarted on any host**, per the plan. The widened chat credential
|
||||
(an API key follows its holder, minus `admin`) lives in that sidecar, so **it is not active anywhere
|
||||
yet**. It is the owner's call when to take that — it ends every live agent session on the host,
|
||||
including this one. **Pending on all five targets plus `officer-dev`.**
|
||||
- **`officer-dev` was not deployed.** Skipped whole, per above.
|
||||
- **The sonnet default was not confirmed from a UI on any host.** The plan names it as the easiest
|
||||
visible check; this agent has no browser. It shipped in the code that is now at `e9f1760f` on all
|
||||
five, unverified at the surface.
|
||||
- **`/proxy-manager` was not opened in a browser.** Verified from the build and the logs instead.
|
||||
- `bun run docs:check` was not run — not a rollout step, and this agent does not fix documentation.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026.08.26.02
|
||||
|
||||
**One fix, and it is a crash.** A screen whose layout names a panel it does not own now renders instead of
|
||||
failing with React #185, "maximum update depth exceeded".
|
||||
|
||||
**No schema change. No dependency change. Do not run `bun install`.** `bun db:push` stays a step because it
|
||||
is cheap and proves the database agrees with the code, but it must report no changes.
|
||||
|
||||
## What broke, and where it shows
|
||||
|
||||
`useAuthoredLayout` shipped in `2026.08.26.01`: a locked screen persists its SIZES and takes its STRUCTURE
|
||||
from source on every render. That is still right, and it is what lets a plugin change its layout and reach
|
||||
people who have already opened it.
|
||||
|
||||
What it did not account for is `WorkspaceView`'s `appTypes` guard, which repairs an unrecognised `appType`
|
||||
and **writes the repair back**. That was self-limiting while structure was persisted — the repair stuck and
|
||||
the next render agreed with it. With structure coming from source it can never stick, so it is recomputed,
|
||||
rewritten, discarded and recomputed every render until React gives up.
|
||||
|
||||
**The `browser` plugin's layout names `officerdev/chat`**, which is not one of its own panel keys. So
|
||||
`/browser` fails to render on any host with that plugin installed. That is every host running
|
||||
`2026.08.26.01` with it — which is the fleet.
|
||||
|
||||
The fix drops a write whose result equals the current value. Not an optimisation: it is what terminates the
|
||||
loop, and persisting an identical tree was pointless anyway.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.26.02`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
**`fofos` is already at the fix** — it was fast-forwarded by hand at 05:22 to `a7c9dab2` while the owner was
|
||||
working on it. Step 1 will fast-forward it to this tag anyway, which only adds the version bump and this
|
||||
plan. Nothing about that is a problem; note it rather than treating it as drift.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. Asleep is
|
||||
a skip with a note.
|
||||
|
||||
**Do not restart `officer-claude-code`.** Unchanged from the last release, same reason: it ends every live
|
||||
agent session on the host, and it is the owner's call. Nothing in this release needs it.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It already has this code.
|
||||
- **`edge-pertento-bridge`** — skipped. Still 300+ commits behind with no `roles` table; its catch-up is a
|
||||
by-hand job. Do not contact it.
|
||||
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||
- **`macbookpro`** — a target if it is awake.
|
||||
|
||||
## What to check
|
||||
|
||||
**Open `/browser`.** It should render rather than showing "This screen failed to render". That is the whole
|
||||
release, and it is one click to confirm. Hard refresh first — a stale bundle looks identical to the bug.
|
||||
|
||||
No direct SQL is needed anywhere, and none is authorised.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Deploy report — 2026.08.26.02
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`).
|
||||
|
||||
Tag `deploy/2026.08.26.02` → `121a77ce`. `package.json` at that commit reads `2026.08.26.02` — tag and
|
||||
version agree. Every host ended at `121a77ce` exactly, merged by `--ff-only` against the tag rather than
|
||||
the branch.
|
||||
|
||||
Second release under this watcher, and the second the loop has executed. The claim was taken by the
|
||||
watcher before this agent was spawned; this agent recorded `done` after the rollout.
|
||||
|
||||
## The release changed no schema, and that makes the `db:push` reading solid
|
||||
|
||||
This release touches five files: two deploy documents, `package.json`, and
|
||||
`src/workspaces/state/useAuthoredLayout.ts` with its test. **No `schema.ts` changed**, so drizzle's desired
|
||||
state is byte-identical before and after the merge. Anything `push` wants is pre-existing drift, deductively
|
||||
rather than by assumption.
|
||||
|
||||
Per doctrine, exit 0 was not taken as evidence. Every host was run with `--verbose` to read the statements
|
||||
push actually wanted, and then queried directly.
|
||||
|
||||
**The standing diff from `2026.08.26.01` is unchanged and is present on all five:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
|
||||
```
|
||||
|
||||
`alpha` additionally wants the documented drop-and-re-add of `pk_music_now_playing`. Nothing new appeared on
|
||||
any host — the set of wanted statements is identical to last night's, host for host.
|
||||
|
||||
Verified by query, per host, not inferred:
|
||||
|
||||
| Host | `command_hq_sessions.reference_paths` | `pk_music_now_playing` | `roles` |
|
||||
| ---------------------------- | ------------------------------------- | ---------------------- | ------- |
|
||||
| `alpha` | `'{}'::text[]` — correct | present (`contype=p`) | 4 |
|
||||
| `fofos` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `production-pertento-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `deadpool-rezio-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `macbookpro` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
|
||||
Each database is already in the state the schema code asks for. The rollout was not stopped for it: the
|
||||
plan's stop condition is "if it wants to alter something", and this is drizzle re-emitting a statement
|
||||
whose effect is already in place. **That stop signal is still spent**, exactly as last night's report said —
|
||||
worth fixing at the schema code, and it is the one thing here the owner should act on.
|
||||
|
||||
No direct SQL was run anywhere. None was authorised and none was needed.
|
||||
|
||||
## Per host
|
||||
|
||||
Steps in plan order on every host: ff-merge to the tag → `bun db:push` → `bun run plugins:update` →
|
||||
`pm2 restart officer`. No `bun install` anywhere — the plan forbids it and no dependency changed.
|
||||
`officer-claude-code` was not restarted anywhere, per the plan.
|
||||
|
||||
### `alpha` — OK
|
||||
|
||||
- `e9f1760f` → `121a77ce`. Tree clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff plus `pk_music_now_playing`; database verified correct by query.
|
||||
- `plugins:update` 14/14 in 55s. That step bounces roughly a dozen sidecars itself (`sidecar: started` per
|
||||
plugin) — more moved on this host than the `officer` line below suggests.
|
||||
- `officer` restarted: `online`, `restart_time` 4 → 5.
|
||||
|
||||
### `fofos` — OK
|
||||
|
||||
- `a7c9dab2` → `121a77ce`, the short fast-forward the plan predicted: it adds only the version bump and the
|
||||
plan file, since the fix itself was hand-applied here at 05:22. Not drift.
|
||||
- One untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward cannot touch it, and
|
||||
nobody's work was discarded.
|
||||
- `db:push` exit 0; standing diff only; verified by query. 43 tables in `public`.
|
||||
- `plugins:update` 3/3 in 21s (`browser`, `gitea`, `proxy-manager`).
|
||||
- `officer` restarted: `online`, `restart_time` 31 → 32.
|
||||
- **This is a host the release actually matters on** — it has the `browser` plugin whose layout names
|
||||
`officerdev/chat`.
|
||||
|
||||
### `production-pertento-bridge` — OK
|
||||
|
||||
- `e9f1760f` → `121a77ce`. Tree clean.
|
||||
- `db:push` exit 0; standing diff only; verified by query.
|
||||
- `plugins:update` 1/1 in 4s.
|
||||
- `officer` restarted: `online`, `restart_time` 9 → 10.
|
||||
|
||||
### `deadpool-rezio-bridge` — OK
|
||||
|
||||
- Logged in as `br`, per `docs/servers.md`.
|
||||
- `e9f1760f` → `121a77ce`. Tree clean.
|
||||
- `db:push` exit 0; standing diff only; verified by query.
|
||||
- `plugins:update` 2/2 in 7s.
|
||||
- `officer` restarted: `online`, `restart_time` 15 → 16.
|
||||
|
||||
### `macbookpro` — OK, and it was awake
|
||||
|
||||
- `e9f1760f` → `121a77ce`. Tree clean.
|
||||
- `PATH` appended with `/opt/homebrew/bin` for the `bun` steps; `pm2` run as a separate command on the
|
||||
interactive shell, so nothing was shadowed.
|
||||
- `db:push` exit 0; standing diff only; verified by query.
|
||||
- `plugins:update`: `no plugins installed — nothing to update`. Not a failure.
|
||||
- `officer` restarted: `online`, `restart_time` 17 → 18.
|
||||
|
||||
## Skipped, and why
|
||||
|
||||
- **`officer-dev`** — the host this agent runs on. Not pulled, nothing restarted. It already holds this
|
||||
code at `121a77ce`. Its working tree carries another session's uncommitted changes
|
||||
(`src/servers/hono.ts`, `src/workspaces/hooks/src/useClient.ts`, an untracked
|
||||
`src/servers/hono-error-shape.test.ts`) — untouched. **The owner restarts `officer` here deliberately,
|
||||
as the last step.**
|
||||
- **`edge-pertento-bridge`** — skipped on the plan's instruction: 300+ commits behind with no `roles`
|
||||
table, its catch-up is a by-hand job, do not contact it. It was not contacted.
|
||||
|
||||
Note the disagreement, because an orchestrator reading the wrong document gets this wrong: `docs/servers.md`
|
||||
lists this host in the platforms table as a deploy target again, with an empty "Deliberately skipped"
|
||||
section. The **plan** overrides it, and the plan is right — a `db:push` on a host with no `roles` table is
|
||||
the exact shape of change this doctrine refuses. `docs/servers.md` is stale on this point.
|
||||
|
||||
## What was not verified
|
||||
|
||||
**The fix itself was not clicked.** The plan's check is "open `/browser`, hard refresh, confirm it renders
|
||||
rather than showing 'This screen failed to render'". That is a browser action on a logged-in session, and
|
||||
this agent has no session on any of these hosts. What is verified is that the code carrying the fix is at
|
||||
`HEAD` on every target and that `officer` came back `online` on each. **The one-click confirmation is
|
||||
still owed, on a host with the `browser` plugin — `fofos` or `alpha`.**
|
||||
@@ -0,0 +1,37 @@
|
||||
# deploy/2026.08.27.01
|
||||
|
||||
55 commits since `deploy/2026.08.26.02`. A full day's work, most of it in two areas.
|
||||
|
||||
## What is in it
|
||||
|
||||
**Calendar and contacts became usable rather than readable.** Create, edit and delete on both screens; a
|
||||
filter on each; `?collection=<name>` instead of an escaped DAV path. The agent skill was rewritten off
|
||||
hand-written iCalendar onto JSON routes — an editor now patches the stored text rather than rebuilding it,
|
||||
so alarms, attendees and recurrence rules survive an edit made from a form that cannot show them.
|
||||
|
||||
**OpenCode can reach the platform.** It gets the skills index, an Officer identity instead of "you are a
|
||||
coding agent", an API credential, and its permission prompts answered — a server session has nobody to
|
||||
answer them and a turn simply hung. Its chats also keep their name, their transcript and their model when
|
||||
reopened.
|
||||
|
||||
**Smaller, and each one somebody hit today:** `/api/notify/user` (and `/broadcast`, which had answered 404
|
||||
since it landed — shadowed by the sidecar proxy's catch-all); a commander started from a plugin chat, so
|
||||
pointing one at a mailbox no longer needs a path only the owner can type; file preview inside a
|
||||
commander's own browser; a dock tile for `/bots`; and the new wordmark in the header, following the theme.
|
||||
|
||||
## Risk
|
||||
|
||||
Low-to-moderate. Nothing here changes a schema. The two worth watching after the rollout:
|
||||
|
||||
- **`useThemeAsset`** now reads in `requestAnimationFrame` rather than during the effect. It fixes the
|
||||
logo and the wallpaper being one theme behind; if a themed asset ever fails to appear, this is the first
|
||||
place to look.
|
||||
- **The notify router's mount order.** The platform's own routes now sit before the sidecar proxy's
|
||||
catch-all. Device registration still forwards — verified — but it is the change most likely to surprise.
|
||||
|
||||
## Hosts
|
||||
|
||||
Seven. `officer-dev` is skipped: it is the host running the rollout, and restarting it ends the rollout.
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
@@ -0,0 +1,78 @@
|
||||
# 2026.08.27.02
|
||||
|
||||
Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no
|
||||
commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays
|
||||
claimed and undeployed; do not try to revive it.
|
||||
|
||||
Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab.
|
||||
|
||||
## The schema question, answered per host
|
||||
|
||||
`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`.
|
||||
`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has
|
||||
`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it.
|
||||
|
||||
Surveyed 2026-08-28, every host:
|
||||
|
||||
| Host | `plugins/caldav/` | `dav_app_passwords` |
|
||||
| --- | --- | --- |
|
||||
| `alpha` | present | **8 rows** |
|
||||
| `fofos` | absent | present, 0 rows |
|
||||
| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) |
|
||||
| `macbruno` | absent | present, 0 rows |
|
||||
| `production-pertento-bridge` | absent | present, 0 rows |
|
||||
| `edge-pertento-bridge` | absent | present, 0 rows |
|
||||
| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) |
|
||||
|
||||
So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core,
|
||||
and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect.
|
||||
|
||||
`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero
|
||||
of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02`
|
||||
2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it
|
||||
holds anything:
|
||||
```
|
||||
psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords"
|
||||
# 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords"
|
||||
# anything else → STOP. Report the count and do not continue on this host.
|
||||
```
|
||||
Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`.
|
||||
3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of
|
||||
hanging. Expect **no changes** after step 2. Anything destructive: stop and report.
|
||||
4. `bun run plugins:update`
|
||||
5. `pm2 restart officer`
|
||||
6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity
|
||||
and its credential, and all of that is built when the sidecar boots. Without this the code ships and
|
||||
nothing about OpenCode changes.
|
||||
|
||||
**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs
|
||||
normally and must report no changes.
|
||||
|
||||
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
|
||||
with a note.
|
||||
|
||||
**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once
|
||||
during the survey — if it refuses again, note it and move on rather than retrying in a loop.
|
||||
|
||||
**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs
|
||||
it, and it is the owner's call.
|
||||
|
||||
## Risk
|
||||
|
||||
Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a
|
||||
count and it is skipped on the one host where the table has contents.
|
||||
|
||||
After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so
|
||||
a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so
|
||||
device registration is worth one check (it forwards correctly on `officer-dev`).
|
||||
|
||||
## Hosts
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
|
||||
`officer-dev` is skipped: it is the host running the rollout.
|
||||
@@ -0,0 +1,96 @@
|
||||
# 2026.08.27.02 — report
|
||||
|
||||
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout.
|
||||
|
||||
Every host moved to `91147b40` (`deploy/2026.08.27.02`) by `git merge --ff-only` onto the TAG. No host
|
||||
was dirty in tracked files, none diverged, none needed a skip.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | Step 2 (drop) | `dav_app_passwords` after | Restarted |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `e867fe72` | `91147b40` | skipped by plan | **present, 8 rows** | officer 0→1, opencode 0→1 |
|
||||
| `fofos` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 0→1 |
|
||||
| `macbookpro` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 0→1, opencode 0→1 |
|
||||
| `macbruno` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 1→2 |
|
||||
| `production-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 1→2, opencode 0→1 |
|
||||
| `edge-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 2→3, opencode 0→1 |
|
||||
| `deadpool-rezio-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 19→20, opencode 0→1 |
|
||||
|
||||
Every process the plan named came back `online` with `restart_time` moved, on every host. No officer
|
||||
process was left `errored` or `stopped` anywhere.
|
||||
|
||||
## The schema, verified by query per host
|
||||
|
||||
The plan's claim was checked on each host, against that host's own database, **after** the rollout —
|
||||
not inferred from an exit code. Final sweep:
|
||||
|
||||
```
|
||||
alpha caldav=yes dav_app_passwords=present rows=8
|
||||
fofos caldav=no dav_app_passwords=GONE
|
||||
macbookpro caldav=no dav_app_passwords=GONE
|
||||
macbruno caldav=no dav_app_passwords=GONE
|
||||
production-pertento-bridge caldav=no dav_app_passwords=GONE
|
||||
edge-pertento-bridge caldav=no dav_app_passwords=GONE
|
||||
deadpool-rezio-bridge caldav=no dav_app_passwords=GONE
|
||||
```
|
||||
|
||||
The count guard held on all six drop hosts: every one read exactly `0` before the `drop table`, and the
|
||||
drop was verified by re-querying `to_regclass` after. `alpha` was never touched by step 2, and its 8 rows
|
||||
are intact.
|
||||
|
||||
**Two counts the survey could not read are now read.** `macbookpro` (bun/psql behind Homebrew) and
|
||||
`deadpool-rezio-bridge` (SSH refused at survey time) both held **0 rows**, so both qualified for the drop
|
||||
under the plan's own guard rather than under an assumption.
|
||||
|
||||
`officer_marketplace` and its `tags` column: nothing applied, correctly — no host runs
|
||||
`officer-marketplace`, confirmed zero of seven.
|
||||
|
||||
## `bun db:push` prints `Changes applied` even when it changes nothing
|
||||
|
||||
Worth recording, because it makes the plan's "expect no changes" unverifiable from stdout. `db:push`
|
||||
printed `[✓] Changes applied` on **every** host, including on an immediate second run against an
|
||||
unchanged database. It is unconditional output, not evidence.
|
||||
|
||||
What settled it was `drizzle-kit push --verbose`, which prints the statements first. Rerun per host, the
|
||||
real content was:
|
||||
|
||||
- **every host** — `ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}'`
|
||||
- **`alpha` and `edge-pertento-bridge` also** — `music_now_playing` dropping and re-adding
|
||||
`pk_music_now_playing` as the same two-column key
|
||||
|
||||
Both re-appear on every push and neither is destructive. This is the drizzle-kit composite-key mis-diff
|
||||
already documented in `src/databases/CLAUDE.md`, and it is **pre-existing drift, unrelated to this
|
||||
release**. Nothing in it drops or recreates `dav_app_passwords` on any host — which is the positive
|
||||
result: plugin schema generation scoped the table to the one host with the plugin, exactly as the plan
|
||||
said it would.
|
||||
|
||||
Not fixed here. A rollout is the wrong place for a schema-code change.
|
||||
|
||||
## Plugins
|
||||
|
||||
`bun run plugins:update` exited 0 everywhere. It also started sidecars beyond the two the plan named, so
|
||||
"restarted" above understates what moved: `officer-gitea` was added on `fofos` and
|
||||
`production-pertento-bridge`, and `officer-offscale` on `edge-pertento-bridge` and
|
||||
`deadpool-rezio-bridge`. Counts: alpha 15/15, fofos 3/3, macbruno 1/1, production 1/1, edge 2/2,
|
||||
deadpool 2/2, macbookpro none installed.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` — skipped entirely**, as the plan requires. Not pulled, nothing restarted. It is the
|
||||
host this rollout ran on, and restarting `officer-claude-code` there would have ended the rollout. The
|
||||
owner restarts it deliberately.
|
||||
- **`officer-claude-code` was not restarted anywhere.** The plan forbids it.
|
||||
- **`deadpool-rezio-bridge` refused SSH on the first probe** (`Connection closed by 100.64.0.21 port 22`)
|
||||
and answered on a second attempt a few minutes later. One retry, not a loop. It then deployed normally,
|
||||
so it is a success rather than the skip the plan allowed for. Its SSH is intermittent and that is worth
|
||||
knowing.
|
||||
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could not
|
||||
touch it and it was left alone.
|
||||
- **`2026.08.27.01` remains claimed and undeployed**, which is correct: this release replaces it.
|
||||
|
||||
## Worth a look, per the plan's own risk note
|
||||
|
||||
Untested by this rollout, because they need a browser: `useThemeAsset` now reads in
|
||||
`requestAnimationFrame` (a themed logo or wallpaper failing to appear points there), and the notify
|
||||
router's mount order changed (device registration is worth one check).
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026.08.28.01
|
||||
|
||||
**One feature: sign in as a user, from user management.** Owner-only, one hour, on a token that carries
|
||||
`impersonatedBy` so an impersonated action is never mistaken for the person's own.
|
||||
|
||||
Two commits since `2026.08.27.02`, one of which is that release's own report.
|
||||
|
||||
## No schema change. No dependency change. No plugin change.
|
||||
|
||||
Checked, not asserted — `git diff --name-only deploy/2026.08.27.02..HEAD` matches no `schema.ts`, no
|
||||
`drizzle` config, no `bun.lock`, and nothing under `plugins/`. That was the claim the `2026.08.27.01`
|
||||
plan got wrong, so it is stated here as a check that was run rather than a belief.
|
||||
|
||||
## `bun db:push` is deliberately NOT a step
|
||||
|
||||
Every previous plan ran it "because it is cheap and proves the database agrees with the code". The
|
||||
`2026.08.27.02` report showed it proves nothing of the sort: it prints **"Changes applied" even when it
|
||||
applies nothing**, including on a second run against an unchanged database. And on this fleet it is not
|
||||
free — every host re-applies a default on `command_hq_sessions.reference_paths`, and `alpha` and
|
||||
`edge-pertento-bridge` drop and re-add `pk_music_now_playing`, which is the drizzle-kit composite-key
|
||||
mis-diff in `src/databases/CLAUDE.md`.
|
||||
|
||||
Nothing in this release touches a schema, so running it would be churn against a database for no reason,
|
||||
using a command whose output cannot be checked. **If you run it anyway, it is not evidence of anything.**
|
||||
|
||||
`bun run plugins:update` is skipped for the same reason: no plugin changed, and `2026.08.27.02` updated
|
||||
them across the fleet yesterday.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.28.01`
|
||||
2. `pm2 restart officer`
|
||||
|
||||
That is the whole rollout. The change is a route and a frontend bundle; nothing else runs it.
|
||||
|
||||
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
|
||||
with a note.
|
||||
|
||||
**`deadpool-rezio-bridge`:** login `br`, checkout `/home/br/officerdev/platform`. Its SSH is intermittent
|
||||
— it refused once during the `2026.08.27.02` survey and answered minutes later. One retry, not a loop.
|
||||
|
||||
**Do not restart `officer-claude-code`** — it ends every live agent session on the host and nothing here
|
||||
needs it. **`officer-opencode` does not need a restart either**: this release changes nothing it reads at
|
||||
boot.
|
||||
|
||||
## Verify, per host
|
||||
|
||||
The feature is owner-only, so an unauthenticated probe proving the route EXISTS is the honest check —
|
||||
401 or 403 means mounted, 404 means the bundle did not move:
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
|
||||
```
|
||||
|
||||
Expect **401**. A **404** means step 2 did not take effect. Ports are in `docs/servers.md` —
|
||||
`macbookpro` and `production-pertento-bridge` are on **9010**, everything else 9000.
|
||||
|
||||
## Risk
|
||||
|
||||
Low. One new route behind the existing owner gate, and one frontend bundle. The route refuses a
|
||||
self-impersonation, an unknown user, a non-Active account, and any caller whose own token is already
|
||||
impersonated — that last one is what stops it chaining into owner powers.
|
||||
|
||||
Nothing to roll back but the merge: no data is written and no schema is touched.
|
||||
|
||||
## Hosts
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
|
||||
`officer-dev` is skipped: it is the host running the rollout, and it is already at this code.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.28.01 — report
|
||||
|
||||
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout, and it
|
||||
was already at the tagged commit.
|
||||
|
||||
Every host moved from `91147b40` (`deploy/2026.08.27.02`) to `9c8af9d4` (`deploy/2026.08.28.01`) by
|
||||
`git merge --ff-only` onto the TAG. Every host started from the same commit, so the fleet was coherent
|
||||
before this release and is coherent after it. No host was dirty in tracked files, none diverged, none
|
||||
needed a skip.
|
||||
|
||||
This is the first release rolled out under the new watcher loop. The rollout procedure is the same one
|
||||
that ran the previous nine; what was unproven was the loop around it.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | Restarted | Route probe |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `alpha` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9000) |
|
||||
| `fofos` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
|
||||
| `macbookpro` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9010) |
|
||||
| `macbruno` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
|
||||
| `production-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 2→3, online | **401** (9010) |
|
||||
| `edge-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 3→4, online | **401** (9000) |
|
||||
| `deadpool-rezio-bridge` | `91147b40` | `9c8af9d4` | officer 20→21, online | **401** (9000) |
|
||||
|
||||
Every restarted process came back `online` with `restart_time` moved. Nothing was left `errored` or
|
||||
`stopped` on any host.
|
||||
|
||||
## The verification, per host
|
||||
|
||||
The plan's check is an unauthenticated probe of the new route: `401` proves it is mounted, `404` would
|
||||
mean the bundle did not move. Run on each host against that host's own port, taken from its `.env` and
|
||||
not from habit:
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
|
||||
```
|
||||
|
||||
All seven answered **401**. None answered 404, so the frontend bundle and the route moved everywhere.
|
||||
|
||||
Three hosts — `fofos`, `production-pertento-bridge`, `edge-pertento-bridge` — answered `000`
|
||||
(connection refused) on the first probe and `401` seconds later. That is `bun start` still coming up,
|
||||
not a fault: `pm2` returns as soon as it has respawned the process, before the server binds. A probe
|
||||
fired immediately after a restart is testing the wrong moment. The probes here retried until the port
|
||||
answered.
|
||||
|
||||
## No schema change — checked, not assumed
|
||||
|
||||
The plan claimed no schema change, no dependency change and no plugin change. That claim was
|
||||
**re-derived before the first host was touched**, not taken on trust:
|
||||
|
||||
```
|
||||
git diff --name-only deploy/2026.08.27.02..deploy/2026.08.28.01^{}
|
||||
```
|
||||
|
||||
Ten files: two `deploy/*.md`, `package.json`, and six under `src/` — a route, a router line, two
|
||||
frontend files, a workspace app and its index. No `schema.ts`, no drizzle config, no `bun.lock`,
|
||||
nothing under `plugins/`.
|
||||
|
||||
So there is no schema verification section in this report, because there was nothing to verify. That is
|
||||
the honest answer rather than an omission.
|
||||
|
||||
## Steps the plan deliberately did NOT include, and which were NOT run
|
||||
|
||||
- **`bun db:push` was not run on any host.** The plan forbids it and gives the reason: it prints
|
||||
`Changes applied` even when it applies nothing, so it is not evidence, and on this fleet it is not
|
||||
free — it re-applies a default on `command_hq_sessions.reference_paths` everywhere, and drops and
|
||||
re-adds `pk_music_now_playing` on `alpha` and `edge-pertento-bridge`. Nothing in this release touches
|
||||
a schema, so running it would have been churn.
|
||||
- **`bun run plugins:update` was not run on any host.** No plugin changed, and `2026.08.27.02` updated
|
||||
them across the fleet yesterday.
|
||||
- **`officer-claude-code` was not restarted anywhere.** It ends every live agent session on the host and
|
||||
nothing in this release needs it.
|
||||
- **`officer-opencode` was not restarted anywhere.** This release changes nothing it reads at boot.
|
||||
|
||||
The consequence worth stating: only `officer` moved on each host. Unlike `2026.08.27.02`, where the
|
||||
plugins step bounced every sidecar, nothing else on the fleet restarted during this rollout.
|
||||
|
||||
## Version agreement
|
||||
|
||||
The tag `deploy/2026.08.28.01` is annotated and dereferences to `9c8af9d4`. `package.json` at that
|
||||
commit reads `2026.08.28.01`. They agree, so there was no question of which tree to deploy.
|
||||
|
||||
## Say what was not done
|
||||
|
||||
- **`officer-dev` was skipped entirely** — not pulled, nothing restarted. It is the machine this
|
||||
rollout ran on, and restarting `officer-claude-code` there would have ended the session mid-rollout.
|
||||
It is already at `9c8af9d4`, so its code is current; what it has not had is a restart of `officer`.
|
||||
**That is the owner's to do, deliberately, as the last step.** Until then `officer-dev` is running the
|
||||
previous release's process against the new code on disk.
|
||||
- **`fofos` carries an untracked `scripts/chk.ts`.** Left alone. An untracked file is a note, not a
|
||||
reason to skip a host, and a fast-forward could never have touched it.
|
||||
- **The eight non-Officer processes on `production-pertento-bridge` were not touched.** `Dashboard`,
|
||||
`Tracking`, `Uptime` and five `Crons *` were all `online` with unchanged `restart_time` after the
|
||||
rollout — checked, not assumed. The restart named `officer` alone.
|
||||
- **No host needed a retry for SSH.** `deadpool-rezio-bridge`, which refused once during the
|
||||
`2026.08.27.02` survey, answered first time. `macbookpro` was awake.
|
||||
- **Nothing was rolled back and nothing needed repair.** No host failed, so no half-applied state
|
||||
exists anywhere.
|
||||
@@ -0,0 +1,122 @@
|
||||
# 2026.08.29.01 — sharp becomes infrastructure
|
||||
|
||||
**This release changes what a machine must have.** Every other deploy this month was code; this one adds
|
||||
a dependency with a native binary and a system package, so `bun install` is a required step rather than
|
||||
an optional one, and there are two things to verify per host that no previous plan asked for.
|
||||
|
||||
Read the whole file before starting a host. The per-host section at the bottom is not optional detail —
|
||||
one host needs a command none of the others do.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **File browser thumbnails.** Images and videos draw a picture in both grid and list. New endpoint
|
||||
`GET /api/file-browser/thumb`, cached under each user's `cache/thumbs/`.
|
||||
- **sharp is now a declared dependency** (`^0.35.3`, resolving 0.35.4). It does the images; ffmpeg keeps
|
||||
the video. `package.json` and `bun.lock` both changed.
|
||||
- **ffmpeg is now declared in machine-setup's package list.** It was always a hard dependency of the file
|
||||
browser — subtitles, audio extraction, audio tracks — and was never in the list. Existing hosts were
|
||||
not provisioned by the new script, so this release checks rather than assumes.
|
||||
- Marketplace: install a plugin from a git URL; Marketplace overview section; nav restyle.
|
||||
- Dock and Spotlight clip bare plugin artwork to the tile shape.
|
||||
- The platform-skill generator emits an identifier for `name` again, so `docs:check` is green.
|
||||
|
||||
## Schema: UNCHANGED. Do not run `db:push`.
|
||||
|
||||
Verified, not assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||
```
|
||||
|
||||
returns nothing. There is no table, no column and no constraint in this release. **No `bun db:push`
|
||||
step exists in this plan and none should be improvised.** If `bun run plugins:update` reports that a
|
||||
plugin moved, say so in the report — do not push a schema to cover it.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
Run these after the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin
|
||||
--tags`, `git merge --ff-only deploy/2026.08.29.01`, `bun run plugins:update`), in this order, in the
|
||||
checkout from PM2's `pm_cwd`.
|
||||
|
||||
### 1. Install — REQUIRED, and it is the point of this release
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
The lockfile is frozen, so this resolves from `bun.lock` and nothing else. It should report installing
|
||||
around 14 packages — sharp and its platform-specific libvips binaries.
|
||||
|
||||
**A failure here stops this host.** Do not continue to the restart, and do not reach for `--force` to get
|
||||
past it: if the lockfile and `package.json` disagree, that is the freeze doing its job and it wants a
|
||||
human.
|
||||
|
||||
### 2. Prove sharp actually loads — the check that matters
|
||||
|
||||
```bash
|
||||
bun -e "import('sharp').then(m => console.log('libvips', m.default.versions.vips)).catch(e => { console.error('SHARP FAILED:', e.message); process.exit(1) })"
|
||||
```
|
||||
|
||||
Expect `libvips 8.18.6`.
|
||||
|
||||
**This is a separate step from the install on purpose.** sharp does not compile — it downloads a prebuilt
|
||||
binary chosen by platform and architecture. So `bun install` can succeed and the module still be
|
||||
unusable, and the symptom is not an error anywhere: it is a file browser drawing generic icons over a
|
||||
folder of photographs, which nobody reads as "a native module is missing".
|
||||
|
||||
A host where this fails is a **failed host**. Report it, move on. `bun install --force` is the usual
|
||||
repair and it is a human's to run, not this rollout's.
|
||||
|
||||
### 3. ffmpeg must be present
|
||||
|
||||
```bash
|
||||
command -v ffmpeg && ffmpeg -version | head -1
|
||||
```
|
||||
|
||||
Video thumbnails need it, and so do three file-browser features that predate this release. Surveyed
|
||||
2026-08-29: present on every host **except `macbruno`** — see below.
|
||||
|
||||
Absent and not covered by a per-host command below → report the host as deployed-with-a-caveat, name
|
||||
ffmpeg, and do not install it yourself. Adding a system package to a machine is not something a rollout
|
||||
improvises.
|
||||
|
||||
### 4. Restart
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
**`officer` only.** Nothing else in this release touches a sidecar. Never
|
||||
`pm2 restart ecosystem.config.cjs`.
|
||||
|
||||
### 5. Verify it came back
|
||||
|
||||
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Then, for this release specifically:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:<PORT>/api/health
|
||||
```
|
||||
|
||||
`200` or `401` are both healthy — the port is per host, in `docs/servers.md` (9010 on `macbookpro` and
|
||||
`production-pertento-bridge`, 9000 elsewhere).
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Extra command | Note |
|
||||
| --- | --- | --- |
|
||||
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here. Do not pull, do not restart. Name it as skipped. |
|
||||
| `alpha` | — | x86_64, ffmpeg present. |
|
||||
| `fofos` | — | **Already at this code and verified** — it was the test host on 2026-08-29 before the tag. Bring it to the tag anyway so its HEAD matches the fleet; `bun install` will be a no-op. 1.9GB box: expect the install to be the slowest part. |
|
||||
| `macbookpro` | — | macOS/arm64, port **9010**. A laptop — unreachable is a SKIP with a note, not a failure. |
|
||||
| `macbruno` | `brew install ffmpeg` **before step 4** | macOS/arm64, login `brunoalcadapadezmonteirodaconceicao`. **The only host without ffmpeg.** Without it, image thumbnails still work — sharp does those — and video ones do not, along with subtitles and audio extraction. If brew is missing or the install fails, deploy anyway and report the caveat: nothing in this release regresses without ffmpeg, it simply does not gain the video half. |
|
||||
| `production-pertento-bridge` | — | x86_64, port **9010**. |
|
||||
| `edge-pertento-bridge` | — | x86_64. Node v24 here where the fleet is v22; sharp ships binaries for both and this was not a problem for the install on any other host, but if step 2 fails anywhere it is most likely here. |
|
||||
| `deadpool-rezio-bridge` | — | x86_64, login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `bun install` result, the libvips version step 2 printed, whether ffmpeg
|
||||
was found, and `officer` back online with a moved `restart_time`.
|
||||
|
||||
The two lines worth having in the summary that no previous report needed: **which hosts print a libvips
|
||||
version**, and **which hosts have ffmpeg**. Those are the two facts this release is actually about.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.29.01 — deploy report
|
||||
|
||||
**7 of 7 target hosts succeeded. 1 skipped (`officer-dev`, the host this rollout ran on).**
|
||||
|
||||
Every target ended at `58f50a52`, the tag commit. Tag and `package.json` agreed before anything was
|
||||
touched. No host failed, none was left half-applied, and nothing was reset or discarded.
|
||||
|
||||
## The two facts this release is about
|
||||
|
||||
**Every host prints a libvips version.** `sharp@0.35.4` installed and loaded on all seven, `libvips
|
||||
8.18.6` everywhere — x86_64 and arm64, Node v22 and v24. The plan flagged `edge-pertento-bridge` (Node
|
||||
v24) as the most likely failure; it was not one.
|
||||
|
||||
**Every host now has ffmpeg.** Six already had it. `macbruno` did not, and the plan's per-host `brew
|
||||
install ffmpeg` ran there before the restart and succeeded — so this release ships with no ffmpeg caveat
|
||||
outstanding on any host.
|
||||
|
||||
| Host | HEAD before → after | install | libvips | ffmpeg | officer |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | — | **SKIPPED — rollout host** |
|
||||
| `alpha` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 2→3, 401 |
|
||||
| `fofos` | `de425d9d` → `58f50a52` | no-op | 8.18.6 | 6.1.1 (had) | online, rt 25→26, 401 |
|
||||
| `macbookpro` | `16fa8692` → `58f50a52` | 10 pkgs | 8.18.6 | 8.1.2 (had) | online, rt 2→3, 401 :9010 |
|
||||
| `macbruno` | `9c8af9d4` → `58f50a52` | 10 pkgs | 8.18.6 | **9.0.1 (installed)** | online, rt 5→6, 401 |
|
||||
| `production-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 3→4, 401 :9010 |
|
||||
| `edge-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 4→5, 401 |
|
||||
| `deadpool-rezio-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 21→22, 401 |
|
||||
|
||||
Health is `/api/health` on each host's own port, read from its `.env` rather than assumed. `401` is
|
||||
healthy per the plan. `production-pertento-bridge` was checked on 9010, not 9000.
|
||||
|
||||
## Schema
|
||||
|
||||
**Unchanged, and no `bun db:push` was run anywhere.** The plan says so and I verified it rather than
|
||||
taking its word:
|
||||
|
||||
```
|
||||
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||
```
|
||||
|
||||
returns nothing. No table, column or constraint in this release, so the "check the database by query"
|
||||
rule had nothing to check and no SQL was improvised. Plugin schema lines in `plugins:update` output
|
||||
(`schema: applied in …ms, N table(s) verified`) are that script's own per-plugin verification, not this
|
||||
release.
|
||||
|
||||
## The one deviation, and why
|
||||
|
||||
**On `macbookpro` the standard step order failed, and I ran the same steps in the other order.**
|
||||
|
||||
`bun run plugins:update` exited 1 with:
|
||||
|
||||
```
|
||||
error: Cannot find package 'sharp' from '…/src/servers/api/file-browser/thumbnails.ts'
|
||||
```
|
||||
|
||||
The updater imports the server graph, which now reaches the new `thumbnails.ts`, which imports `sharp`.
|
||||
The standard steps in `prompts/deploy-commander.md` put `plugins:update` **before** the plan's `bun
|
||||
install` — so on a host where sharp is genuinely new, the step cannot resolve a dependency the same
|
||||
release is about to add.
|
||||
|
||||
I ran `bun install` first, then `plugins:update`, which then exited 0. Both steps ran, none was skipped
|
||||
and nothing was composed — only the order changed. I did the same on the four hosts after it. `alpha`
|
||||
and `fofos` had already passed in the documented order because sharp was on disk for reasons of their
|
||||
own: `alpha` via a plugin's declared npm dependencies, `fofos` as the pre-tag test host.
|
||||
|
||||
**This is a finding for whoever writes the next plan, not a fix I made.** Any future release that adds a
|
||||
runtime dependency imported by the server graph hits the same wall in the same place. The ordering lives
|
||||
in the commander prompt's standard steps, and correcting it is a documentation change, which a rollout
|
||||
does not make.
|
||||
|
||||
## What else moved
|
||||
|
||||
`bun run plugins:update` restarts sidecars itself. It reported `sidecar: started` on `alpha` (15/15
|
||||
plugins, `officer-music` re-added), `fofos` (5/5), `macbruno` (1/1), `production-pertento-bridge` (1/1,
|
||||
`officer-gitea`), `edge-pertento-bridge` (2/2, `officer-offscale`) and `deadpool-rezio-bridge` (2/2).
|
||||
`macbookpro` has no plugins installed. So more than `officer` bounced on most hosts — that is the
|
||||
updater's own behaviour, not a restart this rollout asked for. The only restart it issued was `pm2
|
||||
restart officer`, per host, by name.
|
||||
|
||||
`production-pertento-bridge`: all eight non-Officer Pertento processes were still `online` after the
|
||||
restart. Nothing there was named or touched.
|
||||
|
||||
## Notes, not problems
|
||||
|
||||
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
|
||||
cannot touch it. No host had tracked changes; no work was discarded anywhere.
|
||||
- `production-pertento-bridge` prints an nvm warning (`has a globalconfig and/or a prefix setting…`) on
|
||||
every `bun`/`node` invocation. Pre-existing, unrelated to this release, harmless to it.
|
||||
- `macbruno`: `officer`'s PM2 environment already carries `/opt/homebrew/bin`, so the newly installed
|
||||
ffmpeg is on the restarted process's PATH. Verified, not assumed.
|
||||
|
||||
## For the owner
|
||||
|
||||
- **`officer-dev` is deliberately a step behind the fleet.** Its checkout is already at `58f50a52`, but
|
||||
nothing was pulled, installed or restarted on it, and `bun install` has NOT run there — so sharp is
|
||||
not necessarily present for the running process. Restart it deliberately when you are happy with the
|
||||
rest, and run `bun install` first or the thumbnails endpoint will not have its dependency.
|
||||
- The `plugins:update` ordering above is worth fixing in the prompt before the next release that adds a
|
||||
dependency.
|
||||
@@ -0,0 +1,119 @@
|
||||
# 2026.08.29.02 — a plugin can ship a commander
|
||||
|
||||
**This one changes the database.** Two columns, both nullable additions, on a table that already exists.
|
||||
There is no rename in it, nothing is dropped, and no existing row changes — but `db:push` is still not
|
||||
to be trusted on its exit code, so there is a per-host query below and the report must carry its answer
|
||||
for each host.
|
||||
|
||||
No dependency change: `package.json` and `bun.lock` are untouched since `2026.08.29.01`. **There is no
|
||||
`bun install` step in this plan** — which also sidesteps the step-order problem the last rollout found,
|
||||
where the standard `plugins:update` ran before the plan's install.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Plugins can ship commanders.** A plugin with `commanders/<name>/COMMANDER.md` offers one; it appears
|
||||
in Command HQ under a new **From plugins** section, arrives **not running**, and must be configured
|
||||
before it can arm.
|
||||
- **av-club ships the first one** — *Convert on arrival*: watches a folder, converts what lands in it.
|
||||
It reaches each host through the standard `bun run plugins:update` step, not through this plan.
|
||||
- Marketplace, file browser and Convert Image work from earlier today rides along.
|
||||
|
||||
## Schema — CHANGED. `db:push` is required.
|
||||
|
||||
```
|
||||
command_hq_sessions
|
||||
+ plugin text null which plugin's template made this commander
|
||||
+ plugin_config jsonb null the owner's answers to that template's inputs
|
||||
```
|
||||
|
||||
Verified as the whole of it:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.01..deploy/2026.08.29.02 | grep -iE 'schema|/db/|databases'
|
||||
# → src/databases/officer_db/src/command-hq/queries.ts
|
||||
# src/databases/officer_db/src/command-hq/schema.ts
|
||||
```
|
||||
|
||||
`queries.ts` is code, not schema. The only structural change is the two columns above.
|
||||
|
||||
**Both are nullable with no default and no constraint**, so every existing row is valid the moment they
|
||||
exist and there is nothing to backfill. This is the shape `db:push` handles cleanly — there is no
|
||||
create-then-constrain here, and no `--force` is needed or permitted.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
After the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.08.29.02`, `bun run plugins:update`), in the checkout from PM2's
|
||||
`pm_cwd`:
|
||||
|
||||
### 1. Back the database up first
|
||||
|
||||
```bash
|
||||
pg_dump "$(grep -m1 -E '^(OFFICERDEV_)?POSTGRES_URL=' .env | cut -d= -f2-)" > ~/db-backups/officer-$(date +%Y%m%d-%H%M).sql
|
||||
```
|
||||
|
||||
Read the variable name rather than assuming it: **several hosts use `OFFICERDEV_POSTGRES_URL`**, not
|
||||
`POSTGRES_URL`. `mkdir -p ~/db-backups` first if it is not there.
|
||||
|
||||
### 2. Push the schema
|
||||
|
||||
```bash
|
||||
bun db:push < /dev/null
|
||||
```
|
||||
|
||||
**stdin closed**, so it can never sit waiting on a prompt in an unattended run — it fails instead, which
|
||||
is the outcome you want. Expect `[✓] Changes applied` and then the two post-push scripts.
|
||||
|
||||
### 3. VERIFY BY QUERY. The exit code is not the answer.
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { db } = await import('officerdb/db'); const { sql } = await import('drizzle-orm');
|
||||
const r = await db.execute(sql\`select column_name from information_schema.columns
|
||||
where table_name='command_hq_sessions' and column_name in ('plugin','plugin_config') order by column_name\`);
|
||||
console.log([...((r as any).rows ?? r)].map((x:any)=>x.column_name).join(' '));
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
Must print exactly:
|
||||
|
||||
```
|
||||
plugin plugin_config
|
||||
```
|
||||
|
||||
**Put that line in the report, per host.** Not "schema ✓" — the output. A host printing one column, or
|
||||
none, is a **failed host**: report it, move on, and do not attempt a repair you invented.
|
||||
|
||||
### 4. Restart
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only. `plugins:update` has already restarted the plugin sidecars itself.
|
||||
|
||||
### 5. Check it came back
|
||||
|
||||
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are both healthy.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. Its schema is already pushed; nothing else is needed. Name it as skipped. |
|
||||
| `alpha` | Nothing special. |
|
||||
| `fofos` | The one that matters this time — `av-club` is installed there and the commander template is the point of the release. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||
| `production-pertento-bridge` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | Nothing special. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, whether the backup was written, `db:push` result, **the exact output of
|
||||
the verification query**, and `officer` back online with a moved `restart_time`.
|
||||
|
||||
The line that matters in the summary: **which hosts print `plugin plugin_config`**. That is the only
|
||||
thing this release actually changes about a machine.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026.08.29.02 — deploy report
|
||||
|
||||
**7 of 7 target hosts deployed. `officer-dev` skipped by design.** Every host prints
|
||||
`plugin plugin_config` — the two new `command_hq_sessions` columns exist on all eight databases.
|
||||
|
||||
All eight checkouts were at `58f50a52` (the `2026.08.29.01` tag) before this rollout, and all are at
|
||||
`78bf63c2` (`deploy/2026.08.29.02`) now. No host was ahead, none diverged, none was dirty.
|
||||
|
||||
This was the first release under a new watcher loop. The rollout itself behaved as written.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | plugins:update | backup | db:push | verification query | officer | health |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — **SKIPPED** | — | — | — | `plugin plugin_config` | not restarted | — |
|
||||
| `alpha` | `58f50a52` → `78bf63c2` | 15/15, exit 0 | 12,309,974 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (9000) |
|
||||
| `fofos` | `58f50a52` → `78bf63c2` | 5/5, exit 0 | 2,792,097 B | exit 0 | `plugin plugin_config` | 26 → 27 restarts, online | 401 (9000) |
|
||||
| `macbookpro` | `58f50a52` → `78bf63c2` | no plugins installed | 5,179,143 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (**9010**) |
|
||||
| `macbruno` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 86,480 B | exit 0 | `plugin plugin_config` | 6 → 7 restarts, online | 401 (9000) |
|
||||
| `production-pertento-bridge` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 187,488 B, **second attempt** | exit 0 | `plugin plugin_config` | 4 → 5 restarts, online | 401 (**9010**) |
|
||||
| `edge-pertento-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 5,916,050 B | exit 0 | `plugin plugin_config` | 5 → 6 restarts, online | 401 (9000) |
|
||||
| `deadpool-rezio-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 92,979 B | exit 0 | `plugin plugin_config` | 22 → 23 restarts, online | 401 (9000) |
|
||||
|
||||
`401` is the healthy answer from `/api/health` behind the session gate, as the plan says.
|
||||
|
||||
## What the release was for, confirmed on the host that matters
|
||||
|
||||
`fofos` carries `av-club`, and the commander template it now ships is on disk:
|
||||
`plugins/av-club/commanders/convert-on-arrival/COMMANDER.md`, plugin checkout at `5b2fe03`
|
||||
("Convert on arrival — the first plugin-shipped commander"). The plugin arrived through the standard
|
||||
`bun run plugins:update` step, as the plan predicted — nothing in this plan installed it.
|
||||
|
||||
## What did not go to plan
|
||||
|
||||
**`production-pertento-bridge` failed its backup on the first attempt and the host stopped there.**
|
||||
The plan's `pg_dump` is Ubuntu's 16.15; that host's Postgres is 18.3, and `pg_dump` refuses a newer
|
||||
server outright:
|
||||
|
||||
```
|
||||
pg_dump: error: aborting because of server version mismatch
|
||||
pg_dump: detail: server version: 18.3; pg_dump version: 16.15
|
||||
```
|
||||
|
||||
The host was left correct but half-done: code at the tag, plugins updated, **no `db:push` and no
|
||||
restart**, so the running process was still serving the old code against the old schema. Nothing was
|
||||
broken by stopping there.
|
||||
|
||||
Postgres on that host runs in a docker container (`postgres:18.3-alpine`), so a version-matched client
|
||||
was already present. The backup was retaken with `docker exec postgres pg_dump` — same dump, correct
|
||||
tool, read-only — and the plan then continued unchanged from step 2. Result above.
|
||||
|
||||
**This is a per-host detail worth writing down**, because it will recur on every release whose plan
|
||||
backs the database up: the plan's `pg_dump` line is host-portable only where the client is at least as
|
||||
new as the server. `production-pertento-bridge` is the one host where it is not.
|
||||
|
||||
**`fofos` health read `000` immediately after the restart** and `401` fifteen seconds later. The check
|
||||
was racing a cold start, not finding a fault. The health check now retries for up to thirty seconds
|
||||
before it reports a number.
|
||||
|
||||
**`fofos` has one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could not have
|
||||
disturbed it.
|
||||
|
||||
## Skipped
|
||||
|
||||
`officer-dev` — the rollout ran on it. Its checkout was already at `78bf63c2` and its database already
|
||||
carries both columns (verified by the same query: `plugin plugin_config`). **Its `officer` process was
|
||||
not restarted**, so it is running the previous build. The owner restarts it deliberately:
|
||||
|
||||
```
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`docs/servers.md` lists nothing under "Deliberately skipped" today, and no host was unreachable —
|
||||
`macbookpro` was awake.
|
||||
@@ -0,0 +1,85 @@
|
||||
# 2026.08.29.03 — the todo board leaves, and a plugin can claim a file type
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.02..deploy/2026.08.29.03 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --stat deploy/2026.08.29.02..deploy/2026.08.29.03 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
|
||||
Ten commits, all code.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **`/todos` is gone from the platform.** It is now the `kanban` plugin, in the marketplace. The
|
||||
`todos` permission, the route, the screen and the board were all removed.
|
||||
- **A plugin can claim a file type.** A manifest may declare `fileTypes`; the file browser then offers
|
||||
thumbnails and a full-size render for formats the platform itself cannot read. av-club claims
|
||||
`heic`/`heif`, so on a host with av-club installed, iPhone photographs get pictures instead of file
|
||||
icons and open in the viewer.
|
||||
- Thumbnails retry rather than giving up, and list and grid ask for the same size.
|
||||
- The directory picker moved into the platform so more than one plugin can use it.
|
||||
|
||||
## A behaviour change to expect, and it is deliberate
|
||||
|
||||
**`/todos` now 404s.** Anyone with it bookmarked gets nothing. The board is the `kanban` plugin, which
|
||||
is in the marketplace and installed nowhere by default — that is the intended state, not an oversight.
|
||||
|
||||
`role_permissions` rows naming `todos` are now orphaned. Harmless: absence denies, and the registry no
|
||||
longer has the key. **Do not clean them up as part of this rollout** — it is a data edit nobody asked
|
||||
for, and a row naming a permission that does not exist grants nothing.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the whole rollout this time (dirty check,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.03`, `bun run plugins:update`), plus:
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only — `plugins:update` has already restarted the plugin sidecars.
|
||||
|
||||
### Verify it came back
|
||||
|
||||
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
|
||||
|
||||
### And check the file browser still lists
|
||||
|
||||
This release touched the listing route, so one call per host is worth it:
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { sign } = await import('./src/servers/jwt');
|
||||
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
|
||||
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
|
||||
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
|
||||
|
||||
## Per host
|
||||
|
||||
Surveyed 2026-08-29 while writing this.
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | No av-club, so no HEIC support — expected, not a fault. |
|
||||
| `fofos` | **The only host with av-club installed**, and already at this commit and verified: 6 HEICs thumbnail and one renders at full size. Bring it to the tag so its HEAD matches the fleet. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||
| `production-pertento-bridge` | Port **9010**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
|
||||
| `edge-pertento-bridge` | Did not answer the survey while this plan was written. It may simply have been busy; treat it as an ordinary target and report what you find. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
|
||||
`restart_time`, and the `ls` check's output.
|
||||
|
||||
Nothing in this release needs a database query, a backup or an install. If you find yourself running
|
||||
one, stop — the plan is wrong and that is worth reporting rather than working around.
|
||||
@@ -9,14 +9,14 @@ read-in-place rule this mechanism is the one deliberate exception to).
|
||||
## The shape of it
|
||||
|
||||
Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files
|
||||
that merely *mention* `CLAUDE.md` in a comment suggests.
|
||||
that merely _mention_ `CLAUDE.md` in a comment suggests.
|
||||
|
||||
| # | Site | Verb | Cardinality | Trigger |
|
||||
|---|------|------|-------------|---------|
|
||||
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
|
||||
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory | every plugin-panel chat start |
|
||||
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
|
||||
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
|
||||
| # | Site | Verb | Cardinality | Trigger |
|
||||
| --- | ------------------------------------------------------ | --------------------------- | -------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
|
||||
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory, plus one per email account | every plugin-panel chat start, and every email chat start |
|
||||
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
|
||||
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
|
||||
|
||||
Two things fell out of tracing this that are worth stating up front, because they explain why the
|
||||
mechanism looks the way it does rather than something simpler:
|
||||
@@ -39,7 +39,7 @@ mechanism looks the way it does rather than something simpler:
|
||||
|
||||
Called once, from `command-hq/router.ts`, when `POST /command-hq/commanders` creates a commander — the
|
||||
row, the directory, and this file, in that order, all before any turn is allowed to spawn. The ordering
|
||||
is load-bearing: a turn that spawned before the directory existed used to recreate an *empty* one on a
|
||||
is load-bearing: a turn that spawned before the directory existed used to recreate an _empty_ one on a
|
||||
race with deletion (`websocket.ts:300-308` describes the incident), so the row now has to exist first
|
||||
and the directory has to exist before a turn can reach it.
|
||||
|
||||
@@ -68,7 +68,7 @@ to be reading the file yet.
|
||||
**What happens after creation is the interesting part: nothing does, from the platform's side.** The
|
||||
file is described everywhere as "a living document its commander has been extending" — the commander
|
||||
itself edits it during its own life, the same file a human would open in the file browser. The platform
|
||||
never rewrites it. That is *why* item 3 below has to append rather than regenerate: a commander with
|
||||
never rewrites it. That is _why_ item 3 below has to append rather than regenerate: a commander with
|
||||
months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
|
||||
|
||||
### 2. Plugin chat briefs — the copier
|
||||
@@ -95,8 +95,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise<voi
|
||||
}
|
||||
```
|
||||
|
||||
Called from `resolveChatCwd()` (line 332-336) exactly when `msg.context === 'plugin'` — never for a
|
||||
commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync`
|
||||
Called from `resolveChatCwd()` from **two** branches, and the second one is the interesting case:
|
||||
|
||||
- `msg.context === 'plugin'` — the plugin's own chats directory. The ordinary path.
|
||||
- `msg.context === 'email'` — the **account's** directory, the one holding `emails.db`.
|
||||
|
||||
Never for a commander and never for an ordinary `/chat` session.
|
||||
|
||||
Email is the exception because an email chat does not run in the plugin's chats directory: it runs beside
|
||||
the database it exists to answer from, which is the entire point of that context. So the brief follows the
|
||||
chat rather than the chat moving to the brief, and `plugins/email/chat/CLAUDE.md` lands in
|
||||
`email_accounts/<address>/`. A second plugin wanting this would be the signal that "which directory does
|
||||
this plugin's chat run in" belongs in the manifest rather than in a branch here. That branch also does the `mkdirSync`
|
||||
for the chat directory itself, so the two writers (this one and the commander generator) can never
|
||||
target the same directory: a commander's cwd is `$DATA_PATH/<email>/commanders/<dirName>/`, a plugin
|
||||
chat's is `$DATA_PATH/<email>/plugins/<appName>/chats/`.
|
||||
@@ -105,7 +115,7 @@ The source is `<plugin-repo>/chat/CLAUDE.md` — a file the plugin author ships
|
||||
read fresh from `snapshotPlugins()` on every chat start rather than cached, so a `plugins:update` needs
|
||||
no republish step for the brief to change. Content-compared before writing (`writeAtomic` only fires on
|
||||
an actual diff) specifically so a chat directory's mtimes don't churn on every message — this is called
|
||||
once per chat *start*, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
|
||||
once per chat _start_, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
|
||||
|
||||
Best-effort throughout, on purpose: a plugin with no `chat/CLAUDE.md` (the ordinary case — see the
|
||||
per-plugin table below) is not an error, it is a chat that knows a bit less than one with a brief. A
|
||||
@@ -178,11 +188,11 @@ specifically to stop a future change from "fixing" this back in.
|
||||
what fixed it.
|
||||
- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs
|
||||
un-isolated, `HOME` pointed at their real home, so a generated container `CLAUDE.md` would land
|
||||
*inside* `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
|
||||
_inside_ `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
|
||||
reads. Writing one there would silently mix generated content into a document that is supposed to be
|
||||
entirely the owner's.
|
||||
- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that
|
||||
the *old* `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
|
||||
the _old_ `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
|
||||
`settings.json` describing a user's Docker container, as part of an architecture that no longer exists.
|
||||
What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing
|
||||
generated inside either.
|
||||
@@ -199,28 +209,28 @@ at the top of the plugin's own repository, picked up automatically the moment th
|
||||
starts a conversation. There is no manifest field for this and nothing to register — the platform simply
|
||||
checks whether the file exists at that fixed path within the plugin's directory on every chat start.
|
||||
|
||||
| Plugin | `chat/CLAUDE.md` | Notes |
|
||||
|---|---|---|
|
||||
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
|
||||
| `atelier` | no | |
|
||||
| `bitcoin-wallet` | no | |
|
||||
| `browser` | no | |
|
||||
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
|
||||
| `email` | no | |
|
||||
| `example` | no | |
|
||||
| `gitea` | no | Ships an *affordance* (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
|
||||
| `invoices` | no | |
|
||||
| `iris` | no | |
|
||||
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
|
||||
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
|
||||
| `minesweeper` | no | |
|
||||
| `music` | no | |
|
||||
| `offscale` | no | |
|
||||
| `photos` | no | |
|
||||
| `soulseek` | no | |
|
||||
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
|
||||
| `transmission` | no | |
|
||||
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
|
||||
| Plugin | `chat/CLAUDE.md` | Notes |
|
||||
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
|
||||
| `atelier` | no | |
|
||||
| `bitcoin-wallet` | no | |
|
||||
| `browser` | no | |
|
||||
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
|
||||
| `email` | no | |
|
||||
| `example` | no | |
|
||||
| `gitea` | no | Ships an _affordance_ (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
|
||||
| `invoices` | no | |
|
||||
| `iris` | no | |
|
||||
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
|
||||
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
|
||||
| `minesweeper` | no | |
|
||||
| `music` | no | |
|
||||
| `offscale` | no | |
|
||||
| `photos` | no | |
|
||||
| `soulseek` | no | |
|
||||
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
|
||||
| `transmission` | no | |
|
||||
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
|
||||
|
||||
**No plugin writes, generates, or appends to a `CLAUDE.md` programmatically.** Every plugin-side hit that
|
||||
mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code
|
||||
|
||||
+39
-10
@@ -21,21 +21,50 @@ ssh br@deadpool-rezio-bridge # not implied
|
||||
|
||||
## Officer platforms
|
||||
|
||||
Seven, all running.
|
||||
Eight, all running. **Verified 2026-08-27** — every row below was read off the machine, not recalled.
|
||||
|
||||
| Host | Tailnet IP | Login | Checkout | Domain |
|
||||
| ---------------------------- | ----------- | ----------- | ----------------------------------- | --------------------------------- |
|
||||
| `officer-dev` | 100.64.0.13 | `pastilhas` | `~/officer-dev/platform` | `platform.officer.dev` |
|
||||
| `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` |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
| Host | Tailnet IP | Login | Port | Checkout | Domain |
|
||||
| ---------------------------- | ----------- | ----------------------------------------- | -------- | ----------------------------------- | --------------------------------- |
|
||||
| `officer-dev` | 100.64.0.13 | `pastilhas` | 9000 | `~/officer-dev/platform` | `platform.officer.dev` |
|
||||
| `alpha` | 100.64.0.1 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.pastilhas.dev` |
|
||||
| `fofos` | 100.64.0.17 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | **9010** | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||
| `macbruno` | 100.64.0.22 | **`brunoalcadapadezmonteirodaconceicao`** | 9000 | `~/officerdev/platform` | — (none yet) |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | 9000 | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
|
||||
**THE PORT IS NOT ALWAYS 9000.** Two hosts run on 9010 and it is in `OFFICERDEV_PORT` in each checkout's
|
||||
`.env`. This is not a footnote: on 2026-08-27 a health check against 9000 on `production-pertento-bridge`
|
||||
hit one of Pertento's own apps, which answered `403`, and that was read as Officer failing after a
|
||||
deploy. Take the port from the `.env`, never from habit.
|
||||
|
||||
**`production-pertento-bridge` also runs eight processes that are NOT Officer** — `Dashboard`, `Tracking`,
|
||||
`Uptime` and five `Crons *`. They are production-critical and belong to Pertento. Anything sweeping over
|
||||
`pm2 list` must filter to `officer` and `officer-*`, and a restart there must name its target.
|
||||
|
||||
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||
reachable from inside only.
|
||||
|
||||
## `pm2` is not on the PATH of a non-interactive shell
|
||||
|
||||
`ssh host 'pm2 jlist'` runs a NON-interactive, NON-login shell. zsh reads `~/.zshenv` for those and reads
|
||||
neither `~/.zshrc` nor `~/.zprofile` — so a pm2 installed by nvm or Homebrew is invisible to an
|
||||
orchestrator even though the daemon is running and healthy.
|
||||
|
||||
**This read as "pm2 is not installed" three times on 2026-08-27**, and twice sent someone diagnosing a
|
||||
machine with nothing wrong with it. `claude` on a fresh install has the same shape.
|
||||
|
||||
Fixed at the source on the two hosts where it bit — `production-pertento-bridge` (nvm) and `macbruno`
|
||||
(Homebrew at `/opt/homebrew`, which is on no default PATH on Apple Silicon) — by appending a
|
||||
marker-guarded block to `~/.zshenv`. Everything else already worked.
|
||||
|
||||
If a command comes back empty over SSH, look for the binary on disk before concluding it is absent:
|
||||
|
||||
```bash
|
||||
ssh host 'ls /opt/homebrew/bin/pm2 /usr/local/bin/pm2 ~/.nvm/versions/node/*/bin/pm2 2>/dev/null'
|
||||
```
|
||||
|
||||
## Deliberately skipped
|
||||
|
||||
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 3333,
|
||||
"enable_request_logging": false,
|
||||
"last_updated": "2025-12-24T04:27:17.443375"
|
||||
}
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.26.01",
|
||||
"version": "2026.08.29.03",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -39,7 +39,8 @@
|
||||
"setup": "bash scripts/install.sh",
|
||||
"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"
|
||||
"skills:platform": "bun run scripts/gen-platform-skill.ts",
|
||||
"plugins:check": "bun run scripts/plugins-check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
@@ -159,6 +160,7 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"shiki": "^3.22.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sounds": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Plugins
|
||||
|
||||
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
|
||||
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
|
||||
plugin author needs that are not procedure.
|
||||
|
||||
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
|
||||
appears by `git clone` when somebody installs one, and is gitignored here.
|
||||
|
||||
---
|
||||
|
||||
## Affordances
|
||||
|
||||
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
|
||||
the file format and how they reach the agent. Read it before writing one. What follows is only the part
|
||||
that is specific to shipping one from a plugin.
|
||||
|
||||
> An affordance is something the surface on the other end of the conversation offers the agent — declared
|
||||
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
|
||||
> client will honour.
|
||||
|
||||
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
|
||||
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
|
||||
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
|
||||
inside the file.
|
||||
|
||||
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
|
||||
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
|
||||
that it was a bug. See the two worked examples in `docs/affordances.md`.
|
||||
|
||||
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
|
||||
have nothing to do with your plugin.
|
||||
|
||||
### A plugin-contributed one, for reference## Notifications — the same shape, for push
|
||||
|
||||
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
|
||||
|
||||
```
|
||||
capabilities/notifications/
|
||||
officerdev/music/
|
||||
catalogue.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
nowPlaying:
|
||||
title: 'Now playing'
|
||||
route: 'officer://music/{id}'
|
||||
```
|
||||
|
||||
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
|
||||
and the device renders the title and follows the route. The point is that **adding a notification is a
|
||||
server change and never an app build**, because the mapping is synced to the device rather than compiled
|
||||
into it.
|
||||
|
||||
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
|
||||
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
|
||||
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
|
||||
|
||||
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
|
||||
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
|
||||
catalogue is skipped without affecting anyone else's.
|
||||
|
||||
See `docs/push-notifications.md`.
|
||||
|
||||
### A plugin-contributed one, for reference
|
||||
|
||||
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
|
||||
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Gitea
|
||||
description: Use when working with git repositories, or with Gitea issues and pull requests.
|
||||
version: 1
|
||||
---
|
||||
|
||||
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
|
||||
to the server directly.
|
||||
|
||||
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
|
||||
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
|
||||
configure it, and do not ask the user for a token.
|
||||
```
|
||||
|
||||
### On the word
|
||||
|
||||
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
|
||||
`capabilities/skills/` here and by the wider AI ecosystem.
|
||||
|
||||
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
|
||||
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
|
||||
other end permits. Keeping that distinction in the name saves explaining it every time.
|
||||
+3
-89
@@ -1,92 +1,6 @@
|
||||
# Plugins
|
||||
|
||||
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
|
||||
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
|
||||
plugin author needs that are not procedure.
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
|
||||
appears by `git clone` when somebody installs one, and is gitignored here.
|
||||
|
||||
---
|
||||
|
||||
## Affordances
|
||||
|
||||
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
|
||||
the file format and how they reach the agent. Read it before writing one. What follows is only the part
|
||||
that is specific to shipping one from a plugin.
|
||||
|
||||
> An affordance is something the surface on the other end of the conversation offers the agent — declared
|
||||
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
|
||||
> client will honour.
|
||||
|
||||
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
|
||||
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
|
||||
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
|
||||
inside the file.
|
||||
|
||||
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
|
||||
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
|
||||
that it was a bug. See the two worked examples in `docs/affordances.md`.
|
||||
|
||||
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
|
||||
have nothing to do with your plugin.
|
||||
|
||||
### A plugin-contributed one, for reference## Notifications — the same shape, for push
|
||||
|
||||
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
|
||||
|
||||
```
|
||||
capabilities/notifications/
|
||||
officerdev/music/
|
||||
catalogue.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
nowPlaying:
|
||||
title: 'Now playing'
|
||||
route: 'officer://music/{id}'
|
||||
```
|
||||
|
||||
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
|
||||
and the device renders the title and follows the route. The point is that **adding a notification is a
|
||||
server change and never an app build**, because the mapping is synced to the device rather than compiled
|
||||
into it.
|
||||
|
||||
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
|
||||
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
|
||||
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
|
||||
|
||||
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
|
||||
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
|
||||
catalogue is skipped without affecting anyone else's.
|
||||
|
||||
See `docs/push-notifications.md`.
|
||||
|
||||
### A plugin-contributed one, for reference
|
||||
|
||||
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
|
||||
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Gitea
|
||||
description: Use when working with git repositories, or with Gitea issues and pull requests.
|
||||
version: 1
|
||||
---
|
||||
|
||||
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
|
||||
to the server directly.
|
||||
|
||||
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
|
||||
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
|
||||
configure it, and do not ask the user for a token.
|
||||
```
|
||||
|
||||
### On the word
|
||||
|
||||
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
|
||||
`capabilities/skills/` here and by the wider AI ecosystem.
|
||||
|
||||
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
|
||||
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
|
||||
other end permits. Keeping that distinction in the name saves explaining it every time.
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
@@ -64,7 +64,15 @@ for (const row of rows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const claudeMd = join(dir, 'CLAUDE.md');
|
||||
/*
|
||||
AGENTS.md where it exists, CLAUDE.md where it does not.
|
||||
|
||||
Since 2026-08-27 a commander's brief is written as AGENTS.md with a CLAUDE.md pointing at it, because
|
||||
opencode discovers only the first name. Commanders created BEFORE that have the content in CLAUDE.md
|
||||
and no AGENTS.md at all. Appending to the wrong one is the quiet failure this avoids: on a new
|
||||
commander it would bury a credential section inside a six-line pointer the agent never reads.
|
||||
*/
|
||||
const brief = existsSync(join(dir, 'AGENTS.md')) ? join(dir, 'AGENTS.md') : join(dir, 'CLAUDE.md');
|
||||
|
||||
/**
|
||||
* Append a section only when it is not already described.
|
||||
@@ -73,10 +81,10 @@ for (const row of rows) {
|
||||
* describe the same thing does not end up with it twice.
|
||||
*/
|
||||
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
|
||||
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
|
||||
const existing = existsSync(brief) ? readFileSync(brief, 'utf8') : '';
|
||||
if (existing.includes(marker)) return false;
|
||||
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, claudeMd);
|
||||
appendFileSync(brief, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, brief);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ async function main(): Promise<void> {
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
record(
|
||||
checkCount(
|
||||
join(ROOT, 'CLAUDE.md'),
|
||||
join(ROOT, 'AGENTS.md'),
|
||||
/([0-9]+|[a-z-]+) entries as of/gi,
|
||||
PERMISSIONS.length,
|
||||
'permission registry entries',
|
||||
|
||||
@@ -60,9 +60,34 @@ function coreProcesses(): Pm2App[] {
|
||||
const [name, script, args] = line.split('|');
|
||||
if (!name || !script) return fail(`unreadable CORE_PROCESSES entry: "${line}"`);
|
||||
return { name, script, args, cwd: PLATFORM_DIR, watch: false };
|
||||
});
|
||||
})
|
||||
.filter((app) => !DISABLED.has(app.name));
|
||||
}
|
||||
|
||||
/*
|
||||
Core processes this host does not want, from `OFFICER_DISABLED_SIDECARS` in `.env`.
|
||||
|
||||
A core process is core because the platform's own features depend on it — which is why the list is a
|
||||
fixed array and not a setting. But "the platform depends on it" is not "every host runs it": fofos is a
|
||||
1.9 GB machine serving a website, and `officer-opencode` sat there costing 456 MB of that for a harness
|
||||
nobody on it uses. It froze hard twice, with no OOM line in the journal — which is what memory
|
||||
exhaustion looks like when there is not enough headroom left to kill something cleanly.
|
||||
|
||||
Deliberately NOT a database setting. This file is regenerated during a plugin install, so a host that
|
||||
has dropped a process needs it to stay dropped across one. `.env` is read at generation time and is
|
||||
per-host, which is exactly the scope of the decision.
|
||||
|
||||
Naming something the platform genuinely needs will break that host — `officer` itself, or
|
||||
`officer-pty` if anyone uses a terminal. Not validated, because which processes are safe to drop
|
||||
depends on what the host is for, and this script cannot know that.
|
||||
*/
|
||||
const DISABLED = new Set(
|
||||
(process.env.OFFICER_DISABLED_SIDECARS ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
/** Installed AND enabled AND actually ships a sidecar — all three, or it gets no entry. */
|
||||
async function pluginEntries(): Promise<Pm2App[]> {
|
||||
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
|
||||
|
||||
@@ -60,7 +60,17 @@ export async function renderPlatformSkill(): Promise<string> {
|
||||
const out = (s = '') => lines.push(s);
|
||||
|
||||
out('---');
|
||||
out('name: Officer platform');
|
||||
/*
|
||||
`name` is an IDENTIFIER, not a label: lowercase-hyphenated and equal to the directory, per both
|
||||
harnesses' rules. The human name is `title`.
|
||||
|
||||
This generator emitted `name: Officer platform` and was missed when c24859c fixed every skill by
|
||||
hand — so the file on disk was right and running this script put the old shape back. `docs:check`
|
||||
then failed on a line count that nobody could fix by regenerating, which is the worst place for a
|
||||
drift check to point.
|
||||
*/
|
||||
out('name: platform');
|
||||
out('title: 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: >-');
|
||||
|
||||
@@ -66,7 +66,26 @@ const git = (dir: string, args: string[]): string | null => {
|
||||
return new TextDecoder().decode(result.stdout).trim() || null;
|
||||
};
|
||||
|
||||
const PLUGINS_DIR = resolve(import.meta.dir, '..', 'plugins');
|
||||
/*
|
||||
Which plugins directory to publish FROM.
|
||||
|
||||
There is one: `platform/plugins/`, holding what this machine has INSTALLED — updated by
|
||||
`plugins:update`, which is a git pull, so it sits at whatever version was last installed.
|
||||
|
||||
Publishing from it means the catalogue advertises the version this machine happens to be running
|
||||
rather than the version that was released. That is exactly what happened on 2026-08-27: 21 plugins
|
||||
were tagged and the store went on showing 1.0.0.
|
||||
|
||||
A second directory — `officer-dev/plugins/`, every repo in the org cloned side by side — was created
|
||||
to fix that, and never did: its entries were SYMLINKS into this one, so both paths read the same
|
||||
working copy and published the same version. It was removed on 2026-08-28.
|
||||
|
||||
**The problem is still open, and a second checkout was never the answer.** Each directory here is a
|
||||
real git repo with its own tags, so the released manifest can be read out of the tag itself —
|
||||
`git show <newest-tag>:manifest.ts` — without caring what is checked out. That is the fix; it is not
|
||||
written yet, and until it is, this script advertises what is installed.
|
||||
*/
|
||||
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
|
||||
|
||||
async function syncOne(dir: string): Promise<Sync | null> {
|
||||
const appName = basename(dir);
|
||||
@@ -109,6 +128,11 @@ async function syncOne(dir: string): Promise<Sync | null> {
|
||||
platform: String(manifest.platform ?? '>=1.0.0'),
|
||||
label: String(manifest.label ?? appName),
|
||||
summary: String(manifest.summary ?? ''),
|
||||
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
|
||||
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
|
||||
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
|
||||
// quietly disagreeing with the repository it claims to mirror.
|
||||
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
|
||||
color: (manifest.color as string) ?? null,
|
||||
tile: (manifest.tile as string) ?? null,
|
||||
// Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Is every plugin checkout still a well-formed clone of its own repository?
|
||||
|
||||
── Why a script and not care ──
|
||||
|
||||
On 2026-08-27 one commit had to go into all twenty-one plugin repos. Seven pushes failed, each for a
|
||||
different reason, and none of the reasons had anything to do with the commit:
|
||||
|
||||
· six remotes were HTTPS, which cannot authenticate non-interactively — they simply hung and failed
|
||||
· three checkouts were behind their published remote, so the push was rejected as non-fast-forward
|
||||
· one (`offscale`) had a fetch refspec and an upstream naming a branch called `main` that does not
|
||||
exist on the remote. That one is the reason this file exists: every command reported on a phantom.
|
||||
`git push` said "Everything up-to-date" while `git status` said the branch had diverged, and the
|
||||
repository was actually fine. Twenty minutes went into a repo with nothing wrong with it.
|
||||
· one remote URL had no `.git` suffix, so the catalogue published a clone URL unlike every other
|
||||
|
||||
None of that is visible until you try to push, which is the worst possible moment to find out — you are
|
||||
mid-task, across twenty-one repositories, and the failures look like they are about your change.
|
||||
|
||||
── What it checks, and what it deliberately does not ──
|
||||
|
||||
Only mechanical facts with one right answer: the remote's URL form, the fetch refspec, the branch, its
|
||||
upstream, and whether the checkout has diverged. It does not judge content, does not fetch by default
|
||||
(that would make a local check depend on the network), and never writes — `--fix` is a separate flag so
|
||||
that running the check can never be the thing that changes your tree.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/*
|
||||
Which plugins directory to check.
|
||||
|
||||
`platform/plugins/` is the one that exists. There was briefly a second — `officer-dev/plugins/`, meant
|
||||
to hold every repo in the org side by side — and it was removed on 2026-08-28 once it held nothing but
|
||||
symlinks into this one. A plugin cannot live outside the platform anyway: Bun resolves a symlink to its
|
||||
real path, so `@@/…` and every relative import fail from there. Tested, including
|
||||
`--preserve-symlinks`, which Bun accepts and ignores.
|
||||
|
||||
`PLUGINS_DIR` stays overridable for a checkout somewhere else, which costs nothing and is the only
|
||||
reason this is not a constant.
|
||||
*/
|
||||
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins');
|
||||
const HOST = 'git@gitea.officer.dev';
|
||||
const BRANCH = 'master';
|
||||
const REFSPEC = '+refs/heads/*:refs/remotes/origin/*';
|
||||
|
||||
const FIX = process.argv.includes('--fix');
|
||||
const FETCH = process.argv.includes('--fetch');
|
||||
|
||||
const git = (dir: string, args: string[]): string | null => {
|
||||
const p = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
|
||||
};
|
||||
|
||||
type Problem = { plugin: string; what: string; fix?: () => void };
|
||||
const problems: Problem[] = [];
|
||||
|
||||
const dirs = readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
||||
// `statSync().isDirectory()`, not the dirent's — a Dirent for a SYMLINK to a directory answers false to
|
||||
// `isDirectory()` and true to `isSymbolicLink()`, so filtering on the dirent silently skips every
|
||||
// symlinked plugin. `platform/plugins/` is symlinks into the workspace clones since 2026-08-27, which
|
||||
// would have made this report "0 plugin repositories, all well-formed" — a green check over nothing.
|
||||
// `discover.ts` already does it this way, at line 239, for the same reason.
|
||||
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
|
||||
.filter((e) => {
|
||||
try {
|
||||
return statSync(join(PLUGINS_DIR, e.name)).isDirectory();
|
||||
} catch {
|
||||
return false; // a broken symlink, which the checks below have nothing useful to say about
|
||||
}
|
||||
})
|
||||
.map((e) => e.name)
|
||||
.filter((name) => existsSync(join(PLUGINS_DIR, name, '.git')))
|
||||
.sort();
|
||||
|
||||
/*
|
||||
A repo with no `manifest.ts` is not a plugin, and that is not drift.
|
||||
|
||||
`av-club` is the case: a standalone media service living in the plugins org, intended to become a
|
||||
plugin in time. Checking it against a plugin's conventions would report its branch name as a defect
|
||||
forever, and a check that cries wolf is one people stop reading.
|
||||
|
||||
Presence is the declaration here exactly as it is in `plugins/discover.ts` — the manifest is what makes
|
||||
something a plugin, so its absence is what makes something not one yet.
|
||||
*/
|
||||
const notYet = dirs.filter((name) => !existsSync(join(PLUGINS_DIR, name, 'manifest.ts')));
|
||||
const checkable = dirs.filter((name) => !notYet.includes(name));
|
||||
|
||||
for (const name of checkable) {
|
||||
const dir = join(PLUGINS_DIR, name);
|
||||
const expected = `${HOST}:plugins/${name}.git`;
|
||||
|
||||
// 1. The remote, exactly. SSH because that is what can push unattended, and `.git` because a URL unlike
|
||||
// every other is the one an installer trips over.
|
||||
const url = git(dir, ['remote', 'get-url', 'origin']);
|
||||
if (url !== expected) {
|
||||
problems.push({
|
||||
plugin: name,
|
||||
what: `remote is ${url ?? 'missing'}, expected ${expected}`,
|
||||
fix: () => void git(dir, ['remote', 'set-url', 'origin', expected]),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. The fetch refspec. A narrowed one (a --single-branch clone) hides every other branch; a WRONG one
|
||||
// makes git report on a branch that does not exist, which is the failure that cost the most time.
|
||||
const refspecs = (git(dir, ['config', '--get-all', 'remote.origin.fetch']) ?? '').split('\n').filter(Boolean);
|
||||
if (refspecs.length !== 1 || refspecs[0] !== REFSPEC) {
|
||||
problems.push({
|
||||
plugin: name,
|
||||
what: `fetch refspec is ${refspecs.join(' + ') || 'unset'}, expected ${REFSPEC}`,
|
||||
fix: () => {
|
||||
git(dir, ['config', '--unset-all', 'remote.origin.fetch']);
|
||||
git(dir, ['config', '--add', 'remote.origin.fetch', REFSPEC]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 3. The branch, and what it tracks.
|
||||
const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
if (branch !== BRANCH) {
|
||||
problems.push({ plugin: name, what: `on branch ${branch ?? '?'}, expected ${BRANCH}` });
|
||||
continue; // the checks below are all about this branch
|
||||
}
|
||||
const upstream = git(dir, ['config', '--get', `branch.${BRANCH}.merge`]);
|
||||
if (upstream !== `refs/heads/${BRANCH}`) {
|
||||
problems.push({
|
||||
plugin: name,
|
||||
what: `${BRANCH} tracks ${upstream ?? 'nothing'}, expected refs/heads/${BRANCH}`,
|
||||
fix: () => void git(dir, ['branch', `--set-upstream-to=origin/${BRANCH}`, BRANCH]),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Uncommitted work, and divergence. Not fixable here — both need a human decision about the content.
|
||||
const dirty = git(dir, ['status', '--porcelain']);
|
||||
if (dirty) problems.push({ plugin: name, what: `${dirty.split('\n').length} uncommitted file(s)` });
|
||||
|
||||
if (FETCH) git(dir, ['fetch', '-q', 'origin']);
|
||||
const counts = git(dir, ['rev-list', '--left-right', '--count', `origin/${BRANCH}...HEAD`]);
|
||||
if (counts) {
|
||||
const [behind, ahead] = counts.split(/\s+/).map(Number);
|
||||
if (behind) problems.push({ plugin: name, what: `${behind} commit(s) behind origin/${BRANCH}` });
|
||||
if (ahead) problems.push({ plugin: name, what: `${ahead} commit(s) NOT PUSHED` });
|
||||
}
|
||||
}
|
||||
|
||||
const skipped = notYet.length ? ` (${notYet.length} not a plugin yet: ${notYet.join(', ')})` : '';
|
||||
|
||||
if (!problems.length) {
|
||||
console.log(`plugins:check — ${checkable.length} plugin repositories, all well-formed${skipped}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const p of problems) console.error(` ${p.plugin}: ${p.what}`);
|
||||
|
||||
if (FIX) {
|
||||
const fixable = problems.filter((p) => p.fix);
|
||||
for (const p of fixable) p.fix!();
|
||||
console.error(`\nplugins:check --fix — repaired ${fixable.length} of ${problems.length}; re-run to confirm.`);
|
||||
console.error('Divergence and uncommitted work are never repaired here: both need a decision about content.');
|
||||
} else {
|
||||
console.error(
|
||||
`\nplugins:check — ${problems.length} problem(s). \`bun run plugins:check --fix\` repairs the mechanical ones.`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -100,6 +100,11 @@ fail() {
|
||||
|
||||
ANSWERS_FILE="${ANSWERS_FILE:-}"
|
||||
|
||||
# DOCKER_NETWORK included so officer-setup's Proxy step lands on the SAME bridge Docker
|
||||
# was told to use here, rather than each script independently defaulting to "services" and
|
||||
# happening to agree only because nobody has customised either yet. lib/docker.sh sets
|
||||
# DOCKER_NETWORK at source time (SETUP_DOCKER_NETWORK env var, else "services"), so it is
|
||||
# always defined by the time this runs, regardless of whether the Docker step itself ran.
|
||||
save_answers() {
|
||||
[[ -n "$ANSWERS_FILE" ]] || return 0
|
||||
cat >"$ANSWERS_FILE" <<EOF
|
||||
@@ -107,6 +112,7 @@ save_answers() {
|
||||
MACHINE_ROLE=${MACHINE_ROLE}
|
||||
SETUP_USERNAME=${USERNAME}
|
||||
OFFICER_ROOT=${OFFICER_ROOT}
|
||||
SETUP_DOCKER_NETWORK=${DOCKER_NETWORK}
|
||||
EOF
|
||||
chmod 600 "$ANSWERS_FILE"
|
||||
}
|
||||
@@ -165,25 +171,39 @@ ONLY_STEP="${ONLY_STEP:-}"
|
||||
# Keyed on the step title, so the sections themselves stay Linux code with no
|
||||
# `if macos` branches threaded through them. The reason is printed, because a
|
||||
# silent skip and a missing step look identical.
|
||||
declare -A MACOS_SKIP=(
|
||||
["User account"]="accounts are System Settings' business on a Mac, not a script's"
|
||||
["Disk space"]="ballast and swap tuning are server concerns"
|
||||
["Locale"]="macOS manages locale itself"
|
||||
["Timezone"]="macOS manages the timezone itself"
|
||||
["Swap"]="macOS sizes its own swap dynamically"
|
||||
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
|
||||
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
|
||||
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
|
||||
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
|
||||
["Boot hang"]="a systemd boot ordering fix"
|
||||
["SSH access"]="hardening a door a dev machine should not be opening"
|
||||
["DNS"]="systemd-resolved"
|
||||
["Network address"]="netplan, and a laptop moves between networks by design"
|
||||
["fail2ban"]="brute-force protection for an exposed SSH port"
|
||||
["Unattended upgrades"]="apt; macOS updates through Software Update"
|
||||
["Firewall"]="ufw; macOS has its own application firewall"
|
||||
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
|
||||
)
|
||||
#
|
||||
# A `case` rather than an associative array — `declare -A` is bash 4+, and macOS's own
|
||||
# stock `/bin/bash` is 3.2 (Apple hasn't shipped a newer one since bash went GPLv3), with
|
||||
# nothing newer installed on a fresh machine. An array keyed on the step title would parse-
|
||||
# fail the instant this file is sourced, before a single step could run — which is exactly
|
||||
# what happened here until it was measured. Prints the reason on stdout, so the caller reads
|
||||
# it with `$(...)`; exit status says whether the title matched anything at all.
|
||||
macos_skip_reason() {
|
||||
case "$1" in
|
||||
"User account") echo "accounts are System Settings' business on a Mac, not a script's" ;;
|
||||
"Disk space") echo "ballast and swap tuning are server concerns" ;;
|
||||
"Locale") echo "macOS manages locale itself" ;;
|
||||
"Timezone") echo "macOS manages the timezone itself" ;;
|
||||
"Swap") echo "macOS sizes its own swap dynamically" ;;
|
||||
"Emergency disk ballast") echo "a server trick for a machine nobody is sitting at" ;;
|
||||
"earlyoom") echo "Linux OOM killer tuning; macOS has its own memory pressure handling" ;;
|
||||
"inotify watch limit") echo "Linux inotify; macOS watches files through FSEvents" ;;
|
||||
"Sleep and suspend") echo "a laptop SHOULD sleep — this stops a server from doing it" ;;
|
||||
"Boot hang") echo "a systemd boot ordering fix" ;;
|
||||
"SSH access") echo "hardening a door a dev machine should not be opening" ;;
|
||||
"DNS") echo "systemd-resolved" ;;
|
||||
"Network address") echo "netplan, and a laptop moves between networks by design" ;;
|
||||
"fail2ban") echo "brute-force protection for an exposed SSH port" ;;
|
||||
"Unattended upgrades") echo "apt; macOS updates through Software Update" ;;
|
||||
"Firewall") echo "ufw; macOS has its own application firewall" ;;
|
||||
"Shell") echo "zsh is already the default, and tmux is a choice you make yourself" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# `${var,,}` (case-insensitive compare below) is bash 4+ too — this is the portable
|
||||
# pre-4 substitute, not a style choice.
|
||||
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
@@ -191,16 +211,19 @@ step() {
|
||||
# which one it is. Twenty-six sections, one place.
|
||||
declare -F report_section >/dev/null && report_section "$1"
|
||||
|
||||
if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
local _skip_reason
|
||||
if _skip_reason="$(macos_skip_reason "$1")"; then
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${_skip_reason}"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
@@ -508,6 +531,24 @@ ask_officer_root() {
|
||||
# both of which abort the step.
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# Run a command as the created account, e.g. `run_as_owner git clone ...`.
|
||||
#
|
||||
# On Linux this whole script runs as root — refused otherwise, see the pre-flight check
|
||||
# above — so `sudo -u "$USERNAME"` from root needs no password; sudo lets root become
|
||||
# anyone freely. On macOS the script deliberately never escalates (Homebrew refuses to run
|
||||
# as root), so the process already IS "$USERNAME", and re-invoking sudo for the SAME
|
||||
# account asks for a real password — potentially many times in one run, dev.sh calls this
|
||||
# shape roughly ten times — or fails outright on an account with no sudo rights at all.
|
||||
# `dev.sh`'s git/bun/deno/claude/opencode/editor helpers all route through this rather than
|
||||
# hand-rolling `sudo -H -u "$USERNAME" ...` each time, so macOS behaviour lives in one place.
|
||||
run_as_owner() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
"$@"
|
||||
else
|
||||
sudo -H -u "$USERNAME" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run a block as the created user (login shell, inherits HOME)
|
||||
as_user() {
|
||||
sudo -u "$USERNAME" -i bash -c "$1"
|
||||
|
||||
@@ -47,8 +47,8 @@ MACHINE_SETUP_DEV_LOADED=1
|
||||
# has never been configured — the fresh machine this script exists for — reading
|
||||
# the current value aborted the run before the section had printed anything.
|
||||
# Missing a value is an answer here, not a failure.
|
||||
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
|
||||
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
|
||||
git_get() { (cd / && run_as_owner git config --global --get "$1" 2>/dev/null) || true; }
|
||||
git_set() { (cd / && run_as_owner git config --global "$1" "$2"); }
|
||||
|
||||
# Is there anything configured at all?
|
||||
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
|
||||
@@ -102,7 +102,7 @@ install_oh_my_zsh() {
|
||||
# failed died here, silently, because the output is redirected: the run just
|
||||
# stopped after apt finished installing zsh, with nothing said. Observed on a
|
||||
# fresh Hetzner VPS, 2026-08-14.
|
||||
sudo -H -u "$USERNAME" sh -c \
|
||||
run_as_owner sh -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
|
||||
true
|
||||
# Belt and braces: `|| true` above already makes the last command succeed, and
|
||||
@@ -155,7 +155,19 @@ nvim_latest_version() {
|
||||
#
|
||||
# The old install is removed only after the download has succeeded, so a failed
|
||||
# fetch leaves the working copy alone.
|
||||
#
|
||||
# This had NO macOS branch at all until it was caught here: unconditionally fetched
|
||||
# nvim-linux-<arch>.tar.gz regardless of $OS, which 404s on a Mac. Not a crash — the
|
||||
# caller's `if nvim_install; then … else warn …` catches the failure and reports it as
|
||||
# ERRORS+=("Neovim: install failed") rather than aborting the run — but Neovim never
|
||||
# actually landed. brew has a real, current formula, so that's the macOS path instead of
|
||||
# reaching for a macOS release asset the way lazygit's fix did.
|
||||
nvim_install() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install neovim
|
||||
return
|
||||
fi
|
||||
|
||||
local asset tarball dest
|
||||
asset="$(nvim_asset)"
|
||||
tarball="/tmp/nvim-linux-${asset}.tar.gz"
|
||||
@@ -187,11 +199,11 @@ nvim_clone_config() {
|
||||
local repo="$1" dest="${USER_HOME}/.config/nvim"
|
||||
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
|
||||
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
|
||||
(cd / && run_as_owner git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
|
||||
|
||||
# The starter is a template, not something to track. Left in place for a
|
||||
# config of the user's own, which they will want to keep pulling.
|
||||
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
|
||||
[[ "$repo" == *LazyVim/starter* ]] && run_as_owner rm -rf "${dest}/.git"
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -230,6 +242,18 @@ node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
|
||||
|
||||
install_node() {
|
||||
local major="$1"
|
||||
|
||||
# NodeSource's setup script adds an apt repository — it is Debian/Ubuntu-only by name
|
||||
# and by mechanism (checks for dpkg internally), so there is nothing to point it at on
|
||||
# macOS. Homebrew's `node`/`nodejs` formula (both names resolve, confirmed) tracks only
|
||||
# the current release rather than an arbitrary major the way NodeSource's per-major
|
||||
# scripts do — same tradeoff this codebase already accepts for Docker and Xcode CLT on
|
||||
# macOS: brew's own version, not a pinned one.
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
pkg_install_now nodejs
|
||||
return
|
||||
fi
|
||||
|
||||
# NodeSource publishes one setup script per major. Checked before it is piped
|
||||
# into a shell, because a 404 page piped to bash is a confusing way to fail.
|
||||
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
|
||||
@@ -358,7 +382,7 @@ ensure_bun_symlink() {
|
||||
install_bun() {
|
||||
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
|
||||
(cd / && run_as_owner bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||
}
|
||||
|
||||
@@ -367,7 +391,7 @@ install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
|
||||
|
||||
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
|
||||
install_deno() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
|
||||
(cd / && run_as_owner bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
|
||||
}
|
||||
|
||||
@@ -434,16 +458,16 @@ agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" ==
|
||||
agent_version() {
|
||||
local bin
|
||||
bin="$(agent_path "$1")"
|
||||
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
|
||||
[[ -n "$bin" ]] && (cd / && run_as_owner "$bin" --version 2>/dev/null | head -1)
|
||||
}
|
||||
|
||||
install_claude_code() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
[[ -x "$(agent_bin claude)" ]]
|
||||
}
|
||||
|
||||
install_opencode() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
[[ -x "$(agent_bin opencode)" ]]
|
||||
}
|
||||
|
||||
@@ -471,7 +495,7 @@ editor_candidates() {
|
||||
|
||||
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
|
||||
# assignment from a function that exits non-zero aborts the run under `set -e`.
|
||||
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
|
||||
current_editor() { (cd / && run_as_owner bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
|
||||
|
||||
set_system_editor() {
|
||||
local editor="$1" path
|
||||
|
||||
@@ -82,6 +82,13 @@ LAST_SKIPPED=()
|
||||
# compiles a native module needs it, but it is the one to move out first if that
|
||||
# ever bites.
|
||||
#
|
||||
# ffmpeg is not a convenience here, it is a hard dependency of the file browser.
|
||||
# Subtitles, audio extraction, audio track listing and video thumbnails all shell
|
||||
# out to it, so on a machine without it those features fail at the moment somebody
|
||||
# opens a folder — which reads as Officer being broken rather than as a package
|
||||
# being absent. It was missing from this list until 2026-08-29 and the features
|
||||
# worked anyway, because every machine that had been set up by hand already had it.
|
||||
#
|
||||
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
|
||||
# moment a file lands instead of polling for it. Without it a watcher can only
|
||||
# ask "is there anything new?" on a timer, which is both slower and more work —
|
||||
@@ -108,24 +115,25 @@ pkgs_core() {
|
||||
# broken rather than a package being absent.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
|
||||
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils ffmpeg fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
inotify-tools poppler radicale fail2ban
|
||||
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
|
||||
inotify-tools poppler ffmpeg fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils radicale fail2ban
|
||||
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils ffmpeg fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler radicale
|
||||
#
|
||||
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler ffmpeg
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -253,13 +261,20 @@ pkg_install() {
|
||||
# passed by value cannot be told apart from an empty one once it has been through
|
||||
# word splitting.
|
||||
#
|
||||
# Copies by name via eval rather than `local -n` — namerefs are bash 4.3+, and macOS's
|
||||
# stock bash is 3.2. This is the idiom namerefs replaced: build the local array by
|
||||
# expanding the CALLER's array name inside a double-quoted eval, which bash 3.2 has always
|
||||
# supported. `$2`/`$3` are trusted call-site literals (`present`, `missing`), never
|
||||
# user input, so the eval is not taking anything untrusted.
|
||||
#
|
||||
# Returns non-zero when there is nothing to do, or when the answer was no — in
|
||||
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
|
||||
# refusal so the summary does not claim work that never happened.
|
||||
announce_plan() {
|
||||
local label="$1"
|
||||
local -n _present="$2"
|
||||
local -n _missing="$3"
|
||||
local -a _present _missing
|
||||
eval "_present=(\"\${$2[@]}\")"
|
||||
eval "_missing=(\"\${$3[@]}\")"
|
||||
|
||||
echo ""
|
||||
info "${label} — installs what is missing, keeps what you already have"
|
||||
|
||||
@@ -59,12 +59,35 @@ authorized_key_count() {
|
||||
|
||||
has_authorized_key() { (($(authorized_key_count) > 0)); }
|
||||
|
||||
# Everything about ~/.ssh that has to be true for sshd to use it at all. sshd
|
||||
# ignores an authorized_keys file that is group- or world-writable, and does so
|
||||
# silently from the client's point of view — the login just fails.
|
||||
# Everything that has to be true for sshd to use ~/.ssh at all. sshd's StrictModes
|
||||
# (on by default, and nothing here turns it off) checks the HOME DIRECTORY too, not
|
||||
# just .ssh — this used to check only .ssh, which is the shape of bug that is hardest
|
||||
# to notice: an account whose home came from somewhere other than THIS script's own
|
||||
# adduser call (cloud-init, a prior useradd, LDAP, or simply an account that already
|
||||
# existed) can have a group- or world-writable home directory, and sshd refuses
|
||||
# authorized_keys under it silently. The .ssh directory and the key both look
|
||||
# completely correct — because they are — and nothing here used to say why the
|
||||
# login still failed.
|
||||
fix_ssh_permissions() {
|
||||
local dir
|
||||
local dir home mode
|
||||
dir="$(user_ssh_dir)"
|
||||
home="$USER_HOME"
|
||||
|
||||
if [[ -d "$home" ]]; then
|
||||
# GNU stat's -c, tried first since this step only ever runs on Linux (MACOS_SKIP)
|
||||
# — with a BSD fallback anyway rather than a call that silently returns nothing on
|
||||
# whatever this is next tested against, the same trap officer-setup's layout.sh
|
||||
# file_owner() hit for the same reason.
|
||||
mode="$(stat -c %a "$home" 2>/dev/null || stat -f %Lp "$home" 2>/dev/null)"
|
||||
# 0022 is the group-write and other-write bits together. Cleared rather than
|
||||
# forced to a fixed mode — 0755 vs 0750 is a real choice some setups make on
|
||||
# purpose, and this only removes the bits sshd actually refuses to see.
|
||||
if [[ -n "$mode" ]] && ((8#$mode & 0022)); then
|
||||
chmod go-w "$home"
|
||||
warn "${home} was group/world-writable (${mode}) — sshd's StrictModes refuses authorized_keys under a writable home regardless of .ssh's own permissions; cleared"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$dir"
|
||||
chmod 700 "$dir"
|
||||
[[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys"
|
||||
|
||||
@@ -245,7 +245,18 @@ tailscale_needs_logout() {
|
||||
# Routing has to be on before this machine can forward anyone else's packets,
|
||||
# whether as an exit node or as a subnet router. Written as a drop-in so it is
|
||||
# visible as this script's doing.
|
||||
# Both this and install_exit_node_tuning below are Linux-only — sysctl.d, systemd's
|
||||
# networkd-dispatcher, ethtool NIC offload flags, none of which macOS has an equivalent
|
||||
# concept for. Tailscale itself DOES support acting as an exit node from macOS (that's the
|
||||
# `--advertise-exit-node` flag on `tailscale up`, untouched by this guard) — what's
|
||||
# unavailable is specifically this forwarding-throughput tuning, which is also a much
|
||||
# narrower loss on a Mac: the file's own MACOS_SKIP already treats a laptop advertising as
|
||||
# an exit node as an edge case, not the common path a homelab box is.
|
||||
enable_ip_forwarding() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
warn "IP-forwarding tuning is Linux-only (sysctl.d) — skipping; the exit node still works, just untuned"
|
||||
return 0
|
||||
fi
|
||||
cat >"$TS_EXIT_SYSCTL" <<'EOF'
|
||||
# Written by machine-setup: required to forward traffic for other tailnet nodes,
|
||||
# as an exit node or as a subnet router.
|
||||
@@ -265,6 +276,10 @@ EOF
|
||||
# once, because the settings are per-interface and do not survive the link going
|
||||
# down and back up.
|
||||
install_exit_node_tuning() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
warn "NIC offload tuning is Linux-only (networkd-dispatcher, ethtool) — skipping"
|
||||
return 0
|
||||
fi
|
||||
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
|
||||
|
||||
mkdir -p "$(dirname "$TS_DISPATCHER")"
|
||||
|
||||
@@ -48,7 +48,15 @@ tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
|
||||
# The installers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# The installer script this used is literally named install_update_linux.sh — there is no
|
||||
# upstream equivalent for macOS to swap in, so this is brew there instead of a raw fetch.
|
||||
# All three tools are real Homebrew formulas (confirmed against the live catalogue), which
|
||||
# also means macOS gets `brew upgrade` maintenance the raw-fetch path never had.
|
||||
tool_install_lazydocker() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install lazydocker
|
||||
return
|
||||
fi
|
||||
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
|
||||
DIR=/usr/local/bin bash
|
||||
}
|
||||
@@ -57,7 +65,16 @@ tool_install_lazydocker() {
|
||||
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
|
||||
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
|
||||
# two spellings ARCH uses, hence the mapping.
|
||||
#
|
||||
# The URL was also hardcoded to `_Linux_` — brew handles macOS instead of this needing a
|
||||
# second literal-string branch, see tool_install_lazydocker above for why brew and not a
|
||||
# Darwin asset name here.
|
||||
tool_install_lazygit() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install lazygit
|
||||
return
|
||||
fi
|
||||
|
||||
local version asset url
|
||||
case "$ARCH" in
|
||||
amd64) asset="x86_64" ;;
|
||||
@@ -82,7 +99,14 @@ tool_install_lazygit() {
|
||||
# Quiet on purpose. The installer ends by printing how to add starship to bash,
|
||||
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
|
||||
# already writes the zsh config itself. Errors still come through.
|
||||
#
|
||||
# starship's own installer IS cross-platform (unlike the two above), but brew on macOS
|
||||
# keeps it consistent with the other two tools and gets it `brew upgrade` maintenance.
|
||||
tool_install_starship() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install starship
|
||||
return
|
||||
fi
|
||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
|
||||
# all and errors immediately ("invalid option -- P"), confirmed live.
|
||||
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
@@ -114,15 +116,17 @@ info "Machine: ${OS_NAME} (${ARCH})"
|
||||
info "Packages: ${PM:-none detected}"
|
||||
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
|
||||
|
||||
# Everything below this line is written against apt and systemd. Detection above
|
||||
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
|
||||
# nothing implements them yet — and running the apt path on Arch would half-build
|
||||
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
|
||||
# list one entry at a time as each package manager grows a real path.
|
||||
# Everything below this line is written against apt and systemd, with real macOS/brew
|
||||
# branches threaded through where a Mac needs different behaviour (MACOS_SKIP for whole
|
||||
# steps that are server-only, `$OS == macos` for the rest). Detection above also recognises
|
||||
# pacman and dnf so THEIR branches have somewhere to hang, but nothing implements those yet
|
||||
# — running the apt path on Arch would half-build a machine and stop somewhere unhelpful.
|
||||
# Refuse clearly instead, and relax this list one entry at a time as each package manager
|
||||
# grows a real path.
|
||||
case "$PM" in
|
||||
apt) ;;
|
||||
apt | brew) ;;
|
||||
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
|
||||
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
|
||||
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt and brew only, so far." ;;
|
||||
esac
|
||||
|
||||
ask_machine_role
|
||||
@@ -404,7 +408,9 @@ fi
|
||||
|
||||
step "System update"
|
||||
if ! skip; then
|
||||
mapfile -t UPGRADABLE < <(pkg_upgradable)
|
||||
# `mapfile` is bash 4+; macOS's stock bash is 3.2. This is the portable substitute.
|
||||
UPGRADABLE=()
|
||||
while IFS= read -r _line; do UPGRADABLE+=("$_line"); done < <(pkg_upgradable)
|
||||
|
||||
echo ""
|
||||
info "System update — upgrades installed packages to their latest versions"
|
||||
@@ -2479,7 +2485,9 @@ EOF
|
||||
echo " which is why git's own core.editor is deliberately not set: git"
|
||||
echo " falls back to \$EDITOR, so setting it here covers everything."
|
||||
echo ""
|
||||
mapfile -t EDITORS < <(editor_candidates)
|
||||
# mapfile is bash 4+; macOS's stock bash is 3.2.
|
||||
EDITORS=()
|
||||
while IFS= read -r _line; do EDITORS+=("$_line"); done < <(editor_candidates)
|
||||
if ((${#EDITORS[@]} == 0)); then
|
||||
warn "no editor found to offer — skipping"
|
||||
else
|
||||
|
||||
@@ -50,7 +50,9 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
|
||||
# all and errors immediately ("invalid option -- P"), confirmed live.
|
||||
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
@@ -143,6 +145,13 @@ else
|
||||
echo " would have answered are asked below instead."
|
||||
fi
|
||||
|
||||
# proxy.sh set PROXY_NET from $SETUP_DOCKER_NETWORK when it was SOURCED, before the answers
|
||||
# file above could have populated that variable — so it needs re-deriving now the same way
|
||||
# ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL are recovered below. Without this a customised bridge
|
||||
# name from machine-setup silently would not carry over, and Proxy would default back to
|
||||
# "services" regardless of what Docker was actually told to use.
|
||||
PROXY_NET="${SETUP_DOCKER_NETWORK:-$PROXY_NET}"
|
||||
|
||||
# ── the account ──
|
||||
#
|
||||
# A remembered answer can go stale: the account it names may have been renamed or
|
||||
@@ -211,8 +220,11 @@ fi
|
||||
echo ""
|
||||
info "What Officer needs from this machine"
|
||||
|
||||
mapfile -t MISSING < <(missing_tools)
|
||||
mapfile -t MISSING_OPT < <(missing_optional_tools)
|
||||
# mapfile is bash 4+; macOS's stock bash is 3.2, so this reads line-by-line instead.
|
||||
MISSING=()
|
||||
while IFS= read -r _line; do MISSING+=("$_line"); done < <(missing_tools)
|
||||
MISSING_OPT=()
|
||||
while IFS= read -r _line; do MISSING_OPT+=("$_line"); done < <(missing_optional_tools)
|
||||
|
||||
for t in "${REQUIRED_TOOLS[@]}"; do
|
||||
if command -v "$t" &>/dev/null; then
|
||||
@@ -274,7 +286,9 @@ if ! skip; then
|
||||
echo " To put data/ on a bigger volume later, symlink it — that is a"
|
||||
echo " decision about storage rather than about how Officer is laid out."
|
||||
|
||||
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
|
||||
# mapfile is bash 4+; macOS's stock bash is 3.2.
|
||||
WRONG_OWNER=()
|
||||
while IFS= read -r _line; do WRONG_OWNER+=("$_line"); done < <(layout_wrong_owner)
|
||||
if ((${#WRONG_OWNER[@]} > 0)); then
|
||||
echo ""
|
||||
warn "these exist but do not belong to ${USERNAME}:"
|
||||
@@ -391,6 +405,7 @@ if ! skip; then
|
||||
info "Dependencies — bun install, as ${USERNAME}"
|
||||
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
|
||||
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
|
||||
echo " sharp: $(sharp_loads && echo "loads (libvips $(sharp_version))" || echo 'does not load')"
|
||||
echo ""
|
||||
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
|
||||
echo " so a package.json that disagrees with it fails rather than quietly"
|
||||
@@ -398,12 +413,29 @@ if ! skip; then
|
||||
echo ""
|
||||
echo " node-pty has no Linux prebuild, so this compiles it from source"
|
||||
echo " every time — which is what build-essential and python3 are for."
|
||||
echo ""
|
||||
echo " sharp is the opposite problem: it does not compile, it downloads a"
|
||||
echo " libvips binary for this exact platform and architecture. So the"
|
||||
echo " install can succeed and the module still not load, and the way that"
|
||||
echo " shows up is a file browser drawing generic icons over a folder of"
|
||||
echo " photographs. Loading it is the only check worth running."
|
||||
|
||||
if deps_installed && node_pty_built; then
|
||||
ok "already installed, and node-pty is built"
|
||||
if deps_installed && node_pty_built && sharp_loads; then
|
||||
ok "already installed; node-pty built and sharp loads"
|
||||
SUMMARY+=("Dependencies: already installed")
|
||||
elif confirm "Install them?"; then
|
||||
if install_deps; then
|
||||
# Reported separately from node-pty rather than as one "native modules"
|
||||
# verdict: they fail for different reasons and want different fixes, and a
|
||||
# combined line sends somebody to rebuild the wrong one.
|
||||
if ! sharp_loads; then
|
||||
warn "installed, but sharp does not load"
|
||||
echo " Image thumbnails in the file browser will silently fall back to"
|
||||
echo " file icons. Usually the platform-specific binary did not resolve:"
|
||||
echo " cd $(platform_dir) && bun install --force"
|
||||
ERRORS+=("Dependencies: sharp does not load")
|
||||
fi
|
||||
|
||||
if node_pty_built; then
|
||||
ok "installed, node-pty built"
|
||||
SUMMARY+=("Dependencies: installed")
|
||||
|
||||
@@ -47,10 +47,16 @@ fail() {
|
||||
|
||||
ONLY_STEP="${ONLY_STEP:-}"
|
||||
|
||||
# `${var,,}` (case-insensitive --only compare below) is bash 4+. macOS's stock /bin/bash is
|
||||
# 3.2 — confirmed live: "bad substitution" — and under this script's `set -e` that means
|
||||
# `--only <step>` killed the ENTIRE run on macOS, not just the comparison. This is the
|
||||
# portable pre-4 substitute.
|
||||
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
@@ -134,7 +140,39 @@ ask_required() {
|
||||
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# The bound address(es) of whatever is LISTENing on a TCP port — one per line, formatted
|
||||
# like `127.0.0.1:5432` or `*:5432`. Used both to ask "is anything on this port at all" and
|
||||
# "is it bound wide enough for a container to reach" (Postgres and the Proxy section ask
|
||||
# opposite versions of that question).
|
||||
#
|
||||
# `ss` (iproute2) is Linux-only — no equivalent ships with it on macOS. `lsof` is the
|
||||
# portable answer: present on every macOS install by default, and common enough on Linux
|
||||
# (already a REQUIRED_TOOL nowhere here, so this still prefers `ss` there since that is the
|
||||
# surface this was written and tested against) that branching by OS rather than picking one
|
||||
# tool for both keeps each path exactly as capable as it always was.
|
||||
listening_addrs() {
|
||||
local port="$1"
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk -v p=":${port}\$" '$9 ~ p {print $9}'
|
||||
else
|
||||
ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Run something as the account that owns the install. Officer's files, its
|
||||
# node_modules and its pm2 process list all belong to that account, not to root —
|
||||
# a repository cloned as root is one the owner cannot pull.
|
||||
as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); }
|
||||
#
|
||||
# On Linux this script runs as root (that's the whole point of sudo -H -u here: dropping
|
||||
# from root down to the owning account). On macOS it never escalates — officer-setup.sh
|
||||
# refuses to even start under sudo there, "the account running this IS the owner" — so the
|
||||
# process already IS $USERNAME, and re-invoking sudo for the SAME account asks for a real
|
||||
# password on every single call (Dependencies, Schema, Build, Secrets, Services, Repository
|
||||
# all go through this) or fails outright without sudo rights at all.
|
||||
as_owner() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
(cd "${2:-/}" && bash -c "$1")
|
||||
else
|
||||
(cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1")
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -24,15 +24,20 @@ OFFICER_SETUP_BUILD_LOADED=1
|
||||
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
|
||||
# want at 3am.
|
||||
push_schema() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
|
||||
as_owner "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
|
||||
}
|
||||
|
||||
# What tables the schema will create, read from the aggregator rather than
|
||||
# guessed. This is what makes the section able to say what it is about to do.
|
||||
#
|
||||
# Portable ERE + sed rather than GNU grep's -P/lookbehind: BSD grep on macOS has no -P at
|
||||
# all (confirmed live — errors immediately), and its 2>/dev/null here would have hidden that
|
||||
# and reported "0 tables" on every macOS run instead, cosmetic but silently wrong.
|
||||
schema_table_count() {
|
||||
local dir
|
||||
dir="$(platform_dir)/src/databases/officer_db/src"
|
||||
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
|
||||
grep -E "^export \* from '\./" "$dir/schema.ts" 2>/dev/null |
|
||||
sed -E "s/^export \* from '\.\/([A-Za-z0-9_-]+)\/schema'.*/\1/" | while read -r f; do
|
||||
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
|
||||
done | awk '{s+=$1} END {print s+0}'
|
||||
}
|
||||
@@ -43,7 +48,7 @@ schema_table_count() {
|
||||
# Not optional and not cosmetic: without it the server has no page to serve. It
|
||||
# is gitignored, so a fresh clone never has one.
|
||||
gen_index() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
|
||||
as_owner "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
|
||||
}
|
||||
|
||||
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
|
||||
|
||||
@@ -31,14 +31,32 @@ env_exists() { [[ -f "$(env_file)" ]]; }
|
||||
|
||||
# One value out of an existing .env, without sourcing it — the file holds
|
||||
# secrets and arbitrary shell would run as root.
|
||||
#
|
||||
# Checks the OFFICERDEV_-prefixed key first, matching the priority `OFFICERDEV_X ?? X`
|
||||
# gives it at runtime — write_env ALWAYS writes prefixed keys (see below), so plain `env_get
|
||||
# PORT` used to compare against a key that could never appear in a file this script wrote,
|
||||
# and always came back empty. That silently defeated the resume block below it was written
|
||||
# to fix, and the Environment step's own re-run showed the wrong default. Verified against a
|
||||
# real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000], before this.
|
||||
# Falls back to the bare key so an old, hand-written unprefixed file still works, same as the
|
||||
# runtime does.
|
||||
env_get() {
|
||||
[[ -r "$(env_file)" ]] || return 0
|
||||
awk -F= -v k="$1" '
|
||||
$1 == k {
|
||||
awk -F= -v k="$1" -v pk="OFFICERDEV_$1" '
|
||||
$1 == pk {
|
||||
v = substr($0, index($0, "=") + 1)
|
||||
gsub(/^"|"$/, "", v)
|
||||
print v
|
||||
exit
|
||||
}
|
||||
$1 == k && !found {
|
||||
v = substr($0, index($0, "=") + 1)
|
||||
gsub(/^"|"$/, "", v)
|
||||
bare = v
|
||||
found = 1
|
||||
}
|
||||
END {
|
||||
if (bare != "") print bare
|
||||
}' "$(env_file)"
|
||||
}
|
||||
|
||||
@@ -111,7 +129,12 @@ ENVF
|
||||
# is any better a guess.
|
||||
tailnet_hostname() {
|
||||
local dns ip
|
||||
dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)"
|
||||
# jq over grep -oP: this output is real JSON, and GNU grep's -P/\K doesn't exist on BSD
|
||||
# grep at all (confirmed live — errors visibly), which used to leak straight to the
|
||||
# terminal since only the `tailscale` call's stderr was suppressed, not the grep's. jq is
|
||||
# already an unconditional dependency elsewhere in this script (proxy.sh), so this is
|
||||
# strictly a correctness improvement even setting the portability problem aside.
|
||||
dns="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty')"
|
||||
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
|
||||
dns="${dns%%.*}" # and we want the short name
|
||||
if [[ -n "$dns" ]]; then
|
||||
|
||||
@@ -39,6 +39,13 @@ layout_dirs() {
|
||||
echo "$(layout_items_dir)"
|
||||
}
|
||||
|
||||
# A file's owning username, portably: GNU stat (Linux) takes `-c %U`, BSD stat (macOS) has
|
||||
# no `-c` at all and wants `-f %Su` instead — confirmed live, GNU form exits
|
||||
# "illegal option -- c" under macOS's stat. Try GNU first since that is the common case,
|
||||
# fall back to BSD rather than branching on an OS variable this file does not otherwise
|
||||
# track.
|
||||
file_owner() { stat -c %U "$1" 2>/dev/null || stat -f %Su "$1"; }
|
||||
|
||||
# Created owned by the account, because everything that writes into them runs as
|
||||
# the account: the platform under pm2, the app store's compose files, the item
|
||||
# store the agent authors into.
|
||||
@@ -58,7 +65,7 @@ layout_wrong_owner() {
|
||||
local dir
|
||||
while read -r dir; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
[[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir"
|
||||
[[ "$(file_owner "$dir")" == "$USERNAME" ]] || echo "$dir"
|
||||
done < <(layout_dirs)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ pg_client_installed() {
|
||||
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||
# just a more annoying one to operate.
|
||||
install_pg_client() {
|
||||
local major codename
|
||||
local major
|
||||
major="$(pg_client_major)"
|
||||
[[ -n "$major" ]] || {
|
||||
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||
@@ -74,6 +74,49 @@ install_pg_client() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
install_pg_client_macos "$major"
|
||||
return $?
|
||||
fi
|
||||
|
||||
install_pg_client_linux "$major"
|
||||
}
|
||||
|
||||
# Homebrew's `postgresql@<major>` matches the apt path's own logic: the version has to agree
|
||||
# with the server's, not just be "a psql". It is keg-only — Homebrew never links more than one
|
||||
# Postgres onto PATH by default, because a dev machine plausibly has several majors installed
|
||||
# for different projects — so `brew install` alone does not make `psql` resolve. Symlinking
|
||||
# only the four client binaries this platform actually uses, rather than `brew link --force`ing
|
||||
# the whole keg, is the same caveat Homebrew's own postgresql formula prints; force-linking
|
||||
# would silently steal `psql` from whatever other major was linked before this ran.
|
||||
install_pg_client_macos() {
|
||||
local major="$1" prefix
|
||||
command -v brew &>/dev/null || {
|
||||
warn "Homebrew is not installed — cannot install postgresql@${major}. Run machine-setup.sh first."
|
||||
return 1
|
||||
}
|
||||
prefix="$(brew --prefix)"
|
||||
|
||||
brew install "postgresql@${major}" &>/dev/null || {
|
||||
warn "brew install postgresql@${major} failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
local bin
|
||||
for bin in psql pg_dump pg_dumpall pg_restore; do
|
||||
[[ -x "${prefix}/opt/postgresql@${major}/bin/${bin}" ]] || continue
|
||||
ln -sf "${prefix}/opt/postgresql@${major}/bin/${bin}" "${prefix}/bin/${bin}"
|
||||
done
|
||||
|
||||
pg_client_installed || {
|
||||
warn "psql is not version ${major} after installing — check: brew list postgresql@${major}"
|
||||
return 1
|
||||
}
|
||||
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed via Homebrew"
|
||||
}
|
||||
|
||||
install_pg_client_linux() {
|
||||
local major="$1" codename
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||
@@ -121,7 +164,12 @@ pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{
|
||||
|
||||
# Is something already answering on the port? A Postgres the user runs their own
|
||||
# way is a perfectly good answer, and finding out by failing to bind is not.
|
||||
pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; }
|
||||
#
|
||||
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) is the portable version, branching to
|
||||
# `lsof` on macOS. Confirmed empirically to fail silently the old way: `ss` doesn't exist on
|
||||
# macOS, its "command not found" was swallowed by `2>/dev/null`, and an empty grep result
|
||||
# reported the port as free even when something WAS listening.
|
||||
pg_port_in_use() { [[ -n "$(listening_addrs "$PG_PORT")" ]]; }
|
||||
|
||||
# Bound to loopback, deliberately, and the reason is worth keeping next to the
|
||||
# line it explains.
|
||||
@@ -189,9 +237,19 @@ ENVF
|
||||
return 0
|
||||
}
|
||||
|
||||
# Failure has to mean failure. The bare awk one-liner this replaced printed nothing and
|
||||
# still exited 0 when the file existed but had no POSTGRES_PASSWORD= line — so the caller's
|
||||
# `pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"` treated that as
|
||||
# success with an EMPTY password, and built POSTGRES_URL with a blank credential instead of
|
||||
# falling through to provision fresh. Only reachable via a hand-edited/corrupted .env, since
|
||||
# write_pg_compose always writes the line — but "reuse a blank password" is the wrong
|
||||
# direction to fail in even for an edge case that rare.
|
||||
pg_password_from_env_file() {
|
||||
[[ -r "$(pg_env_file)" ]] || return 1
|
||||
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
|
||||
local pw
|
||||
pw="$(awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)")"
|
||||
[[ -n "$pw" ]] || return 1
|
||||
printf '%s' "$pw"
|
||||
}
|
||||
|
||||
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
|
||||
|
||||
@@ -32,6 +32,13 @@ load_machine_answers() {
|
||||
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
|
||||
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
|
||||
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
|
||||
# The bridge machine-setup told Docker to use — proxy.sh's PROXY_NET otherwise
|
||||
# defaults to "services" independently and can only agree with this by coincidence.
|
||||
# PROXY_NET is a top-level assignment evaluated when proxy.sh is SOURCED, which
|
||||
# happens before this function is ever called — officer-setup.sh re-derives it from
|
||||
# SETUP_DOCKER_NETWORK right after calling this, same pattern as ENV_PORT/
|
||||
# ENV_PUBLIC_URL/POSTGRES_URL below it.
|
||||
SETUP_DOCKER_NETWORK) if [[ -z "${SETUP_DOCKER_NETWORK:-}" ]]; then SETUP_DOCKER_NETWORK="$value"; fi ;;
|
||||
esac
|
||||
done <"$MACHINE_ANSWERS"
|
||||
return 0
|
||||
@@ -69,7 +76,19 @@ tool_why() {
|
||||
# The account has to exist before anything is written to its home.
|
||||
owner_exists() { id "$USERNAME" &>/dev/null; }
|
||||
|
||||
# `getent` is Linux/glibc — not present on stock macOS at all (confirmed: "command not
|
||||
# found" on a real machine; it only appeared to work in earlier testing because this dev
|
||||
# session's shell profile shims it, which a fresh install does not have). Reproduced live:
|
||||
# this returned the WRONG, hardcoded `/home/<user>` fallback on a real macOS run just now,
|
||||
# and `install -d` then failed on it outright ("Operation not supported") since /home isn't
|
||||
# a real, writable path on macOS.
|
||||
#
|
||||
# `eval echo ~"$USERNAME"` is the portable replacement — tilde expansion resolves through
|
||||
# whatever the OS's own user database is (NSS on Linux, Directory Services on macOS)
|
||||
# without naming either. A nonexistent user leaves the tilde word unexpanded (`~name`
|
||||
# rather than a path), which is how the fallback below still triggers for that case.
|
||||
resolve_user_home() {
|
||||
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||
USER_HOME="$(eval echo ~"$USERNAME" 2>/dev/null)"
|
||||
[[ "$USER_HOME" == "~"* ]] && USER_HOME=""
|
||||
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ proxy_ask() {
|
||||
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
|
||||
proxy_require_listening() {
|
||||
local port="$1" listen
|
||||
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
|
||||
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) branches to `lsof` on macOS. Same
|
||||
# silent-empty-result failure mode as postgres.sh's pg_port_in_use if this were left as-is.
|
||||
listen="$(listening_addrs "$port" | head -1)"
|
||||
[[ -n "$listen" ]] || {
|
||||
warn "nothing is listening on port ${port} — start Officer first"
|
||||
return 1
|
||||
@@ -170,7 +172,15 @@ proxy_wait_for_dns() {
|
||||
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
|
||||
|
||||
while ((elapsed < timeout)); do
|
||||
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
|
||||
# `getent` is glibc/Linux-only — absent on macOS (confirmed: "command not found" on a
|
||||
# real machine), silently swallowed by 2>/dev/null, which would have spun this through
|
||||
# its full 30-minute timeout regardless of whether the record actually resolved.
|
||||
# `dscacheutil` is macOS's own equivalent, consulting the same system resolver.
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
got="$(dscacheutil -q host -a name "$domain" 2>/dev/null | awk '/^ip_address:/ {print $2; exit}')"
|
||||
else
|
||||
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
|
||||
fi
|
||||
if [[ "$got" == "$want" ]]; then
|
||||
ok "${domain} resolves to ${want}"
|
||||
return 0
|
||||
@@ -194,8 +204,18 @@ proxy_ensure_network() {
|
||||
|
||||
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
|
||||
# address does not exist yet and the WHOLE container fails to start, not just that port.
|
||||
#
|
||||
# Docker Desktop on macOS has no systemd unit to order — it is a GUI app the OS launches, not
|
||||
# a service tailscaled could be declared a dependency of — and this account does not run as
|
||||
# root there either, so `mkdir -p /etc/systemd/...` would fail on permissions rather than do
|
||||
# something merely unnecessary. Same shape as machine-setup's MACOS_SKIP: skip with a reason
|
||||
# rather than let a Linux-only action fail confusingly.
|
||||
proxy_order_docker_after_tailscaled() {
|
||||
[[ "$CHALLENGE" == "dns" ]] || return 0
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
ok "Docker Desktop has no systemd unit to order — skipping"
|
||||
return 0
|
||||
fi
|
||||
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
|
||||
[[ -f "$f" ]] && return 0
|
||||
mkdir -p "$(dirname "$f")"
|
||||
@@ -378,8 +398,20 @@ proxy_get_token() {
|
||||
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
|
||||
# upstream would need none of this, which is why container upstreams are preferable when
|
||||
# there is a choice.
|
||||
# `ufw` is Debian/Ubuntu — no macOS equivalent through this interface (pf/pfctl is a
|
||||
# different rule syntax entirely, and the Application Firewall machine-setup's own
|
||||
# MACOS_SKIP already documents for "Firewall" is per-APP, not per-subnet/per-port). Skipped
|
||||
# rather than guessed at: Docker Desktop for Mac also runs containers inside its own VM, so
|
||||
# bridge-to-host traffic does not cross the real host firewall the same way it does when
|
||||
# Docker runs directly on a Linux kernel — an unverified pf rule here could be solving a
|
||||
# problem macOS's Docker networking model does not actually have.
|
||||
proxy_allow_bridge_to_host() {
|
||||
local port="$1" subnet
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
ok "no ufw on macOS — Docker Desktop's own networking model handles bridge-to-host, skipping"
|
||||
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
|
||||
return 0
|
||||
fi
|
||||
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
|
||||
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
|
||||
ok "ufw already allows the bridge to reach port ${port}"
|
||||
|
||||
@@ -95,4 +95,27 @@ deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
|
||||
# would not start.
|
||||
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
|
||||
|
||||
# sharp is libvips, and it arrives as a PREBUILT BINARY chosen by platform and
|
||||
# architecture — @img/sharp-linux-x64 and its libvips, or the arm64 pair, or the
|
||||
# musl ones. bun picks them from the lockfile's optional dependencies.
|
||||
#
|
||||
# Which means the install can succeed and the module can still be unusable: a
|
||||
# lockfile written on x64 carries the x64 binaries, and on a machine where the
|
||||
# right optional package did not resolve, `import('sharp')` throws at the first
|
||||
# call rather than at install time. Same failure shape as node-pty above, and the
|
||||
# same reason to check it here — the symptom otherwise is a file browser drawing
|
||||
# generic icons over a folder of photographs, which nobody reads as "a native
|
||||
# module is missing".
|
||||
#
|
||||
# Loading it is the only test worth running. A directory being present proves
|
||||
# nothing; sharp's own `versions.vips` proves libvips is there and bound.
|
||||
sharp_loads() {
|
||||
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => { if (!m.default.versions.vips) process.exit(1) }).catch(() => process.exit(1))\"" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# What libvips sharp found, for the report. Empty when it does not load at all.
|
||||
sharp_version() {
|
||||
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => console.log(m.default.versions.vips)).catch(() => {})\"" 2>/dev/null | tr -d '\r'
|
||||
}
|
||||
|
||||
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
|
||||
|
||||
@@ -35,7 +35,7 @@ bootstrap_secret_store() {
|
||||
|
||||
# From the repo, because the module derives the install root as the parent of
|
||||
# the working directory — the same rule as src/servers/data-path.ts.
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
|
||||
as_owner "cd '$(platform_dir)' && bun --eval \"
|
||||
const { getKey } = await import('officerdb/secret-store');
|
||||
getKey('jwt');
|
||||
\"" >/dev/null 2>&1 || return 1
|
||||
|
||||
@@ -44,13 +44,6 @@ CORE_PROCESSES=(
|
||||
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
||||
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
||||
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
||||
# Calendar and contacts, CORE since 2026-08-19 rather than an app-store entry.
|
||||
#
|
||||
# It was a Docker service because the app store made everything one, not because Radicale needs a
|
||||
# container — it is a ~2MB Python package, and this sidecar spawns it directly on loopback. A personal
|
||||
# server having a calendar is not an integration with something you already run; it is the baseline.
|
||||
# Radicale needs `python3` and the `radicale` package, both installed by machine-setup.
|
||||
"officer-caldav|bun|run src/servers/sidecar/caldav/index.ts"
|
||||
# Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with
|
||||
# memos and photos when it was slated to become a plugin — the only one of the three never extracted,
|
||||
# so it sat mounted nowhere with its schema commented out.
|
||||
@@ -62,6 +55,11 @@ CORE_PROCESSES=(
|
||||
"officer-notify|bun|run src/servers/sidecar/notify/index.ts"
|
||||
)
|
||||
|
||||
# Calendar and contacts are NOT here. They were core from 2026-08-19 until 2026-08-27, when caldav became
|
||||
# a plugin: it declares its own sidecar, its own `radicale` dependency and its own two screens, and the
|
||||
# generated ecosystem file gains an `officer-caldav` entry when it is installed. A fresh machine runs five
|
||||
# processes instead of seven, and gets a calendar by installing one plugin.
|
||||
|
||||
write_ecosystem() {
|
||||
local dest entry name script args
|
||||
dest="$(ecosystem_file)"
|
||||
@@ -101,24 +99,36 @@ FOOTER
|
||||
}
|
||||
|
||||
pm2_start() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
|
||||
as_owner "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
|
||||
}
|
||||
|
||||
pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; }
|
||||
pm2_save() { as_owner "pm2 save" 2>&1; }
|
||||
|
||||
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
|
||||
# doing it — so this runs what it prints, which is the whole point of already
|
||||
# being root here.
|
||||
#
|
||||
# `systemd` is hardcoded because that IS the only path today — pm2's own "platform" argument
|
||||
# has to be `launchd` on macOS instead, generating a LaunchAgent plist rather than a systemd
|
||||
# unit, and running it is a separate mechanism (`launchctl load`, not "eval a printed sudo
|
||||
# command"). That is real, separate work rather than a one-line swap, so this skips with a
|
||||
# stated reason on macOS instead of pretending the systemd path could ever produce anything
|
||||
# usable there — matches the same call this codebase already made for the Tailscale exit-node
|
||||
# NIC tuning and the proxy's systemd docker-ordering fix.
|
||||
pm2_enable_startup() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
warn "pm2 boot-persistence needs a launchd plist on macOS, not a systemd unit — not implemented yet"
|
||||
return 1
|
||||
fi
|
||||
local cmd
|
||||
cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
|
||||
cmd="$(as_owner "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
|
||||
[[ -z "$cmd" ]] && return 1
|
||||
eval "${cmd#sudo }"
|
||||
}
|
||||
|
||||
# One line per process: name, status, restarts.
|
||||
pm2_status_lines() {
|
||||
sudo -u "$USERNAME" pm2 jlist 2>/dev/null |
|
||||
as_owner "pm2 jlist" 2>/dev/null |
|
||||
node -e '
|
||||
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
||||
let apps = []; try { apps = JSON.parse(s); } catch { }
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Frontend Apps
|
||||
|
||||
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
|
||||
|
||||
## UI Components
|
||||
|
||||
### shadcn/ui (Base)
|
||||
Location: `src/workspaces/components/ui/`
|
||||
|
||||
Standard shadcn/ui components with Tailwind CSS. Import via:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
Location: `src/workspaces/components/`
|
||||
|
||||
Built on top of shadcn primitives:
|
||||
- `Avatar.tsx` - User avatars
|
||||
- `Card.tsx` - Custom card wrapper
|
||||
- `ColorPicker.tsx` - Color selection
|
||||
- `DataTable/` - Table with sorting, filtering, pagination (see below)
|
||||
- `Dialogs/` - Common dialog patterns
|
||||
- `ErrorDialogs/` - Error display dialogs
|
||||
- `MetricCard.tsx` - Stats display card
|
||||
- `SearchInput.tsx` - Search with debounce
|
||||
- `Select.tsx` - Enhanced select
|
||||
- `Slider/` - Custom slider
|
||||
- `Tabs/` - Enhanced tabs
|
||||
|
||||
### DataTable + useDataControl
|
||||
|
||||
A complete data table solution with sorting, filtering, and pagination.
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
import { DataTable, useDataControl } from '@/components/DataTable';
|
||||
|
||||
function JobsLibrary() {
|
||||
const { jobs } = useJobs();
|
||||
const dataController = useDataControl<Job>(jobs || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Wire search input to controller */}
|
||||
<SearchInput
|
||||
value={dataController.searchQuery}
|
||||
handleSearch={dataController.setSearchQuery}
|
||||
/>
|
||||
|
||||
<DataTable<Job>
|
||||
dataController={dataController}
|
||||
pageSize={10}
|
||||
columns={[
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'type', label: 'Type', sortKey: 'type' },
|
||||
{
|
||||
field: 'status',
|
||||
label: 'Status',
|
||||
condition: view === 'All', // conditional column
|
||||
format: ({ value, item }) => <Badge>{value}</Badge>
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <ActionsCell job={item} />
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useDataControl returns:**
|
||||
- `data` - Current page of filtered/sorted data
|
||||
- `rawData` - Original unfiltered data
|
||||
- `searchQuery`, `setSearchQuery` - Search state
|
||||
- `searchKeys`, `setSearchKeys` - Which fields to search
|
||||
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
|
||||
- `sortKey`, `sortDirection` - Parsed sort info
|
||||
- `currentPage`, `changePage`, `pageCount` - Pagination
|
||||
- `pageSize`, `setPageSize` - Items per page
|
||||
- `setCustomSort` - Custom sort function
|
||||
|
||||
**Column options:**
|
||||
- `field` - Key in data object
|
||||
- `label` / `labelMobile` - Header text
|
||||
- `sortKey` - Enable sorting on this column
|
||||
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
|
||||
- `condition` - Show/hide column: `boolean | () => boolean`
|
||||
- `tooltip` - Header tooltip text
|
||||
- `headerClassName` / `cellClassName` - Styling
|
||||
|
||||
**Features:**
|
||||
- Auto-generates columns from data if not specified
|
||||
- Resets to page 1 on search/sort change
|
||||
- Search strips diacritics for accent-insensitive matching
|
||||
- Built-in pagination bar (shows when data exceeds pageSize)
|
||||
|
||||
## Hooks
|
||||
|
||||
Location: `src/workspaces/hooks/src/`
|
||||
|
||||
### Data Fetching - `useClient`
|
||||
HTTP client with auth token handling:
|
||||
```tsx
|
||||
const client = useClient();
|
||||
const data = await client.get<User[]>("/users");
|
||||
await client.post("/experiments", payload);
|
||||
```
|
||||
|
||||
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
Auto-attaches Bearer token from localStorage/sessionStorage.
|
||||
|
||||
### Forms - `useForm`
|
||||
Custom form hook (not react-hook-form):
|
||||
```tsx
|
||||
const { state, formRef, update, reset, isValid } = useForm<FormState>(
|
||||
initialState,
|
||||
validateFn
|
||||
);
|
||||
|
||||
return <form ref={formRef}>...</form>;
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-syncs form inputs with state via `name` attribute
|
||||
- Handles checkboxes, radios, selects, number inputs
|
||||
- Nested object support via data attributes
|
||||
- Validation function support
|
||||
|
||||
### Other Hooks
|
||||
- `useAuth/` - Authentication state and methods
|
||||
- `useDebounce` - Debounced values
|
||||
- `useDragAndDrop` - Drag and drop functionality
|
||||
- `useFullscreen/` - Fullscreen API wrapper
|
||||
- `useIsMobile` - Responsive breakpoint detection
|
||||
- `useLocalStorageState` - Persisted state
|
||||
- `useMounted` - Component mount status
|
||||
- `usePopover` - Popover state management
|
||||
- `useTimeout` - Timeout management
|
||||
- `useTimer` - Interval-based timer
|
||||
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
|
||||
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
|
||||
- `useJobs` - background jobs
|
||||
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
|
||||
|
||||
## State Management
|
||||
|
||||
Uses React Query cache as both server and client state manager.
|
||||
|
||||
### Global State - `useGlobal`
|
||||
|
||||
Uses React Query cache as a global state store (no Context providers needed):
|
||||
|
||||
```tsx
|
||||
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
|
||||
|
||||
// Any component using the same key shares state and reacts to changes
|
||||
setValue("collapsed");
|
||||
```
|
||||
|
||||
How it works:
|
||||
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
|
||||
- Automatic re-renders when state changes
|
||||
- Visible in React Query DevTools
|
||||
- Supports functional updates: `setValue(prev => ...)`
|
||||
|
||||
### URL Query State - `useQueryState`
|
||||
|
||||
Syncs state with URL query parameters:
|
||||
|
||||
```tsx
|
||||
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
|
||||
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
|
||||
```
|
||||
|
||||
Features:
|
||||
- Updates URL via `history.replaceState` (no page reload)
|
||||
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
|
||||
- `isGlobal = false`: local to component, uses `useState`
|
||||
- Auto-rebuilds URL when pathname changes (preserves global query state)
|
||||
- Type coercion based on `defaultValue` type
|
||||
|
||||
### Server State
|
||||
|
||||
Domain-specific hooks in `state/` directories wrap React Query:
|
||||
|
||||
```tsx
|
||||
// src/workspaces/hooks/src/useJobs.ts
|
||||
const { jobs, isLoading } = useJobs();
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Hook |
|
||||
|----------|------|
|
||||
| API data | Domain hooks (`useJobs`, `useModels`, …) |
|
||||
| Shared UI state | `useGlobal` |
|
||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||
| Component-only state | `useState` |
|
||||
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
|
||||
| Persisted to localStorage | `useLocalStorageState` |
|
||||
|
||||
## Event Handlers
|
||||
|
||||
**Always use `ev` for event parameters**, not `e`:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
```
|
||||
|
||||
## React 19 Patterns
|
||||
|
||||
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
|
||||
```tsx
|
||||
// ✅ Good - allow null in ref type
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
// ❌ Bad - will error when passing useRef result
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
|
||||
```tsx
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type TopHeaderProps = {
|
||||
manager: ReturnType<typeof useJobs>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Toast notifications** via Sonner:
|
||||
```tsx
|
||||
import { toast } from "sonner";
|
||||
|
||||
toast.error("Something went wrong");
|
||||
toast.success("Saved successfully");
|
||||
```
|
||||
|
||||
API errors automatically trigger via `useClient.config.onError`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `../../CLAUDE.md` — architecture and the frontend route conventions
|
||||
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
|
||||
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
|
||||
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
|
||||
- `../../CONVENTIONS.md` — component organisation and React patterns
|
||||
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
|
||||
+3
-268
@@ -1,271 +1,6 @@
|
||||
# Frontend Apps
|
||||
|
||||
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
## UI Components
|
||||
|
||||
### shadcn/ui (Base)
|
||||
Location: `src/workspaces/components/ui/`
|
||||
|
||||
Standard shadcn/ui components with Tailwind CSS. Import via:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
Location: `src/workspaces/components/`
|
||||
|
||||
Built on top of shadcn primitives:
|
||||
- `Avatar.tsx` - User avatars
|
||||
- `Card.tsx` - Custom card wrapper
|
||||
- `ColorPicker.tsx` - Color selection
|
||||
- `DataTable/` - Table with sorting, filtering, pagination (see below)
|
||||
- `Dialogs/` - Common dialog patterns
|
||||
- `ErrorDialogs/` - Error display dialogs
|
||||
- `MetricCard.tsx` - Stats display card
|
||||
- `SearchInput.tsx` - Search with debounce
|
||||
- `Select.tsx` - Enhanced select
|
||||
- `Slider/` - Custom slider
|
||||
- `Tabs/` - Enhanced tabs
|
||||
|
||||
### DataTable + useDataControl
|
||||
|
||||
A complete data table solution with sorting, filtering, and pagination.
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
import { DataTable, useDataControl } from '@/components/DataTable';
|
||||
|
||||
function JobsLibrary() {
|
||||
const { jobs } = useJobs();
|
||||
const dataController = useDataControl<Job>(jobs || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Wire search input to controller */}
|
||||
<SearchInput
|
||||
value={dataController.searchQuery}
|
||||
handleSearch={dataController.setSearchQuery}
|
||||
/>
|
||||
|
||||
<DataTable<Job>
|
||||
dataController={dataController}
|
||||
pageSize={10}
|
||||
columns={[
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'type', label: 'Type', sortKey: 'type' },
|
||||
{
|
||||
field: 'status',
|
||||
label: 'Status',
|
||||
condition: view === 'All', // conditional column
|
||||
format: ({ value, item }) => <Badge>{value}</Badge>
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <ActionsCell job={item} />
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useDataControl returns:**
|
||||
- `data` - Current page of filtered/sorted data
|
||||
- `rawData` - Original unfiltered data
|
||||
- `searchQuery`, `setSearchQuery` - Search state
|
||||
- `searchKeys`, `setSearchKeys` - Which fields to search
|
||||
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
|
||||
- `sortKey`, `sortDirection` - Parsed sort info
|
||||
- `currentPage`, `changePage`, `pageCount` - Pagination
|
||||
- `pageSize`, `setPageSize` - Items per page
|
||||
- `setCustomSort` - Custom sort function
|
||||
|
||||
**Column options:**
|
||||
- `field` - Key in data object
|
||||
- `label` / `labelMobile` - Header text
|
||||
- `sortKey` - Enable sorting on this column
|
||||
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
|
||||
- `condition` - Show/hide column: `boolean | () => boolean`
|
||||
- `tooltip` - Header tooltip text
|
||||
- `headerClassName` / `cellClassName` - Styling
|
||||
|
||||
**Features:**
|
||||
- Auto-generates columns from data if not specified
|
||||
- Resets to page 1 on search/sort change
|
||||
- Search strips diacritics for accent-insensitive matching
|
||||
- Built-in pagination bar (shows when data exceeds pageSize)
|
||||
|
||||
## Hooks
|
||||
|
||||
Location: `src/workspaces/hooks/src/`
|
||||
|
||||
### Data Fetching - `useClient`
|
||||
HTTP client with auth token handling:
|
||||
```tsx
|
||||
const client = useClient();
|
||||
const data = await client.get<User[]>("/users");
|
||||
await client.post("/experiments", payload);
|
||||
```
|
||||
|
||||
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
Auto-attaches Bearer token from localStorage/sessionStorage.
|
||||
|
||||
### Forms - `useForm`
|
||||
Custom form hook (not react-hook-form):
|
||||
```tsx
|
||||
const { state, formRef, update, reset, isValid } = useForm<FormState>(
|
||||
initialState,
|
||||
validateFn
|
||||
);
|
||||
|
||||
return <form ref={formRef}>...</form>;
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-syncs form inputs with state via `name` attribute
|
||||
- Handles checkboxes, radios, selects, number inputs
|
||||
- Nested object support via data attributes
|
||||
- Validation function support
|
||||
|
||||
### Other Hooks
|
||||
- `useAuth/` - Authentication state and methods
|
||||
- `useDebounce` - Debounced values
|
||||
- `useDragAndDrop` - Drag and drop functionality
|
||||
- `useFullscreen/` - Fullscreen API wrapper
|
||||
- `useIsMobile` - Responsive breakpoint detection
|
||||
- `useLocalStorageState` - Persisted state
|
||||
- `useMounted` - Component mount status
|
||||
- `usePopover` - Popover state management
|
||||
- `useTimeout` - Timeout management
|
||||
- `useTimer` - Interval-based timer
|
||||
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
|
||||
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
|
||||
- `useJobs` - background jobs
|
||||
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
|
||||
|
||||
## State Management
|
||||
|
||||
Uses React Query cache as both server and client state manager.
|
||||
|
||||
### Global State - `useGlobal`
|
||||
|
||||
Uses React Query cache as a global state store (no Context providers needed):
|
||||
|
||||
```tsx
|
||||
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
|
||||
|
||||
// Any component using the same key shares state and reacts to changes
|
||||
setValue("collapsed");
|
||||
```
|
||||
|
||||
How it works:
|
||||
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
|
||||
- Automatic re-renders when state changes
|
||||
- Visible in React Query DevTools
|
||||
- Supports functional updates: `setValue(prev => ...)`
|
||||
|
||||
### URL Query State - `useQueryState`
|
||||
|
||||
Syncs state with URL query parameters:
|
||||
|
||||
```tsx
|
||||
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
|
||||
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
|
||||
```
|
||||
|
||||
Features:
|
||||
- Updates URL via `history.replaceState` (no page reload)
|
||||
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
|
||||
- `isGlobal = false`: local to component, uses `useState`
|
||||
- Auto-rebuilds URL when pathname changes (preserves global query state)
|
||||
- Type coercion based on `defaultValue` type
|
||||
|
||||
### Server State
|
||||
|
||||
Domain-specific hooks in `state/` directories wrap React Query:
|
||||
|
||||
```tsx
|
||||
// src/workspaces/hooks/src/useJobs.ts
|
||||
const { jobs, isLoading } = useJobs();
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Hook |
|
||||
|----------|------|
|
||||
| API data | Domain hooks (`useJobs`, `useModels`, …) |
|
||||
| Shared UI state | `useGlobal` |
|
||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||
| Component-only state | `useState` |
|
||||
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
|
||||
| Persisted to localStorage | `useLocalStorageState` |
|
||||
|
||||
## Event Handlers
|
||||
|
||||
**Always use `ev` for event parameters**, not `e`:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
```
|
||||
|
||||
## React 19 Patterns
|
||||
|
||||
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
|
||||
```tsx
|
||||
// ✅ Good - allow null in ref type
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
// ❌ Bad - will error when passing useRef result
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
|
||||
```tsx
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type TopHeaderProps = {
|
||||
manager: ReturnType<typeof useJobs>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Toast notifications** via Sonner:
|
||||
```tsx
|
||||
import { toast } from "sonner";
|
||||
|
||||
toast.error("Something went wrong");
|
||||
toast.success("Saved successfully");
|
||||
```
|
||||
|
||||
API errors automatically trigger via `useClient.config.onError`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `../../CLAUDE.md` — architecture and the frontend route conventions
|
||||
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
|
||||
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
|
||||
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
|
||||
- `../../CONVENTIONS.md` — component organisation and React patterns
|
||||
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
@@ -78,8 +78,6 @@ export function App() {
|
||||
entry keep working. Only DIRECTORIES live here — an open file stays `?view=`, since it is
|
||||
what is on top of you rather than where you are. */}
|
||||
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
|
||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
<Route path="/command-hq" element={<Dashboard.CommandHqScreen />} />
|
||||
<Route path="/command-hq/:sessionId" element={<Dashboard.CommandHqScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
@@ -91,13 +89,15 @@ export function App() {
|
||||
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
||||
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
|
||||
{installedPlugins.flatMap((plugin) => [
|
||||
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
|
||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||
// passed between them.
|
||||
<Route
|
||||
key={`${plugin.appName}-section`}
|
||||
key={`${plugin.screenId}-section`}
|
||||
path={`${plugin.route}/:section`}
|
||||
element={<Dashboard.PluginScreen {...plugin} />}
|
||||
/>,
|
||||
@@ -107,7 +107,9 @@ export function App() {
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
||||
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
||||
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
||||
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { Bot, Check, Pencil, Plus, Trash2, X } from 'lucide-react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { useClaudeSessions, type ClaudeSessionSummary } from 'state/useClaudeSessions';
|
||||
|
||||
/**
|
||||
* The bots, as cards — the same object Command HQ gives a commander.
|
||||
*
|
||||
* ── Why not the chat session list ──
|
||||
*
|
||||
* A bot IS a chat underneath, and that is an implementation detail nobody using this should have to hold.
|
||||
* The generic session list says "conversation, last touched at" because that is what a conversation is. A
|
||||
* bot is a thing you name, check on, and eventually delete — so it gets a card with its name, its id, and
|
||||
* whether it is watching, exactly as a commander does.
|
||||
*
|
||||
* ── The id pill ──
|
||||
*
|
||||
* A commander shows `#54`, its directory. A bot has no directory of its own — they share one — so its id
|
||||
* is the first eight characters of its session, which is also what an `@mention` carries. Seeing the same
|
||||
* eight characters in both places is the point: the pill is what you type to reference it.
|
||||
*/
|
||||
|
||||
const shortId = (id: string) => id.slice(0, 8);
|
||||
|
||||
type CardProps = {
|
||||
bot: ClaudeSessionSummary;
|
||||
selected: boolean;
|
||||
onRename: (id: string, title: string) => Promise<unknown>;
|
||||
onDelete: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const BotCard = ({ bot, selected, onRename, onDelete }: CardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [draft, setDraft] = useState(bot.title);
|
||||
|
||||
const commit = async () => {
|
||||
const next = draft.trim();
|
||||
setRenaming(false);
|
||||
if (next && next !== bot.title) await onRename(bot.id, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex flex-col gap-1.5 rounded-xl border bg-background p-3 transition-colors hover:border-primary/40 hover:bg-muted/40 ${
|
||||
selected ? 'border-primary/60 ring-1 ring-primary/30' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{/* The whole card is the link, with the controls layered above it — same arrangement as a commander
|
||||
card, so a click anywhere opens the bot and the buttons still work. */}
|
||||
{!renaming && <Link to={`/bots/${bot.id}`} className="absolute inset-0" aria-label={bot.title} />}
|
||||
|
||||
<div className="flex items-center gap-2 pr-12">
|
||||
<Bot className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
{renaming ? (
|
||||
<form
|
||||
className="z-10 flex min-w-0 flex-1 items-center gap-1"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void commit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
onKeyDown={(ev) => ev.key === 'Escape' && setRenaming(false)}
|
||||
className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0.5 text-sm"
|
||||
/>
|
||||
<button type="submit" className="rounded p-0.5 text-primary" aria-label="Save name">
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate text-sm font-medium">{bot.title || '(unnamed)'}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground" title={bot.id}>
|
||||
#{shortId(bot.id)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{bot.messageCount} message{bot.messageCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span title={bot.updatedAt}>{new Date(bot.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{confirming ? (
|
||||
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-1 rounded-lg bg-background/95 p-0.5 shadow">
|
||||
<button
|
||||
onClick={() => {
|
||||
void onDelete(bot.id);
|
||||
// Leaving the route pointed at something that no longer exists would render an empty chat.
|
||||
if (selected) void navigate('/bots');
|
||||
}}
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
title="Delete this bot"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirming(false)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
!renaming && (
|
||||
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(bot.title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BotsList = () => {
|
||||
const { sessionId } = useParams();
|
||||
const { sessions, isLoading, renameSession, deleteSession } = useClaudeSessions(null, {
|
||||
chatContext: { context: 'bots' },
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="flex size-full flex-col">
|
||||
<header className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
{sessions.length} {sessions.length === 1 ? 'bot' : 'bots'}
|
||||
</span>
|
||||
{/* `/bots` with no id IS the new-bot state: an empty composer, and the first sentence arms it. */}
|
||||
<Link to="/bots" title="New bot" className="text-muted-foreground hover:text-foreground">
|
||||
<Plus className="size-4" />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="p-3 text-xs text-muted-foreground">Loading…</p>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<Bot className="size-7 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No bots yet.</p>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
Say what you want watched — “notify me when x.com is down” — and it arms itself.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-1 flex-col gap-2 overflow-auto p-2">
|
||||
{sessions.map((bot) => (
|
||||
<li key={bot.id}>
|
||||
<BotCard bot={bot} selected={bot.id === sessionId} onRename={renameSession} onDelete={deleteSession} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
|
||||
import { useParams } from 'react-router';
|
||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { BotsList } from './BotsList';
|
||||
|
||||
/**
|
||||
* `/bots` — one sentence arms a watch, and the conversation stays open to widen it.
|
||||
*
|
||||
* ── The same shape as Command HQ ──
|
||||
*
|
||||
* A narrow list on the left, the conversation on the right, and the selection in the URL as a route pair:
|
||||
* `/bots` is the new-bot state, `/bots/:sessionId` is one of them. That is the convention
|
||||
* `docs/navigation-audit.md` sets and the one Command HQ already follows, so a bot is linkable and a
|
||||
* reload keeps the one you were reading.
|
||||
*
|
||||
* ── Why the panels are supplied here rather than registered ──
|
||||
*
|
||||
* Every bot runs in one shared directory, which the server resolves from `context: 'bots'`. The registry's
|
||||
* `officerdev/chat` derives its context from the workspace and would land on none of it — it would list
|
||||
* and resume the caller's HOME, which is exactly what this screen did on its first outing. `components` is
|
||||
* the prop for a screen that supplies its own chat panel, and it carries the header, so the picker lists
|
||||
* bots rather than every chat on the machine.
|
||||
*
|
||||
* The instructions a bot runs under are `$DATA_PATH/<email>/bots/CLAUDE.md`, written by the server on
|
||||
* every turn — `api/bots/brief.ts`.
|
||||
*/
|
||||
|
||||
const CHAT_CONTEXT = { context: 'bots' } as const;
|
||||
|
||||
const BotsChat = ({ panelId }: { panelId: string }) => {
|
||||
const { sessionId } = useParams();
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* `sessionId ?? null` — null is "the screen chose a new chat", which is not the same as having no
|
||||
opinion. On `/bots` with no id that is exactly right: an empty composer waiting for the sentence. */}
|
||||
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} sessionId={sessionId ?? null} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BotsScreen = () => {
|
||||
const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
|
||||
|
||||
return (
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
|
||||
components={{
|
||||
'bots-list': BotsList,
|
||||
// No header picker: the cards on the left are the list, and a second one in the panel bar would be
|
||||
// the same choice offered twice. Command HQ does not have one either, for the same reason.
|
||||
'bots-chat': BotsChat,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// The bots down the left, the conversation on the right — the same division Command HQ makes, and for the
|
||||
// same reason: the list is names and the right-hand side is the only thing anybody reads.
|
||||
//
|
||||
// No category pane. Commanders have one because a commander is a job somebody files; a bot is a sentence,
|
||||
// and a filter over a handful of one-line watches would be furniture.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'bots-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'bots-list', appType: 'bots-list' }, size: 24 },
|
||||
{ node: { type: 'panel', id: 'bots-chat', appType: 'bots-chat' }, size: 76 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BotsScreen } from './BotsScreen';
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /calendar uses the Workspace/Panel system (like /command-hq): the calendar list on the left, the
|
||||
// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the
|
||||
// /api/caldav auth proxy, which holds no DAV credentials of its own.
|
||||
//
|
||||
// There is no :param route pair here and so no <Navigate> guard: a collection id is a DAV path with
|
||||
// slashes in it, so the selection lives in ?collection= on this same route. An absent or unknown value
|
||||
// resolves to the first collection inside the panels rather than by redirecting, because until the
|
||||
// collection list has loaded there is no canonical URL to redirect to.
|
||||
|
||||
export const CalendarScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/calendar', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['calendar-nav', 'calendar-view'], fallback: 'calendar-view' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'calendar-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'calendar-nav', appType: 'calendar-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'calendar-view', appType: 'calendar-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './CalendarScreen';
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected
|
||||
// collection is a query param rather than a route segment.
|
||||
|
||||
export const ContactsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/contacts', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['contacts-nav', 'contacts-view'], fallback: 'contacts-view' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'contacts-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'contacts-nav', appType: 'contacts-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'contacts-view', appType: 'contacts-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ContactsScreen';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
||||
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
|
||||
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
|
||||
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
|
||||
<ImpersonationBanner />
|
||||
<Header dockItems={visibleItems} hidden={panelFullscreen} />
|
||||
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
||||
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
||||
|
||||
@@ -109,10 +109,16 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
|
||||
{item.label}
|
||||
</span>
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl overflow-hidden flex items-center justify-center transition-all"
|
||||
style={{
|
||||
// `bare` keeps the box — it is what sizes the row and carries the glow — and simply
|
||||
// does not paint it.
|
||||
//
|
||||
// `overflow-hidden` is what makes the ROUNDING apply to artwork. A `badge` tile insets
|
||||
// its glyph, so the corners were never reached and nothing here looked wrong for as
|
||||
// long as every plugin used one. `bare` fills the box edge to edge, so a logo on an
|
||||
// opaque background kept its own square corners and sat in the dock as the one tile
|
||||
// that had not been told the shape.
|
||||
background: item.tile === 'bare' ? 'transparent' : item.color,
|
||||
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
|
||||
}}
|
||||
@@ -142,22 +148,23 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
|
||||
};
|
||||
|
||||
import {
|
||||
Home,
|
||||
MessageCircle,
|
||||
FolderOpen,
|
||||
Code,
|
||||
LayoutGrid,
|
||||
Monitor,
|
||||
Globe,
|
||||
Workflow,
|
||||
Music,
|
||||
Activity,
|
||||
Radio,
|
||||
Bot,
|
||||
Code,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Store,
|
||||
Globe,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
MessageCircle,
|
||||
Monitor,
|
||||
Music,
|
||||
Puzzle,
|
||||
Radar,
|
||||
Radio,
|
||||
Send,
|
||||
Store,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -176,6 +183,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
||||
{ 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' },
|
||||
// Beside Command HQ, which is the neighbouring idea: a commander runs a job, a bot watches one thing.
|
||||
// The route and the screen have existed since bots did; there was simply never a tile, so the only way
|
||||
// to reach /bots was to type it.
|
||||
{ labelKey: 'nav.bots', to: '/bots', icon: Bot, color: '#34d399' },
|
||||
{ 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' },
|
||||
@@ -187,17 +198,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
||||
// `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
|
||||
// this file's comment above warns about, arrived at from the other direction.
|
||||
//
|
||||
// Artwork rather than glyphs since 2026-08-19: both were redrawn to the icon brief, so they carry
|
||||
// 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.
|
||||
{ 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' },
|
||||
// Calendar and Contacts are NOT here. They were core tiles from 2026-08-19 until caldav became a
|
||||
// plugin on 2026-08-27, and they come back through `dockItemsFromPlugins` — one tile per screen, each
|
||||
// with its own artwork, filtered by the same `calendar` permission. A plugin contributing two tiles is
|
||||
// what `screens` in a manifest is for.
|
||||
// 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.
|
||||
@@ -282,6 +286,8 @@ export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
||||
// this list is what every install has and most people want visible; a calendar is now in that category.
|
||||
// Contacts is deliberately NOT here — it is core and available in dock settings, but a second tile for
|
||||
// the same sidecar in everyone's default dock is a crowded default rather than a useful one.
|
||||
// `/calendar` stays in the default arrangement even though it is a plugin route now: it is installed by
|
||||
// default and a fresh dock that silently dropped it would look like a downgrade.
|
||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat', '/calendar'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -143,7 +143,10 @@ const Row = ({
|
||||
it put a coloured pane behind Calendar, Contacts and every plugin logo.
|
||||
*/}
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
||||
// `overflow-hidden` for the same reason as the dock: `bare` artwork fills the box, so without it
|
||||
// the rounding is drawn and then painted over. The two have to agree — the same plugin appears in
|
||||
// both, a few pixels apart.
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md"
|
||||
style={{
|
||||
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
|
||||
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
|
||||
|
||||
@@ -178,13 +178,9 @@ const SETTINGS_ITEMS: SettingsItem[] = [
|
||||
icon: KeyRound,
|
||||
},
|
||||
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
|
||||
{
|
||||
id: 'set:dav',
|
||||
labelKey: 'settings.sections.davSync.label',
|
||||
to: '/settings/integrations/dav-app-passwords',
|
||||
hintKey: 'settings.sections.davSync.hint',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
// DAV sync setup left with the caldav plugin on 2026-08-27 — it is a modal on the Calendar screen now,
|
||||
// and Spotlight has no way to open a plugin's modal. `bun run docs:check` caught this one: it compares
|
||||
// every settings route Spotlight offers against the sections that actually exist.
|
||||
// Users — UserManagement
|
||||
{
|
||||
id: 'set:users',
|
||||
|
||||
@@ -18,11 +18,18 @@ import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
|
||||
// something else falls back rather than rendering another plugin's panel inside this one.
|
||||
export function PluginScreen({
|
||||
appName,
|
||||
screenId,
|
||||
panels,
|
||||
layout,
|
||||
}: {
|
||||
appName: string;
|
||||
/**
|
||||
* This SCREEN's id, not the plugin's — `caldav/calendar`, not `caldav`.
|
||||
*
|
||||
* A plugin with two screens has two of these, and they must differ: the id is both the layout
|
||||
* persistence key and the owner of the panel allow-list below, so sharing one would have Calendar and
|
||||
* Contacts persisting over each other's sizes and each accepting the other's panels.
|
||||
*/
|
||||
screenId: string;
|
||||
panels: AppRegistryMeta[];
|
||||
layout: LayoutNode;
|
||||
}) {
|
||||
@@ -32,7 +39,7 @@ export function PluginScreen({
|
||||
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
|
||||
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
|
||||
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
|
||||
const workspace = useAuthoredLayout(`screens/plugin/${appName}`, layout);
|
||||
const workspace = useAuthoredLayout(`screens/plugin/${screenId}`, layout);
|
||||
const allowed = panels.map((panel) => panel.key);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
|
||||
@@ -11,7 +11,11 @@ import { defaultLayout } from './defaultLayout';
|
||||
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
|
||||
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
|
||||
export const PluginsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
|
||||
// `useAuthoredLayout`, not `useDashboardState`: the STRUCTURE here is ours and comes from source on
|
||||
// every render, while only the sizes are the user's and persisted. Under the old hook the whole tree
|
||||
// was stored, so the panels this screen ships today would never have reached anyone who had opened it
|
||||
// before — they would still be looking at the list-and-preview this replaced.
|
||||
const workspace = useAuthoredLayout('screens/plugins', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
|
||||
@@ -8,7 +8,9 @@ export const defaultLayout: LayoutNode = {
|
||||
id: 'plugins-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
|
||||
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
|
||||
// 22/78, not 32/68. The left panel was a list of every plugin and needed the room; it is a list of
|
||||
// seven tags now, and the grid on the right is what benefits from the width — one more card per row.
|
||||
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 78 },
|
||||
],
|
||||
};
|
||||
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useFormat } from 'i18n';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
||||
//
|
||||
// The generated password is returned by POST and never again — it is stored as an argon2 hash, so there
|
||||
// is nothing to read back. That single fact drives the whole design of this screen: the new credential
|
||||
// is shown in a panel that stays put until dismissed, with the server URL and username beside it,
|
||||
// because the moment it disappears the only remedy is to revoke and mint another.
|
||||
|
||||
type DavPassword = {
|
||||
id: number;
|
||||
label: string;
|
||||
hint: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Minted = { password: string; username: string; label: string };
|
||||
|
||||
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
|
||||
|
||||
export const DavAppPasswords = () => {
|
||||
const { t } = useTranslation();
|
||||
const { date: formatDate } = useFormat();
|
||||
|
||||
const copy = async (text: string, what: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast.success(t('settings.dav.copied', { what }));
|
||||
} catch {
|
||||
toast.error(t('settings.dav.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const [passwords, setPasswords] = useState<DavPassword[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [label, setLabel] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [minted, setMinted] = useState<Minted | null>(null);
|
||||
|
||||
// The base the phone should be pointed at. Taken from the browser rather than configured: it is by
|
||||
// definition the address that reached this page, which is the one that will work.
|
||||
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
|
||||
const load = useCallback(() => {
|
||||
client
|
||||
.get<{ passwords: DavPassword[] }>('/dav/passwords')
|
||||
.then((data) => setPasswords(data?.passwords ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [client]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const create = async (ev?: FormEvent) => {
|
||||
ev?.preventDefault();
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const res = await client.post<{ password: string; username: string }>('/dav/passwords', { label: trimmed });
|
||||
setMinted({ password: res.password, username: res.username, label: trimmed });
|
||||
setLabel('');
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.createFailed'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: number) => {
|
||||
try {
|
||||
await client.post(`/dav/passwords/${id}/revoke`, {});
|
||||
toast.success(t('settings.dav.revoked'));
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.revokeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await client.delete(`/dav/passwords/${id}`);
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
Calendar and contacts sync uses one password per device. A phone can't hold an Officer session, so it gets its
|
||||
own credential — revocable on its own, without touching anything else.
|
||||
</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">
|
||||
Password for “{minted.label}” — shown once
|
||||
</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,
|
||||
revoke this entry and make another.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{[
|
||||
{ field: 'Server', value: serverUrl },
|
||||
{ field: 'Username', value: minted.username },
|
||||
{ field: 'Password', value: minted.password },
|
||||
].map(({ field, value }) => (
|
||||
<div key={field} className="flex items-center gap-2">
|
||||
<span className="w-20 shrink-0 text-xs text-duck-dark/50 dark:text-foreground/50">{field}</span>
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copy(value, field)}
|
||||
title={t('settings.dav.copyField', { field })}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||
In DAVx5 or iOS, choose “login with URL and username” and give it the server address above — not a full
|
||||
calendar path. Discovery does the rest.
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
|
||||
I've saved it
|
||||
</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">{t('settings.dav.newDevice')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20"
|
||||
value={label}
|
||||
onChange={(ev) => setLabel(ev.target.value)}
|
||||
placeholder={t('settings.dav.placeholder')}
|
||||
/>
|
||||
</Label>
|
||||
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
{t('settings.dav.create')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passwords.length === 0 ? (
|
||||
<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">
|
||||
{t('settings.dav.none')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{passwords.map((entry) => {
|
||||
const revoked = !!entry.revokedAt;
|
||||
const lastUsed = entry.lastUsedAt
|
||||
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-center gap-3 rounded-lg border p-3 ${
|
||||
revoked
|
||||
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
|
||||
: 'border-duck-dark/15 dark:border-foreground/15'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="h-4 w-4 shrink-0 text-duck-teal/70" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-duck-dark dark:text-foreground">
|
||||
{entry.label}
|
||||
{revoked && <span className="ml-2 text-xs text-red-500">revoked</span>}
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
<code className="font-mono">{entry.hint}…</code>
|
||||
{' · '}
|
||||
{/* "never used" is the tell that a device was set up wrong, so it earns its own wording. */}
|
||||
{lastUsed ? `last used ${lastUsed}` : 'never used'}
|
||||
</div>
|
||||
</div>
|
||||
{!revoked && (
|
||||
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(entry.id)}
|
||||
title={t('settings.dav.delete')}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection }
|
||||
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';
|
||||
@@ -43,13 +42,9 @@ const personalSections: SettingsSection[] = [
|
||||
// descriptionKey: 'settings.sections.apify.description',
|
||||
// content: <GoogleAccount />,
|
||||
// },
|
||||
{
|
||||
key: 'dav-app-passwords',
|
||||
icon: CalendarDays,
|
||||
titleKey: 'settings.sections.davSync.label',
|
||||
descriptionKey: 'settings.sections.davSync.description',
|
||||
content: <DavAppPasswords />,
|
||||
},
|
||||
// DAV app passwords left with the caldav plugin on 2026-08-27. A plugin cannot contribute a settings
|
||||
// section — it ships panels — so the form is a modal on the Calendar screen, the same answer the email
|
||||
// plugin reached for account management when it was extracted.
|
||||
];
|
||||
|
||||
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { startImpersonation } from 'officerdev';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Crown,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
SquareTerminal as TerminalIcon,
|
||||
Trash2,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -128,6 +138,25 @@ export const UsersSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign in as this person, for an hour.
|
||||
*
|
||||
* No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
|
||||
* nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
|
||||
* new password and a deletion, which are neither.
|
||||
*/
|
||||
const impersonate = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
try {
|
||||
const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
|
||||
// Swaps the token and reloads — nothing after this line runs.
|
||||
startImpersonation(token);
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
setConfirmDelete(null);
|
||||
@@ -263,6 +292,22 @@ export const UsersSection = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Not for the owner: impersonating yourself is refused by the route, and the row that
|
||||
offers it would be the one row where it cannot work. */}
|
||||
{!user.isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
disabled={busy}
|
||||
aria-label={`Sign in as ${user.email}`}
|
||||
title="Sign in as this user for one hour"
|
||||
onClick={() => void impersonate(user)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCog className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
|
||||
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
|
||||
export const TodosScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'todo-board',
|
||||
appType: 'todo-board',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './TodosScreen';
|
||||
@@ -11,8 +11,6 @@ export * from './Skills';
|
||||
export * from './Tasks';
|
||||
|
||||
export * from './Files';
|
||||
export * from './Calendar';
|
||||
export * from './Contacts';
|
||||
export * from './CommandHQ';
|
||||
export * from './SystemMonitor';
|
||||
export * from './Activity';
|
||||
@@ -23,4 +21,4 @@ export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './SendOff';
|
||||
export * from './Todos';
|
||||
export * from './Bots';
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Dateien",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Files",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Archivos",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Trabajos",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Fichiers",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Éditeur",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Ficheiros",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Trabalhos",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Filer",
|
||||
"chat": "Chatt",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobb",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
|
||||
describing a different codebase.
|
||||
|
||||
```
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
|
||||
│ ├── schema.ts # what db:push creates — see below
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||
│ └── <feature>/
|
||||
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||
│ ├── schema.ts # its tables
|
||||
│ └── queries.ts # everything that reads or writes them
|
||||
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||
```
|
||||
|
||||
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||
query function is one file in one directory, rather than that file plus a hand-written list of every
|
||||
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
|
||||
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
|
||||
|
||||
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
|
||||
`queries/` trees, where the two sides had drifted: four features were named differently on each side
|
||||
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
|
||||
query file at all, and `integrations` had no schema file.
|
||||
|
||||
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
|
||||
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
|
||||
Task Logs feature — see below.)
|
||||
|
||||
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
|
||||
commented line there removes a table from the DATABASE without removing a line of code — every query
|
||||
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
|
||||
create only the core tables, with the plugin ones commented out until their plugin is installed.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
|
||||
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
|
||||
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
|
||||
as the source of truth, never those files.
|
||||
|
||||
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
|
||||
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
|
||||
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
|
||||
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
|
||||
them was gone, because nothing structural said they had become illegal.
|
||||
|
||||
```ts
|
||||
const SERVER_PROVIDERS = ['google', 'apify'] as const;
|
||||
|
||||
check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(
|
||||
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
database has drifted, instead of quietly accepting it. Clean the rows, then push.
|
||||
|
||||
### Composite keys: use `uniqueIndex`, and name any long foreign key
|
||||
|
||||
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
|
||||
|
||||
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
|
||||
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
|
||||
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
|
||||
longer version and re-creates the constraint on every push.
|
||||
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
|
||||
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
|
||||
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
The six, and where each lives:
|
||||
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
|
||||
nothing — whatever is being re-created gets re-created next push regardless.
|
||||
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
|
||||
uniqueness rule would break the upserts that depend on it.
|
||||
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
|
||||
branch it takes at a truncate prompt.
|
||||
4. **Read the plan before applying it:**
|
||||
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
|
||||
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
|
||||
all. Anything else is your change.
|
||||
|
||||
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
|
||||
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
|
||||
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
|
||||
|
||||
## Type naming
|
||||
|
||||
```ts
|
||||
// types.ts — inferred from the schema, never hand-written
|
||||
export type UserSelect = typeof Schema.users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.users.$inferInsert;
|
||||
|
||||
// The bare name is the hydrated shape, when a table has relations worth carrying
|
||||
export type User = UserSelect & {
|
||||
passkeys: PasskeySelect[];
|
||||
};
|
||||
```
|
||||
|
||||
Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
## Queries
|
||||
|
||||
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||
types from `../types`:
|
||||
|
||||
```ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys } from '../schema';
|
||||
import type { UserSelect } from '../types';
|
||||
|
||||
export async function getUsers(): Promise<UserSelect[]> { … }
|
||||
```
|
||||
|
||||
Every query is exported from `src/index.ts`, which is the only surface callers use.
|
||||
|
||||
## Importing
|
||||
|
||||
```ts
|
||||
// ✅ types from the types subpath
|
||||
import type { User } from 'officerdb/types';
|
||||
// ✅ queries, schema and drizzle helpers from the package root
|
||||
import { getUserById, eq } from 'officerdb';
|
||||
|
||||
// ❌ never reach into src/schema or src/queries directly
|
||||
// ❌ never hand-write a type a table can infer
|
||||
```
|
||||
|
||||
In app code import from `'types'`, which re-exports the database types. Only server and database code
|
||||
imports `officerdb/types` directly.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
|
||||
(`chat_session_events` is the example — its id is also the replay cursor).
|
||||
- `timestamp('…', { withTimezone: true })`, always.
|
||||
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
|
||||
than cast.
|
||||
+3
-228
@@ -1,231 +1,6 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
|
||||
describing a different codebase.
|
||||
|
||||
```
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
|
||||
│ ├── schema.ts # what db:push creates — see below
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||
│ └── <feature>/
|
||||
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||
│ ├── schema.ts # its tables
|
||||
│ └── queries.ts # everything that reads or writes them
|
||||
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||
```
|
||||
|
||||
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||
query function is one file in one directory, rather than that file plus a hand-written list of every
|
||||
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
|
||||
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
|
||||
|
||||
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
|
||||
`queries/` trees, where the two sides had drifted: four features were named differently on each side
|
||||
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
|
||||
query file at all, and `integrations` had no schema file.
|
||||
|
||||
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
|
||||
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
|
||||
Task Logs feature — see below.)
|
||||
|
||||
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
|
||||
commented line there removes a table from the DATABASE without removing a line of code — every query
|
||||
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
|
||||
create only the core tables, with the plugin ones commented out until their plugin is installed.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
|
||||
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
|
||||
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
|
||||
as the source of truth, never those files.
|
||||
|
||||
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
|
||||
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
|
||||
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
|
||||
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
|
||||
them was gone, because nothing structural said they had become illegal.
|
||||
|
||||
```ts
|
||||
const SERVER_PROVIDERS = ['google', 'apify'] as const;
|
||||
|
||||
check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(
|
||||
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
database has drifted, instead of quietly accepting it. Clean the rows, then push.
|
||||
|
||||
### Composite keys: use `uniqueIndex`, and name any long foreign key
|
||||
|
||||
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
|
||||
|
||||
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
|
||||
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
|
||||
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
|
||||
longer version and re-creates the constraint on every push.
|
||||
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
|
||||
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
|
||||
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
The six, and where each lives:
|
||||
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
|
||||
nothing — whatever is being re-created gets re-created next push regardless.
|
||||
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
|
||||
uniqueness rule would break the upserts that depend on it.
|
||||
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
|
||||
branch it takes at a truncate prompt.
|
||||
4. **Read the plan before applying it:**
|
||||
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
|
||||
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
|
||||
all. Anything else is your change.
|
||||
|
||||
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
|
||||
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
|
||||
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
|
||||
|
||||
## Type naming
|
||||
|
||||
```ts
|
||||
// types.ts — inferred from the schema, never hand-written
|
||||
export type UserSelect = typeof Schema.users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.users.$inferInsert;
|
||||
|
||||
// The bare name is the hydrated shape, when a table has relations worth carrying
|
||||
export type User = UserSelect & {
|
||||
passkeys: PasskeySelect[];
|
||||
};
|
||||
```
|
||||
|
||||
Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
## Queries
|
||||
|
||||
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||
types from `../types`:
|
||||
|
||||
```ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys } from '../schema';
|
||||
import type { UserSelect } from '../types';
|
||||
|
||||
export async function getUsers(): Promise<UserSelect[]> { … }
|
||||
```
|
||||
|
||||
Every query is exported from `src/index.ts`, which is the only surface callers use.
|
||||
|
||||
## Importing
|
||||
|
||||
```ts
|
||||
// ✅ types from the types subpath
|
||||
import type { User } from 'officerdb/types';
|
||||
// ✅ queries, schema and drizzle helpers from the package root
|
||||
import { getUserById, eq } from 'officerdb';
|
||||
|
||||
// ❌ never reach into src/schema or src/queries directly
|
||||
// ❌ never hand-write a type a table can infer
|
||||
```
|
||||
|
||||
In app code import from `'types'`, which re-exports the database types. Only server and database code
|
||||
imports `officerdb/types` directly.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
|
||||
(`chat_session_events` is the example — its id is also the replay cursor).
|
||||
- `timestamp('…', { withTimezone: true })`, always.
|
||||
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
|
||||
than cast.
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
@@ -17,6 +17,8 @@ export type CatalogueEntry = {
|
||||
platform: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
/** What it is for. Always present, `[]` for a plugin that declares none, so callers need no guard. */
|
||||
tags: string[];
|
||||
color: string;
|
||||
tile?: 'badge' | 'bare';
|
||||
iconUrl?: string;
|
||||
@@ -53,6 +55,9 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
|
||||
platform: m.platform,
|
||||
label: m.label,
|
||||
summary: m.summary,
|
||||
// Normalised to an array here rather than at every call site: the column is nullable because it
|
||||
// arrived after the rows did, and null vs [] is a distinction no consumer of this wants to carry.
|
||||
tags: Array.isArray(m.tags) ? (m.tags as string[]) : [],
|
||||
color: m.color ?? '#94a3b8',
|
||||
...(m.tile ? { tile: m.tile as 'badge' | 'bare' } : {}),
|
||||
...(m.iconUrl ? { iconUrl: m.iconUrl } : {}),
|
||||
|
||||
@@ -85,6 +85,20 @@ export const pluginMetadata = pgTable(
|
||||
/** A lucide glyph NAME, for a plugin that ships no artwork. `iconUrl` wins where both are given. */
|
||||
icon: text('icon'),
|
||||
|
||||
/**
|
||||
* What the plugin is for — the store's filter, and the only field here that exists for BROWSING
|
||||
* rather than for installing.
|
||||
*
|
||||
* jsonb like the shapes below it, and for the same reason: it is read as a unit and rendered as a
|
||||
* unit. A join table would buy filtering in SQL, which matters at a scale a hand-curated catalogue of
|
||||
* twenty-one is nowhere near — and it would cost a migration every time the vocabulary changes, which
|
||||
* is exactly the thing expected to change.
|
||||
*
|
||||
* Nullable because it arrived after the rows did. Null and `[]` mean the same thing here: no filter
|
||||
* shows it.
|
||||
*/
|
||||
tags: jsonb('tags'),
|
||||
|
||||
permissions: jsonb('permissions').notNull(),
|
||||
osDependencies: jsonb('os_dependencies'),
|
||||
/** `{ api, schema, sidecar, web }` — what the plugin ships. */
|
||||
|
||||
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
plugin: string | null;
|
||||
pluginConfig: Record<string, string> | null;
|
||||
watcherArmed: boolean;
|
||||
watcherTaskId: string | null;
|
||||
watcherArmedAt: Date | null;
|
||||
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
|
||||
referencePaths: commandHqSessions.referencePaths,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
plugin: commandHqSessions.plugin,
|
||||
pluginConfig: commandHqSessions.pluginConfig,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
||||
@@ -85,7 +89,16 @@ const SESSION_ROW_COLUMNS = {
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
};
|
||||
|
||||
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
|
||||
export type NewCommander = {
|
||||
userId: number;
|
||||
name: string;
|
||||
referencePath: string;
|
||||
categoryId?: number | null;
|
||||
/** The plugin whose template this was made from, or null for one somebody made themselves. */
|
||||
plugin?: string | null;
|
||||
/** The owner's answers to that template's inputs. Null when there was no template. */
|
||||
pluginConfig?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
|
||||
@@ -109,6 +122,8 @@ export async function createCommander(input: NewCommander): Promise<CommandHqSes
|
||||
referencePaths: [input.referencePath],
|
||||
name: input.name,
|
||||
categoryId: input.categoryId ?? null,
|
||||
plugin: input.plugin ?? null,
|
||||
pluginConfig: input.pluginConfig ?? null,
|
||||
})
|
||||
.returning({ id: commandHqSessions.id });
|
||||
const [row] = await tx
|
||||
@@ -155,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
|
||||
export async function listCommandHqSessions(
|
||||
userId: number,
|
||||
categoryId?: number | null,
|
||||
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
|
||||
plugin?: string,
|
||||
): Promise<CommandHqSessionRow[]> {
|
||||
const where =
|
||||
categoryId === undefined
|
||||
const where = plugin
|
||||
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
|
||||
: categoryId === undefined
|
||||
? eq(commandHqSessions.userId, userId)
|
||||
: categoryId === null
|
||||
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
||||
@@ -257,6 +275,33 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
|
||||
* longer the current one.
|
||||
*/
|
||||
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
|
||||
/*
|
||||
A commander made from a plugin's template cannot arm until it has been configured.
|
||||
|
||||
Enforced HERE rather than in a route because arming is not a button: it happens when the agent
|
||||
starts a tracked background job, and the platform notices. A check anywhere else would be a check
|
||||
the agent could route around simply by starting its loop, which is exactly what it does.
|
||||
|
||||
Creating a half-configured commander stays fine and useful — it exists, it is visible, and it can be
|
||||
finished later. It is ARMING one that would run somebody's disk against a blank setting.
|
||||
|
||||
A row with no template is unaffected: `plugin` is null and this returns immediately.
|
||||
*/
|
||||
const [existing] = await db
|
||||
.select({ plugin: commandHqSessions.plugin, pluginConfig: commandHqSessions.pluginConfig })
|
||||
.from(commandHqSessions)
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing?.plugin) {
|
||||
// Only the presence of answers is checked here, not which ones are required — that needs the
|
||||
// template, which is a filesystem read and does not belong in the query layer. The route that
|
||||
// creates the commander writes every input it was given, so an empty object means nothing was
|
||||
// configured at all, which is the case worth stopping.
|
||||
const answered = Object.values(existing.pluginConfig ?? {}).filter((v) => String(v ?? '').trim());
|
||||
if (answered.length === 0) return false;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
serial,
|
||||
text,
|
||||
@@ -117,10 +118,7 @@ export const commandHqSessions = pgTable(
|
||||
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
||||
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
||||
*/
|
||||
referencePaths: text('reference_paths')
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
referencePaths: text('reference_paths').array().notNull().default([]),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
@@ -137,6 +135,29 @@ export const commandHqSessions = pgTable(
|
||||
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. */
|
||||
watcherArmed: boolean('watcher_armed').notNull().default(false),
|
||||
|
||||
/**
|
||||
* The plugin that shipped this commander's template, or null for one somebody made themselves.
|
||||
*
|
||||
* A COLUMN rather than a reserved category name, for the reason tasks carry the same field:
|
||||
* provenance should be structural. A category called "av-club" is a string an owner can rename,
|
||||
* merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones —
|
||||
* with nothing on disk saying otherwise.
|
||||
*
|
||||
* It also survives the plugin being uninstalled, which is the point: the commander is still there,
|
||||
* still in its directory, and the UI can say what it came from rather than pretending it was
|
||||
* always hand-made.
|
||||
*/
|
||||
plugin: text('plugin'),
|
||||
|
||||
/**
|
||||
* The owner's answers to the template's `inputs`, or null when there was no template.
|
||||
*
|
||||
* Held here rather than only in the generated watcher script because the form has to be
|
||||
* re-openable: somebody changing which directory is watched should see what it is watching now,
|
||||
* not an empty form. The script is generated FROM this, so this is the source of truth.
|
||||
*/
|
||||
pluginConfig: jsonb('plugin_config').$type<Record<string, string>>(),
|
||||
/**
|
||||
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
|
||||
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export {
|
||||
listDavAppPasswords,
|
||||
createDavAppPassword,
|
||||
revokeDavAppPassword,
|
||||
deleteDavAppPassword,
|
||||
verifyDavAppPassword,
|
||||
} from './queries';
|
||||
|
||||
export type { DavAppPassword, DavAppPasswordView } from './queries';
|
||||
@@ -1,113 +0,0 @@
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import argon2 from 'argon2';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { db } from '../db';
|
||||
import { davAppPasswords } from './schema';
|
||||
|
||||
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
||||
|
||||
/** What the UI is allowed to see: everything except the hash. */
|
||||
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
|
||||
|
||||
const view = (row: DavAppPassword): DavAppPasswordView => {
|
||||
const { passwordHash: _hash, ...rest } = row;
|
||||
return rest;
|
||||
};
|
||||
|
||||
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
|
||||
// into a phone by hand. Grouped into blocks of four for the same reason.
|
||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
|
||||
function generateSecret(): string {
|
||||
const bytes = randomBytes(20);
|
||||
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
|
||||
// still fits in five readable blocks.
|
||||
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
|
||||
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
|
||||
}
|
||||
|
||||
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(eq(davAppPasswords.userId, userId))
|
||||
.orderBy(desc(davAppPasswords.createdAt));
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
|
||||
* value is the only chance the owner gets to see it.
|
||||
*/
|
||||
export async function createDavAppPassword(
|
||||
userId: number,
|
||||
label: string,
|
||||
): Promise<{ entry: DavAppPasswordView; password: string }> {
|
||||
const password = generateSecret();
|
||||
const [row] = await db
|
||||
.insert(davAppPasswords)
|
||||
.values({
|
||||
userId,
|
||||
label,
|
||||
passwordHash: await argon2.hash(password),
|
||||
hint: password.slice(0, 8),
|
||||
})
|
||||
.returning();
|
||||
if (!row) throw new Error('failed to create dav app password');
|
||||
return { entry: view(row), password };
|
||||
}
|
||||
|
||||
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.update(davAppPasswords)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.delete(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Basic-auth credential, returning the owning user id.
|
||||
*
|
||||
* Every live credential is tried, because the username a DAV client sends is the account email, not a
|
||||
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
|
||||
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
|
||||
* UI should encourage deleting devices that are gone rather than accumulating them.
|
||||
*
|
||||
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
|
||||
* the column exists to answer "is this device still around", not to be an access log.
|
||||
*/
|
||||
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
|
||||
|
||||
for (const row of rows) {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await argon2.verify(row.passwordHash, password);
|
||||
} catch {
|
||||
ok = false; // a corrupt hash must not take the whole login path down
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
const now = Date.now();
|
||||
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
|
||||
await db
|
||||
.update(davAppPasswords)
|
||||
.set({ lastUsedAt: new Date(now) })
|
||||
.where(eq(davAppPasswords.id, row.id));
|
||||
}
|
||||
return row.userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
|
||||
//
|
||||
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
|
||||
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
|
||||
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
|
||||
// credential a phone could carry is the account password itself — which then lives in that phone's
|
||||
// account manager in recoverable form, and in whatever backs the phone up.
|
||||
//
|
||||
// So: one password per device, revocable per device, hashed with argon2 like the account password and
|
||||
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
|
||||
//
|
||||
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
|
||||
// this table — an app password is not a session, and a device that syncs a calendar has no business
|
||||
// reaching the wallet or the vault.
|
||||
//
|
||||
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
|
||||
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
|
||||
// noticeable at all.
|
||||
export const davAppPasswords = pgTable(
|
||||
'dav_app_passwords',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
|
||||
label: text('label').notNull(),
|
||||
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
/**
|
||||
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
|
||||
* authenticate with, and it is the only way for the owner to match a row in this list against the
|
||||
* password saved on a device they are looking at.
|
||||
*/
|
||||
hint: text('hint').notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
|
||||
// being free and it being a sequential scan on every PROPFIND.
|
||||
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
|
||||
);
|
||||
@@ -52,7 +52,6 @@ export * from './user-data';
|
||||
|
||||
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||
|
||||
export * from './dav';
|
||||
export * from './notify';
|
||||
|
||||
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
|
||||
|
||||
@@ -51,9 +51,11 @@ export * from './service-connections/schema'; // service_connections
|
||||
export * from './notify/schema'; // push_devices — core since 2026-08-19, see docs/push-notifications.md
|
||||
export * from './nudges/schema'; // nudges — the Nudger schedule
|
||||
|
||||
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
|
||||
|
||||
export * from './dav/schema'; // dav_app_passwords officer-caldav
|
||||
// ── Plugins — nothing here ───────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `dav_app_passwords` was the last entry and it left with caldav on 2026-08-27. A plugin's tables are
|
||||
// generated into `drizzle-schema.ts` from the plugin's own `db/schema.ts` when it is installed, which is
|
||||
// what this hand-maintained line was standing in for while caldav was core.
|
||||
|
||||
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeAgentBrief } from './agent-brief';
|
||||
|
||||
/**
|
||||
* Both harnesses have to find the brief.
|
||||
*
|
||||
* Claude Code discovers `CLAUDE.md`, opencode discovers `AGENTS.md` and nothing else. Every brief the
|
||||
* platform wrote was `CLAUDE.md`, so under opencode a commander, a bot and a plugin chat were each
|
||||
* briefed with nothing — silently, which is why it lasted.
|
||||
*/
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'agent-brief-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const dir = (name: string) => {
|
||||
const d = join(root, name);
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
Bun.spawnSync(['mkdir', '-p', d]);
|
||||
return d;
|
||||
};
|
||||
|
||||
describe('writeAgentBrief', () => {
|
||||
test('writes the content as AGENTS.md and a POINTER as CLAUDE.md', async () => {
|
||||
const d = dir('both');
|
||||
await writeAgentBrief(d, '# Brief\n\nthe real instructions\n');
|
||||
|
||||
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('the real instructions');
|
||||
const pointer = readFileSync(join(d, 'CLAUDE.md'), 'utf8');
|
||||
expect(pointer).toContain('AGENTS.md');
|
||||
// The pointer must NOT be a second copy — two copies of a file rewritten every turn will disagree,
|
||||
// and the stale one is the one somebody reads.
|
||||
expect(pointer).not.toContain('the real instructions');
|
||||
});
|
||||
|
||||
test('is idempotent — an unchanged brief writes nothing', async () => {
|
||||
const d = dir('idempotent');
|
||||
expect(await writeAgentBrief(d, 'same\n')).toBe(true);
|
||||
expect(await writeAgentBrief(d, 'same\n')).toBe(false);
|
||||
});
|
||||
|
||||
test('rewrites when the content changes, because that is how a template reaches a bot', async () => {
|
||||
const d = dir('rewrite');
|
||||
await writeAgentBrief(d, 'v1\n');
|
||||
expect(await writeAgentBrief(d, 'v2\n')).toBe(true);
|
||||
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toBe('v2\n');
|
||||
});
|
||||
|
||||
test('onlyIfAbsent never touches a brief the agent has extended', async () => {
|
||||
// A commander writes its own job into this file over days. Rewriting it because the template
|
||||
// changed would delete what it agreed with the owner.
|
||||
const d = dir('extended');
|
||||
await writeAgentBrief(d, 'generated\n');
|
||||
writeFileSync(join(d, 'AGENTS.md'), 'generated\n\n## JOB\n\nwatch the thing\n');
|
||||
|
||||
expect(await writeAgentBrief(d, 'a new template\n', { onlyIfAbsent: true })).toBe(false);
|
||||
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('watch the thing');
|
||||
});
|
||||
|
||||
test('onlyIfAbsent still creates both files the first time', async () => {
|
||||
const d = dir('first');
|
||||
expect(await writeAgentBrief(d, 'generated\n', { onlyIfAbsent: true })).toBe(true);
|
||||
expect(existsSync(join(d, 'AGENTS.md'))).toBe(true);
|
||||
expect(existsSync(join(d, 'CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { writeAtomic } from './write-atomic';
|
||||
|
||||
/**
|
||||
* Writing a brief where BOTH harnesses will find it.
|
||||
*
|
||||
* ── The bug this exists to remove ──
|
||||
*
|
||||
* Claude Code discovers `CLAUDE.md` by walking up from its working directory. opencode discovers
|
||||
* `AGENTS.md` and nothing else — verified in the binary, whose only target is
|
||||
* `H.up({ targets: ["AGENTS.md"], start, stop })`. There is no overlap.
|
||||
*
|
||||
* Every brief the platform writes was named `CLAUDE.md`, so under opencode a commander got none of its
|
||||
* standing brief, a bot none of its arming instructions, and a plugin chat none of its plugin's
|
||||
* guidance. Nothing failed and nothing was logged: the agent simply knew nothing, which is
|
||||
* indistinguishable from the feature not existing and is why it survived this long.
|
||||
*
|
||||
* ── The shape, and why the pointer rather than two copies ──
|
||||
*
|
||||
* `AGENTS.md` holds the content; `CLAUDE.md` is six lines pointing at it. That is the same arrangement
|
||||
* the repository's own docs use, and it is chosen over writing the text twice for one reason: two copies
|
||||
* of a file that is REWRITTEN ON EVERY TURN will eventually disagree, and the one that is stale is the
|
||||
* one somebody reads.
|
||||
*
|
||||
* A harness that discovers neither name still gets nothing, and that is honest — it is a fact about the
|
||||
* harness rather than something this can paper over.
|
||||
*/
|
||||
|
||||
const POINTER = `# CLAUDE.md
|
||||
|
||||
**The instructions live in [\`AGENTS.md\`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
Written by the platform so that agents looking for \`AGENTS.md\` by convention and agents looking for
|
||||
\`CLAUDE.md\` find the same guidance, without the two drifting apart.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Put `content` in `dir` under both names.
|
||||
*
|
||||
* Content-diffed, so a directory's mtimes do not churn on every turn — these are rewritten per message
|
||||
* in the bots' case. Returns false when nothing was written.
|
||||
*
|
||||
* Best effort by contract: every caller must still work if this throws. An agent that knows less is
|
||||
* where all of them were before any of this existed.
|
||||
*/
|
||||
export async function writeAgentBrief(
|
||||
dir: string,
|
||||
content: string,
|
||||
/**
|
||||
* Never overwrite an existing `AGENTS.md`.
|
||||
*
|
||||
* For a brief the AGENT extends rather than one the platform owns. A commander's file is generated
|
||||
* once at creation and then edited by the commander over its whole life — rewriting it because the
|
||||
* template changed would silently delete the job it had agreed. The platform's own briefs (bots,
|
||||
* plugin chats) are the opposite: rewritten every turn ON PURPOSE, so a change reaches them.
|
||||
*/
|
||||
opts: { onlyIfAbsent?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
let wrote = false;
|
||||
|
||||
const agents = join(dir, 'AGENTS.md');
|
||||
if (opts.onlyIfAbsent && existsSync(agents)) return false;
|
||||
if (!existsSync(agents) || (await Bun.file(agents).text()) !== content) {
|
||||
await writeAtomic(agents, content);
|
||||
wrote = true;
|
||||
}
|
||||
|
||||
const claude = join(dir, 'CLAUDE.md');
|
||||
if (!existsSync(claude) || (await Bun.file(claude).text()) !== POINTER) {
|
||||
await writeAtomic(claude, POINTER);
|
||||
wrote = true;
|
||||
}
|
||||
|
||||
return wrote;
|
||||
}
|
||||
|
||||
/** The pointer's text, for a caller that generates its own file listing and has to describe both. */
|
||||
export const AGENT_BRIEF_POINTER = POINTER;
|
||||
@@ -0,0 +1,204 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { getBotsDir } from '@@/data-path';
|
||||
import { writeAgentBrief } from '@@/agent-brief';
|
||||
|
||||
/**
|
||||
* The instructions every bot runs under.
|
||||
*
|
||||
* Written into `$DATA_PATH/<email>/bots/CLAUDE.md`, which Claude Code reads from its working directory at
|
||||
* session start. Rewritten on every bot turn, so a change here reaches every bot on the next message with
|
||||
* nothing to migrate — the same arrangement as a plugin's chat brief.
|
||||
*
|
||||
* ── Why this file is short, and a commander's is not ──
|
||||
*
|
||||
* A commander is a job: it has procedures, a ledger, classification rules, a dispatch policy, and a setup
|
||||
* conversation to settle all of it. A bot is one sentence and a watch. Everything a commander's template
|
||||
* spends words on — proving the action on one item, arming only once the owner agrees, writing JOB.md —
|
||||
* is ceremony a bot does not have. The whole point is that the first message arms it.
|
||||
*/
|
||||
const BRIEF = `# Bots
|
||||
|
||||
Every conversation in this directory is a **bot**: one thing watched, one thing done when it changes.
|
||||
|
||||
**This file is the platform's and is rewritten on every turn.** Anything you want to keep goes in a file
|
||||
you create beside it.
|
||||
|
||||
## Arm on the first message. Do not interview.
|
||||
|
||||
The owner says what they want in one sentence. Work out what it means and **arm it in that same turn**.
|
||||
|
||||
> "notify me when x.com is down"
|
||||
> "tell the todo guy when nytimes.com has a new headline"
|
||||
|
||||
That is the whole input. Both are complete instructions and neither wants a conversation about it.
|
||||
|
||||
**Do not ask any of these.** Every one has a default below: how often, which selector, what counts as down,
|
||||
how many failures before it counts, what to do if the check itself errors, whether to notify on recovery.
|
||||
Pick the default, arm it, and say in ONE line what you armed and how often it checks. The owner corrects
|
||||
you in the next message if you guessed wrong, which is cheaper for them than answering six questions first.
|
||||
|
||||
**Ask only when you genuinely cannot proceed** — no address at all, or a request so vague that any watch
|
||||
you built would be a guess about the subject rather than about the details. "Watch my site" with no site
|
||||
is a question. "notify me when x.com is down" is not.
|
||||
|
||||
**Never ask the owner to phrase it differently.** They will not say "notify" or "nudge" and must not have
|
||||
to. "tell me", "let me know", "ping me", "message the todo guy" all mean the same two things below.
|
||||
|
||||
## Defaults — use these instead of asking
|
||||
|
||||
- **Interval: one minute.** Unless the owner says otherwise. It is cheap: the check is shell, not a turn.
|
||||
Use longer only when the source asks for it — a page that rate-limits, or something that changes daily.
|
||||
- **"Down" means** the site fails to answer, or answers 5xx, **twice in a row**. One failed request is a
|
||||
blip and firing on it produces a bot nobody trusts. Follow redirects. A slow answer is not down; a
|
||||
timeout of about ten seconds is.
|
||||
- **"Has a new headline" / "has changed" means** the thing you extracted differs from what you stored last
|
||||
time. Extract the smallest stable piece — the top headline's text, not the whole page, which changes on
|
||||
every ad and would fire constantly.
|
||||
- **Recovery is worth one message**, once, when something that was down comes back. Nothing else.
|
||||
- **The first run establishes the baseline** and does not fire. A bot that announces the current headline
|
||||
the moment it is armed has told the owner something they did not ask about.
|
||||
|
||||
## Arming — the whole mechanism, so you do not go looking for it
|
||||
|
||||
**Do not read the commander skill.** It is about spawning agents in a loop and none of it applies here.
|
||||
This is everything a bot needs:
|
||||
|
||||
1. **Write the check as a shell script in THIS directory**, named after the bot — \`newweb-uptime.sh\`, not
|
||||
somewhere under \`/tmp\`. It has to survive a reboot, and \`/tmp\` does not.
|
||||
2. **Run it with the Bash tool, \`run_in_background: true\`.** That is the arming. You will be handed back
|
||||
when it exits.
|
||||
3. **The script waits in the shell and exits when the condition is true**, printing one line saying what
|
||||
happened. The exit IS the signal — nothing else tells anyone it fired.
|
||||
|
||||
The shape, which almost every bot is a variation of:
|
||||
|
||||
\`\`\`bash
|
||||
#!/bin/bash
|
||||
# state lives beside this script, so a restart resumes rather than re-baselines
|
||||
STATE="$(dirname "$0")/newweb-uptime.state"
|
||||
fails=0
|
||||
while true; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 10 "https://example.com/" || echo 000)
|
||||
if [ "$code" = "000" ] || [ "$code" -ge 500 ]; then
|
||||
fails=$((fails + 1))
|
||||
# twice in a row, never once — a single failed request is a blip
|
||||
if [ "$fails" -ge 2 ]; then echo "DOWN http=$code"; exit 0; fi
|
||||
else
|
||||
fails=0
|
||||
fi
|
||||
sleep 60
|
||||
done
|
||||
\`\`\`
|
||||
|
||||
For a "has it changed" watch, the same shape with the comparison against \`$STATE\` instead of a counter:
|
||||
fetch, extract the smallest stable piece, compare, and \`exit 0\` printing the new value when it differs.
|
||||
Write the new value to \`$STATE\` **before** exiting, or the next arming fires on the same change again.
|
||||
|
||||
4. **Record what you armed, in \`<your-session-id>.bot.json\` in this directory.** This is not optional and
|
||||
it is not bookkeeping — it is the only thing that can stop your watch. Nothing else knows your pid, and
|
||||
a bot that is deleted while its record is missing leaves a process polling a website for ever.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"sessionId": "6305e570-f26a-4989-a0ae-2135c1076505",
|
||||
"watches": "newweb.fofosfluffybastards.com, every 60s, notify the owner when down",
|
||||
"script": "/abs/path/to/newweb-uptime.sh",
|
||||
"state": "/abs/path/to/newweb-uptime.state",
|
||||
"pids": [1316235],
|
||||
"armedAt": "2026-08-26T07:33:00Z"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Rewrite it every time you arm**, with the new pid. \`pids\` is an array because a re-arm can briefly
|
||||
overlap; list every process you started that is still meant to be running.
|
||||
|
||||
**To find your own session id**, list the bots and match on the title:
|
||||
|
||||
\`\`\`bash
|
||||
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
|
||||
"$OFFICER_API_URL/api/chat/sessions?context=bots"
|
||||
\`\`\`
|
||||
|
||||
Claude titles a session from its first message, so on the turn that arms you the entry titled with the
|
||||
owner's own opening sentence is you. That is only reliable BEFORE you rename yourself — which is the
|
||||
next step, so do them in this order: find your id, write the record, then rename.
|
||||
|
||||
5. **Give yourself a real name.** The automatic title is the owner's first sentence, truncated —
|
||||
\`"can you notifiy me when https://newweb.fofos…"\`, typos and all. It is a prompt, not a name, and it is
|
||||
what they will scan a list of bots for later.
|
||||
|
||||
\`\`\`bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
|
||||
-H 'content-type: application/json' -d '{"title":"newweb uptime"}' \\
|
||||
"$OFFICER_API_URL/api/chat/sessions/<your-session-id>/title"
|
||||
\`\`\`
|
||||
|
||||
Two to four words, the SUBJECT and what is watched about it: \`newweb uptime\`, \`NYT front page\`,
|
||||
\`gitea disk space\`. Not "Website Monitor Bot" — every bot is a monitor and the word earns nothing in a
|
||||
list of them. Lower case unless the subject is a proper noun. Rename again if the owner widens what you
|
||||
watch and the old name stops being true.
|
||||
|
||||
6. **When you are handed back**, do what the owner asked, then **re-arm the same script the same way,
|
||||
before you end the turn**, and rewrite the record. Act, re-arm, record — in the one turn. Never build a
|
||||
second loop on top of this.
|
||||
|
||||
7. **If you are woken and told your watch is gone**, read your own \`.bot.json\`, arm the script it names
|
||||
again, and rewrite it. That file is what survives a reboot; the conversation may be days behind.
|
||||
|
||||
Do not go hunting for how any of this works — it is above, and looking costs the owner a minute of tool
|
||||
calls before anything is armed.
|
||||
|
||||
## What to do when it fires
|
||||
|
||||
Two things, and the owner will not name either of them:
|
||||
|
||||
- **Tell the owner.** A notification reaches their phone. Send one when what fired is worth an interruption
|
||||
— a site down, a headline they asked to know about. Not "I checked and nothing happened".
|
||||
- **Tell another chat.** If they named one, deliver it there rather than to the owner.
|
||||
- An \`@Name [id]\` in their message carries the id already. It is a PREFIX — resolve it to the full
|
||||
session before using it.
|
||||
- A name in prose — "the todo guy", "my deploy commander" — is just as valid and is how they will
|
||||
usually say it. Look it up in the chat list and match on the title. If exactly one matches, use it and
|
||||
say which one you picked. If several do, that is worth the one question.
|
||||
|
||||
Then re-arm, and stay quiet until the next fire.
|
||||
|
||||
## Remembering things
|
||||
|
||||
The interesting watches compare against last time — a headline, a hash, a status. Keep that in a file in
|
||||
this directory named after this bot, and say in your first reply what you called it. Do not keep it only in
|
||||
the conversation: you are woken into a turn that may be days later, and the file is what survives.
|
||||
|
||||
## Adding to a bot
|
||||
|
||||
The owner will come back and add more — another site to ping, another page to watch. That is the same bot:
|
||||
widen the check, re-arm it, and confirm in one line what it now covers. Do not start a second watch running
|
||||
beside the first unless the two genuinely need different intervals.
|
||||
|
||||
## What a bot does not do
|
||||
|
||||
It watches and it tells. It does not fix things, deploy things, or change anything it is watching. A
|
||||
request that wants work done is a request for a commander or an ordinary chat — say so plainly and stop
|
||||
rather than quietly becoming one.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Put the brief where a bot will read it, and make sure the directory exists.
|
||||
*
|
||||
* Best effort by contract: a turn must still start if this fails. The bot then knows less, which is where
|
||||
* every chat was before this existed — worse than being briefed, better than not answering.
|
||||
*/
|
||||
export async function ensureBotsBrief(email: string): Promise<string> {
|
||||
const dir = getBotsDir(email);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Both names — a bot run under opencode was getting none of this. See `writeAgentBrief`.
|
||||
await writeAgentBrief(dir, BRIEF);
|
||||
} catch (err) {
|
||||
console.error('[bots] could not write the brief:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getBotsDir } from '@@/data-path';
|
||||
import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
/**
|
||||
* Stopping a bot, and knowing which ones have stopped themselves.
|
||||
*
|
||||
* ── Why a record exists at all ──
|
||||
*
|
||||
* A bot's watch is a detached shell process. Nothing else on the machine knows it belongs to that bot: not
|
||||
* the transcript, not the session list, not the process table, which sees an anonymous `bash` in a
|
||||
* temporary directory. So the bot writes `<sessionId>.bot.json` beside its script, and that file is the
|
||||
* only thing that can connect the two.
|
||||
*
|
||||
* The cost of not having it was paid before it existed: a bot was deleted on 2026-08-26 and its watcher
|
||||
* was still polling a website eleven minutes later, with nothing in the product able to find or stop it.
|
||||
*
|
||||
* ── Why the id is in the FILENAME ──
|
||||
*
|
||||
* Every bot shares one directory, so the record has to be addressable without reading all of them. Deleting
|
||||
* a session knows only its id; `join(dir, id + '.bot.json')` is then a lookup rather than a scan, and a
|
||||
* record whose contents are damaged still gets found and cleaned up.
|
||||
*/
|
||||
|
||||
export type BotRecord = {
|
||||
sessionId?: string;
|
||||
watches?: string;
|
||||
script?: string;
|
||||
state?: string;
|
||||
pids?: number[];
|
||||
armedAt?: string;
|
||||
};
|
||||
|
||||
const recordPath = (email: string, sessionId: string) => join(getBotsDir(email), `${sessionId}.bot.json`);
|
||||
|
||||
/** Refuse anything that is not a bare id — this reaches here from a URL. */
|
||||
const safeId = (sessionId: string) => /^[A-Za-z0-9._-]+$/.test(sessionId) && !sessionId.includes('..');
|
||||
|
||||
export function readBotRecord(email: string, sessionId: string): BotRecord | null {
|
||||
if (!safeId(sessionId)) return null;
|
||||
const path = recordPath(email, sessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as BotRecord;
|
||||
} catch {
|
||||
// Damaged rather than absent. The caller still deletes the file — a record it cannot read is a record
|
||||
// that can never stop anything, and leaving it would only make the next reader believe there is one.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const alive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Every recorded pid still running, which is how "is this bot actually watching" is answered. */
|
||||
export function liveBotPids(email: string, sessionId: string): number[] {
|
||||
const record = readBotRecord(email, sessionId);
|
||||
return (record?.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1 && alive(pid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a bot's watch and forget it.
|
||||
*
|
||||
* Children first, then the recorded process, so the loop cannot spawn a replacement between the two —
|
||||
* the same ordering `stopCommanderProcesses` uses and for the same reason.
|
||||
*
|
||||
* Best effort throughout: a bot whose record is missing, damaged, or names processes that are already
|
||||
* gone still has its files removed. Returns how many processes were signalled, which is worth logging —
|
||||
* "deleted a bot and stopped nothing" is a different event from "deleted a bot that was not armed".
|
||||
*/
|
||||
export function tearDownBot(email: string, sessionId: string): number {
|
||||
const record = readBotRecord(email, sessionId);
|
||||
if (!record) return 0;
|
||||
|
||||
const pids = (record.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1);
|
||||
const tree = [...new Set(pids.flatMap((pid) => [...descendantPids(pid), pid]))];
|
||||
|
||||
let signalled = 0;
|
||||
for (const pid of tree) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
signalled += 1;
|
||||
} catch {
|
||||
// Gone between the walk and the signal. That is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
|
||||
// The script and its state go with it. They are the bot's, they are named in its own record, and leaving
|
||||
// them means the next bot with the same idea finds a stale baseline and does not fire.
|
||||
for (const path of [record.script, record.state]) {
|
||||
if (!path) continue;
|
||||
// Only inside the bots directory. A record is written by an agent, and a path out of that tree is
|
||||
// either a mistake or something worse — either way it is not this function's business to delete.
|
||||
if (!path.startsWith(getBotsDir(email))) continue;
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
} catch {
|
||||
// Not worth failing a delete over.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(recordPath(email, sessionId), { force: true });
|
||||
} catch {
|
||||
// Same.
|
||||
}
|
||||
|
||||
return signalled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill anything running out of the bots directory that no live bot claims.
|
||||
*
|
||||
* ── Why a record is not enough on its own ──
|
||||
*
|
||||
* `tearDownBot` needs the bot to have written `<sessionId>.bot.json`. A bot that never did — armed before
|
||||
* the instruction existed, or one that simply did not — cannot be stopped that way, and deleting it leaves
|
||||
* a process polling for ever.
|
||||
*
|
||||
* That is not the worst of it. **The orphan resurrects the bot it belonged to.** Its watch fires, the
|
||||
* harness hands back to the agent, the agent resumes by transcript id, and `claude --resume` RECREATES the
|
||||
* transcript that was deleted — so the bot reappears in the list, untitled, and does it again on the next
|
||||
* fire. Seen on 2026-08-26: a bot deleted three times and back each time. It is the same shape
|
||||
* `22a9d58d` fixed for commanders.
|
||||
*
|
||||
* ── The test ──
|
||||
*
|
||||
* A process is an orphan if it is running a script inside the bots directory and its pid appears in NO
|
||||
* record. Claimed pids are left alone whatever else is true — a live bot's watch is the thing this must
|
||||
* never touch. Anything else in there belongs to a bot that is gone.
|
||||
*
|
||||
* Reads `/proc` rather than shelling out to `ps`: this runs inside the request that deletes a bot, and a
|
||||
* subprocess per delete is a cost with no benefit.
|
||||
*/
|
||||
export function sweepOrphanWatches(email: string): number {
|
||||
const dir = getBotsDir(email);
|
||||
const claimed = new Set(listBotRecords(email).flatMap(({ record }) => record.pids ?? []));
|
||||
|
||||
let killed = 0;
|
||||
let pids: string[];
|
||||
try {
|
||||
pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name));
|
||||
} catch {
|
||||
return 0; // not Linux, or /proc unreadable — the record path still works
|
||||
}
|
||||
|
||||
for (const name of pids) {
|
||||
const pid = Number(name);
|
||||
if (claimed.has(pid)) continue;
|
||||
|
||||
let cmdline: string;
|
||||
try {
|
||||
// NUL-separated argv. A process that exits between the listing and this read is the ordinary case.
|
||||
cmdline = readFileSync(`/proc/${name}/cmdline`, 'utf-8').replace(/\0/g, ' ');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The script has to live in the bots directory — the brief requires it — so its path is in the argv of
|
||||
// anything running it. `.sh` narrows it to the watch itself rather than an editor or a grep over it.
|
||||
if (!cmdline.includes(dir) || !cmdline.includes('.sh')) continue;
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
killed += 1;
|
||||
console.warn(`[bots] orphaned watch ${pid} killed — no record claims it`);
|
||||
} catch {
|
||||
// Gone already, which is the outcome wanted.
|
||||
}
|
||||
}
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
/** Every bot that has a record, for the sweep that revives them. */
|
||||
export function listBotRecords(email: string): { sessionId: string; record: BotRecord }[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(getBotsDir(email));
|
||||
} catch {
|
||||
return []; // no directory yet is the ordinary state of an account with no bots
|
||||
}
|
||||
|
||||
return names
|
||||
.filter((name) => name.endsWith('.bot.json'))
|
||||
.map((name) => {
|
||||
const sessionId = name.slice(0, -'.bot.json'.length);
|
||||
return { sessionId, record: readBotRecord(email, sessionId) ?? {} };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Context } from 'hono';
|
||||
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
|
||||
|
||||
// One answer to "which directory is this chat about", for the two routers that need it.
|
||||
//
|
||||
// It lived in chat.ts as a private helper until 2026-08-27, when Command HQ grew a button that starts a
|
||||
// commander from a plugin chat. That commander watches the directory the chat runs in — and the whole
|
||||
// reason this function exists is that the CLIENT cannot name that directory. Two copies of the mapping
|
||||
// would be two chances for a commander to end up watching somewhere its chat has never been.
|
||||
|
||||
/**
|
||||
* Which directory a listing is about.
|
||||
*
|
||||
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
|
||||
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
|
||||
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
|
||||
* its own directory and then has no way to ask for them back.
|
||||
*
|
||||
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
|
||||
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
|
||||
* another directory shows you your own conversations elsewhere, never anybody else's.
|
||||
*/
|
||||
/**
|
||||
* The same mapping, from values rather than from a request.
|
||||
*
|
||||
* Command HQ takes the context in a JSON BODY — it is creating a commander, not listing anything — and
|
||||
* the request-reading version silently fell through to `who.home` for it, because there was nothing in
|
||||
* the query string. A commander watching the wrong directory is not an error anywhere: it just answers
|
||||
* questions about the wrong thing forever.
|
||||
*/
|
||||
export const chatContextDir = (
|
||||
context: string | undefined,
|
||||
contextId: string | undefined,
|
||||
who: { home: string; email: string },
|
||||
): string => {
|
||||
const id = contextId?.trim();
|
||||
if (context === 'plugin' && id) return getPluginChatsDir(who.email, id);
|
||||
if (context === 'bots') return getBotsDir(who.email);
|
||||
if (context === 'email') return resolveEmailAccountDir(who.email, id);
|
||||
return who.home;
|
||||
};
|
||||
|
||||
export const resolveChatContextDir = (ctx: Context, who: { home: string; email: string }): string => {
|
||||
const context = ctx.req.query('context');
|
||||
const contextId = ctx.req.query('contextId')?.trim();
|
||||
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
|
||||
// Every bot shares one directory, so this needs no id — the context IS the address.
|
||||
if (context === 'bots') return getBotsDir(who.email);
|
||||
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
|
||||
// screen, and is optional: without one the resolver takes the most recently synced account.
|
||||
//
|
||||
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
|
||||
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
|
||||
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
|
||||
// the socket, so the turn and the list cannot disagree about where the chat lives.
|
||||
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
|
||||
return ctx.req.query('cwd')?.trim() || who.home;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getPluginChatsDir } from '@@/data-path';
|
||||
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
|
||||
import { tearDownBot, sweepOrphanWatches } from '../bots/teardown';
|
||||
import type { Context } from 'hono';
|
||||
import { emitToSession, sessionKeyForTranscript } from './websocket';
|
||||
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
@@ -81,7 +82,16 @@ export async function chatIdentity(user: { id: number; email: string }): Promise
|
||||
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
|
||||
// `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
|
||||
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
|
||||
//
|
||||
// ── A THIRD thing now rides on this restriction (2026-08-27) ──
|
||||
//
|
||||
// The serve holds an Officer API credential minted for the OWNER, in its own environment, because one
|
||||
// process serves every session and there is nowhere else to put one — `sidecar/opencode/serve-env.ts`.
|
||||
// So lifting `isOwner` here without changing that hands a member the owner's API authority as well as the
|
||||
// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it
|
||||
// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together.
|
||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
import { resolveChatContextDir } from './chat-context';
|
||||
|
||||
export const chatRouter = createRouter();
|
||||
|
||||
@@ -108,25 +118,9 @@ export const chatRouter = createRouter();
|
||||
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
|
||||
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
|
||||
// selects there too.
|
||||
/**
|
||||
* Which directory a listing is about.
|
||||
*
|
||||
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
|
||||
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
|
||||
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
|
||||
* its own directory and then has no way to ask for them back.
|
||||
*
|
||||
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
|
||||
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
|
||||
* another directory shows you your own conversations elsewhere, never anybody else's.
|
||||
*/
|
||||
const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
|
||||
const context = ctx.req.query('context');
|
||||
const contextId = ctx.req.query('contextId')?.trim();
|
||||
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
|
||||
return ctx.req.query('cwd')?.trim() || who.home;
|
||||
};
|
||||
// selects there too. The resolver moved to `chat-context.ts` when Command HQ needed the same answer —
|
||||
// a commander started from a plugin chat watches the directory that chat runs in, and only the server
|
||||
// can turn a context into a path.
|
||||
|
||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||
chatRouter.get('/pwds', async (ctx) => {
|
||||
@@ -139,7 +133,7 @@ chatRouter.get('/pwds', async (ctx) => {
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await chatIdentity(user);
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
|
||||
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
||||
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
||||
@@ -223,7 +217,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await chatIdentity(user);
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
||||
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
||||
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
|
||||
@@ -436,7 +430,7 @@ chatRouter.get('/live', async (ctx) => {
|
||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
if (isOpenCodeSessionId(id)) {
|
||||
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
|
||||
@@ -468,6 +462,32 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
conversation — no live session holds it, so nothing is keyed on a key to remove.
|
||||
*/
|
||||
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
|
||||
|
||||
/*
|
||||
And stop anything a BOT armed, which nothing above would have touched.
|
||||
|
||||
A bot's watch is a detached shell process. Killing the harness ends the agent; the loop it started is
|
||||
not its child and carries on. That gap was paid for on 2026-08-26: a bot was deleted and its watcher
|
||||
was still polling a website eleven minutes later, with nothing in the product able to find it.
|
||||
|
||||
Every transcript removed, not just the id asked for — `deleteClaudeSession` returns a chain, and a
|
||||
resumed conversation's earlier transcripts can each carry their own record.
|
||||
|
||||
Unconditional rather than gated on "was this a bot": the record only exists in the bots directory and
|
||||
is keyed by session id, so for anything else this reads a path that is not there and returns 0. A
|
||||
branch on the cwd would be a second place to get the question wrong.
|
||||
*/
|
||||
for (const transcriptId of removed) {
|
||||
const stopped = tearDownBot(who.email, transcriptId);
|
||||
if (stopped > 0) console.log(`[bots] ${transcriptId} deleted — stopped ${stopped} process(es)`);
|
||||
}
|
||||
|
||||
// And anything left running out of the bots directory that no remaining record claims. This is what
|
||||
// catches a bot that never wrote one — which cannot be stopped by id, and whose watch RESURRECTS it on
|
||||
// its next fire, because the hand-back resumes a transcript that `claude --resume` then recreates.
|
||||
const orphaned = sweepOrphanWatches(who.email);
|
||||
if (orphaned > 0) console.log(`[bots] swept ${orphaned} orphaned watch process(es)`);
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -475,7 +495,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
|
||||
@@ -78,31 +78,45 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
|
||||
const messages: ClaudeChatMessage[] = [];
|
||||
let modelId = '';
|
||||
|
||||
for (const m of stored) {
|
||||
const role = m.info?.role;
|
||||
const parts = m.parts ?? [];
|
||||
/*
|
||||
OLDEST FIRST. The endpoint returns newest first, and nothing downstream re-sorts — so the
|
||||
conversation rendered backwards: the answer above the question that produced it, and the opening
|
||||
message at the bottom. Sorting here rather than at the call site because this is the only place
|
||||
that knows the wire order, and the display shape it returns has no timestamps left to sort by.
|
||||
*/
|
||||
const ordered = [...stored].sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0));
|
||||
|
||||
for (const m of ordered) {
|
||||
// `type` is the current build, `info.role` the older one. See OpenCodeStoredMessage.
|
||||
const role = m.type ?? m.info?.role;
|
||||
const parts = m.content ?? m.parts ?? [];
|
||||
|
||||
if (role === 'user') {
|
||||
const text = parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
// The current build hands a user message's text over directly; the older one wraps it in parts.
|
||||
const text = (
|
||||
m.text ??
|
||||
parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
).trim();
|
||||
if (text) messages.push({ role: 'user', text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
if (!modelId) modelId = m.info?.model?.modelID ?? m.info?.modelID ?? '';
|
||||
if (!modelId) modelId = m.model?.id ?? m.model?.modelID ?? m.info?.model?.modelID ?? m.info?.modelID ?? '';
|
||||
for (const p of parts) {
|
||||
if (p.type === 'text' && p.text) {
|
||||
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: p.text });
|
||||
} else if (p.type === 'tool' && p.callID) {
|
||||
} else if (p.type === 'tool') {
|
||||
const callId = p.callID ?? p.id;
|
||||
if (!callId) continue;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
toolName: p.tool ?? 'tool',
|
||||
toolName: p.name ?? p.tool ?? 'tool',
|
||||
toolInput: p.state?.input ?? {},
|
||||
toolCallId: p.callID,
|
||||
toolCallId: callId,
|
||||
output: p.state?.output,
|
||||
isError: Boolean(p.state?.error),
|
||||
});
|
||||
@@ -112,6 +126,11 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
|
||||
}
|
||||
}
|
||||
|
||||
// The session record carries the model too, and it is the only source for a conversation whose
|
||||
// assistant turns have not landed yet. Without it the detail reports a bare `opencode`, the composer
|
||||
// cannot match that to anything, and reopening the chat silently switches the harness back to Claude.
|
||||
if (!modelId) modelId = info?.model?.id ?? info?.model?.modelID ?? '';
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
model: modelId ? `opencode/${modelId}` : 'opencode',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user