Compare commits
149
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f1760f27 | ||
|
|
a2102c5b46 | ||
|
|
a7b95a7bd1 | ||
|
|
9f2b703b4b | ||
|
|
9a3b5dd649 | ||
|
|
f9f4753425 | ||
|
|
8a12d5094b | ||
|
|
4adf96efa4 | ||
|
|
2ed47fa65e | ||
|
|
367006fb5d | ||
|
|
cb41a8fe16 | ||
|
|
0ef1bbc3c4 | ||
|
|
355fcaf1e5 | ||
|
|
42d9068f7b | ||
|
|
a7b61a07eb | ||
|
|
d3deca0d08 | ||
|
|
d0f83b58c9 | ||
|
|
5f803c3fb0 | ||
|
|
4efca226f6 | ||
|
|
4a6e4a773c | ||
|
|
ac25cd4860 | ||
|
|
e1f5d01b90 | ||
|
|
16da10150b | ||
|
|
007cc9a1f2 | ||
|
|
8e2670ff1d | ||
|
|
6b8cf848ff | ||
|
|
f56b9de0d6 | ||
|
|
c4cd42eb29 | ||
|
|
21bc460892 | ||
|
|
31151e7aa6 | ||
|
|
2b720cb2c2 | ||
|
|
c4dba8cb60 | ||
|
|
7d2b8b644d | ||
|
|
ffb4c3eedd | ||
|
|
538a623bb8 | ||
|
|
01cecdad60 | ||
|
|
fc474d3061 | ||
|
|
ea4b8a1e5f | ||
|
|
d1a41d6534 | ||
|
|
de0590c25b | ||
|
|
1072e17193 | ||
|
|
efe01e3019 | ||
|
|
ab79470356 | ||
|
|
a35df2afb2 | ||
|
|
141e470aee | ||
|
|
92fec50fcb | ||
|
|
4d2ee284ff | ||
|
|
a3fced25c4 | ||
|
|
27a8c29fe4 | ||
|
|
4564d6eb60 | ||
|
|
941259ce4f | ||
|
|
ed2a343676 | ||
|
|
02d44b93a4 | ||
|
|
22a9d58d5c | ||
|
|
f16aa87d8b | ||
|
|
7a651aee77 | ||
|
|
be56c0c7cd | ||
|
|
9bf31eb53c | ||
|
|
95dd9740da | ||
|
|
92841efac9 | ||
|
|
f07a1c8cc7 | ||
|
|
5a4fcd5a24 | ||
|
|
0096392cde | ||
|
|
b5c91a81c5 | ||
|
|
1b2a2d0a78 | ||
|
|
f4e983693e | ||
|
|
ee1cd006af | ||
|
|
70acccfe0c | ||
|
|
47db0383c7 | ||
|
|
33e8e70e06 | ||
|
|
e201bceafb | ||
|
|
d4b26be706 | ||
|
|
0fe02c84af | ||
|
|
27bd2ac768 | ||
|
|
e87323f71a | ||
|
|
20b270ef07 | ||
|
|
3e10c43bd5 | ||
|
|
6041379e98 | ||
|
|
facf1a5f9e | ||
|
|
af41144689 | ||
|
|
2c0e23d4fb | ||
|
|
68f4fe61fa | ||
|
|
58dbfc8313 | ||
|
|
d6d102111e | ||
|
|
db96f53bc2 | ||
|
|
03601db69c | ||
|
|
ddabf46bf3 | ||
|
|
887f702c05 | ||
|
|
9dfa83e607 | ||
|
|
420cd07507 | ||
|
|
fdf10f02f7 | ||
|
|
236572107b | ||
|
|
879edbdfb0 | ||
|
|
61c546ab79 | ||
|
|
fdd53b0dd9 | ||
|
|
a321babb28 | ||
|
|
955837b5bb | ||
|
|
a9dd264a72 | ||
|
|
46ae8051d4 | ||
|
|
7e50134200 | ||
|
|
dd6d0a8e12 | ||
|
|
5ee09a3fb6 | ||
|
|
0a242e15ba | ||
|
|
c6a92735d3 | ||
|
|
cb2dcfa4cc | ||
|
|
de633f8697 | ||
|
|
16d02ffee1 | ||
|
|
40a03d987f | ||
|
|
2d5e9835f5 | ||
|
|
478ddb13e9 | ||
|
|
00baa83a2c | ||
|
|
a3a8516e66 | ||
|
|
12a9605b01 | ||
|
|
e29a4ee380 | ||
|
|
247291eeb9 | ||
|
|
121ddb9964 | ||
|
|
6d673a1321 | ||
|
|
50e96e0d77 | ||
|
|
c427cb5b61 | ||
|
|
d74e07ef08 | ||
|
|
e0d80fd3ae | ||
|
|
a10757ba7c | ||
|
|
e35ff5c7ad | ||
|
|
9ddd23c388 | ||
|
|
c813eef6cc | ||
|
|
5dec221a68 | ||
|
|
e861eb55b0 | ||
|
|
2646842c32 | ||
|
|
b6d6de7e53 | ||
|
|
75d5a795a2 | ||
|
|
1600a57b66 | ||
|
|
2a0f688e10 | ||
|
|
24f3b4bdef | ||
|
|
3adc88963b | ||
|
|
6d06acc111 | ||
|
|
ba64bcd2e6 | ||
|
|
27d85f934c | ||
|
|
54f9f24726 | ||
|
|
7fc25ae53f | ||
|
|
8770f98dcd | ||
|
|
6572fa90f0 | ||
|
|
99149443b7 | ||
|
|
7370a422d1 | ||
|
|
0fc43ef6d0 | ||
|
|
5c6aea4ffc | ||
|
|
c38f02afa3 | ||
|
|
a6509f6456 | ||
|
|
9cd9d10d50 | ||
|
|
6cd99ddbc3 |
@@ -38,10 +38,15 @@ anything under `execution`.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
||||
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
||||
deliberate boundary.
|
||||
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||
|
||||
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||
the counts stay right and the sentences rot.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -207,7 +212,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 3 execution, 5 admin. 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.
|
||||
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`.
|
||||
@@ -372,6 +377,12 @@ another session's roles work, swept up mid-`git add`. The other session was righ
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
@@ -526,6 +537,23 @@ explaining why it was safe.
|
||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||
anything it already rules out
|
||||
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map
|
||||
over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here.
|
||||
Format and the two-way-sync contract: `todo/README.md`.
|
||||
Format and the two-way-sync contract: `todo/README.md`. Browse and edit it at `/todos` in the app.
|
||||
|
||||
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||
@@ -17,42 +17,48 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
## Urgent
|
||||
|
||||
- **due 2026-08-22** — **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
||||
- **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)
|
||||
- **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)
|
||||
|
||||
## Themes
|
||||
|
||||
**90 open, 25 closed**, across 29 themes.
|
||||
**100 open, 31 closed**, across 33 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 3 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 16 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
| 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 |
|
||||
|
||||
## Records — no items, kept for the reasoning
|
||||
|
||||
@@ -63,13 +69,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
|
||||
- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
|
||||
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
|
||||
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
|
||||
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
|
||||
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
|
||||
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
|
||||
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
|
||||
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
|
||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
||||
- `2026-08-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)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# 2026.08.23.05 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
|
||||
rollout) and `edge-pertento-bridge` (in the *Deliberately skipped* section of `docs/servers.md`).
|
||||
|
||||
Every target ended at `e15ab2303a17ed0e1d1cf59b35f34aec68875907` = `deploy/2026.08.23.05`, merged
|
||||
`--ff-only` onto the tag itself. No host was left on the branch, and no host is a commit past the tag.
|
||||
|
||||
Schema evidence is per host, by query, below. `--force` was never used and was never needed.
|
||||
|
||||
---
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout (from PM2 `pm_cwd`) | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphan users `0`, orphan grants `0`.
|
||||
|
||||
SQL applied, exit 0. Verification query:
|
||||
|
||||
```
|
||||
conname | pg_get_constraintdef
|
||||
-------------------------------+-------------------------------------------------------------
|
||||
ck_role_permissions_not_owner | CHECK ((role <> 'Super Admin'::text))
|
||||
fk_role_permissions_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||
fk_users_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||
```
|
||||
|
||||
Both `fk_` present, `ON UPDATE CASCADE`, no `ON DELETE`. `ck_role_permissions_role` absent.
|
||||
`ck_role_permissions_not_owner` present. Re-queried after `db:push` — unchanged.
|
||||
|
||||
`bun db:push` ran clean. **It is not a no-op on this host** — see "The one thing that is not clean" below.
|
||||
|
||||
`bun run plugins:update`: `12/12 updated in 47s`, exit 0. That step restarted twelve plugin sidecars —
|
||||
atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek,
|
||||
transmission, vault.
|
||||
|
||||
`pm2 restart officer`: restart_time 70 → 71, `online`. All 19 `officer*` processes `online` afterwards.
|
||||
|
||||
### `fofos` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | `scripts/chk.ts` — left alone, not a reason to skip |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query returned the same
|
||||
three rows as `alpha`: both `fk_` present with `ON UPDATE CASCADE`, `ck_role_permissions_role` absent,
|
||||
`ck_role_permissions_not_owner` present.
|
||||
|
||||
`bun db:push` clean; a second `push --verbose` said **`No changes detected`** — a genuine no-op.
|
||||
|
||||
`bun run plugins:update`: `1/1 updated in 7s`, exit 0. Restarted `officer-gitea`.
|
||||
|
||||
`pm2 restart officer`: 24 → 25, `online`. All 8 processes `online`.
|
||||
|
||||
### `production-pertento-bridge` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||
same verdict.
|
||||
|
||||
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||
|
||||
`bun run plugins:update`: `1/1 updated in 5s`, exit 0. Restarted `officer-gitea`.
|
||||
|
||||
`pm2 restart officer`: 3 → 4, `online`. All 15 processes `online` (this host also runs eight non-Officer
|
||||
PM2 entries — Crons/Dashboard/Tracking/Uptime — untouched and still `online`).
|
||||
|
||||
Cosmetic, pre-existing, not caused by this release: every `$SHELL -ic` command on this host prints an
|
||||
nvm warning about a `globalconfig`/`prefix` in `~/.npmrc`. Nothing failed because of it.
|
||||
|
||||
### `deadpool-rezio-bridge` — succeeded
|
||||
|
||||
Login `br`, per `docs/servers.md`.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/br/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||
same verdict.
|
||||
|
||||
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||
|
||||
`bun run plugins:update`: `2/2 updated in 7s`, exit 0. Restarted `officer-email` and `officer-offscale`.
|
||||
|
||||
`pm2 restart officer`: 9 → 10, `online`. All 9 processes `online`.
|
||||
|
||||
### `macbookpro` — succeeded, and the host the plan flagged is now verified
|
||||
|
||||
Awake and reachable. macOS, so the PATH and `psql` notes in `docs/servers.md` all applied.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/Users/pastilhas/projects/officer-suite/platform` |
|
||||
| HEAD before | `6e092c32` (`deploy/2026.08.23.04-48-g6e092c32`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
It started 48 commits *past* the previous tag rather than on it — the tag was still an ancestor, so the
|
||||
fast-forward was legitimate and no reset was needed.
|
||||
|
||||
**The roles table the plan could not verify before the tag is now verified:** `roles = 4`, orphan users
|
||||
`0`, orphan grants `0`. The plan was right that there is no `psql` here — there is also no Homebrew
|
||||
postgres and no postgres container. The query went through **Bun's own `SQL` client** (`bun 1.3.14`)
|
||||
against the same `OFFICERDEV_POSTGRES_URL` the platform's `drizzle.config.ts` reads, so it is that host's
|
||||
own database and not an inference from another one.
|
||||
|
||||
SQL applied through the same client, no exception raised. Verification query:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "conname": "ck_role_permissions_not_owner",
|
||||
"pg_get_constraintdef": "CHECK ((role <> 'Super Admin'::text))" },
|
||||
{ "conname": "fk_role_permissions_role",
|
||||
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" },
|
||||
{ "conname": "fk_users_role",
|
||||
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" }
|
||||
]
|
||||
```
|
||||
|
||||
Both `fk_` present with `ON UPDATE CASCADE` and no `ON DELETE`; `ck_role_permissions_role` absent;
|
||||
`ck_role_permissions_not_owner` present. Same verdict as the other four.
|
||||
|
||||
`bun db:push` ran clean. It is not a no-op here either — see below.
|
||||
|
||||
`bun run plugins:update`: exit 0, and its whole output was **`no plugins installed — nothing to update`**.
|
||||
No sidecar restarted. That is worth a second look and is covered below.
|
||||
|
||||
`pm2 restart officer`: 11 → 12, `online`. All 6 processes `online`.
|
||||
|
||||
---
|
||||
|
||||
## The one thing that is not clean
|
||||
|
||||
The plan says `bun db:push` should be a no-op after the SQL, and that anything it still wants to alter
|
||||
means the SQL was incomplete. On two hosts it still wants to alter something. **It is not the release's
|
||||
schema change, and the SQL was not incomplete** — the verification query above proves the constraints
|
||||
this release introduced are exactly right on all five hosts, and re-querying after `push` on `alpha`
|
||||
showed them unchanged.
|
||||
|
||||
What `push --verbose` still wants, after the SQL:
|
||||
|
||||
- **`alpha`** — drops and immediately re-adds `pk_music_now_playing` on `music_now_playing`, with an
|
||||
identical definition.
|
||||
- **`macbookpro`** — the same `music_now_playing` churn, plus two `atelier_collection_items` foreign
|
||||
keys dropped and re-added under names one character longer (`…_id_f` → `…_id_fk`).
|
||||
|
||||
Both are plugin tables, both are the documented drizzle-kit mis-diff on named composite constraints and
|
||||
on foreign keys whose generated name Postgres truncates at 63 characters. The `atelier` case is
|
||||
precisely the churn this release's notes claim to end "across the fleet's plugins" — and on `alpha`,
|
||||
where the plugins *did* update, the atelier churn is indeed gone and only the `music` one remains. On
|
||||
`macbookpro` the plugins did not update at all, so it still carries both.
|
||||
|
||||
`fofos`, `production-pertento-bridge` and `deadpool-rezio-bridge` all reported `No changes detected` —
|
||||
a true no-op, which is the outcome the plan expected.
|
||||
|
||||
I judged this not to be a stop condition and continued. The reasoning, so it can be overruled: the plan's
|
||||
"stop and report" is aimed at the roles/FK change being half-applied, that change is provably whole on
|
||||
every host, and the residue is a pre-existing self-cancelling churn on plugin tables that this release
|
||||
does not touch. If that call was wrong, the thing to re-examine is `music_now_playing`'s composite
|
||||
primary key — it is the one still churning on the host where plugins are current.
|
||||
|
||||
---
|
||||
|
||||
## What I did not do, and could not verify
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It is at `6cd99ddb`, one commit
|
||||
*past* the tag; that commit landed on `master` after the tag was cut and is a docs commit. Its schema
|
||||
change was already applied and verified before this rollout, per the plan. It is the only host not at
|
||||
the tag exactly, and restarting it is the owner's deliberate last step.
|
||||
- **`edge-pertento-bridge` — skipped, not a failure.** Listed in *Deliberately skipped* since
|
||||
2026-08-23. Nothing was spent on it: no SSH attempt, no retry.
|
||||
- **The five-language picker was NOT checked in a browser.** The plan's "what to check" asks for the
|
||||
profile settings picker to offer five languages and for a *panel* to be inspected in a non-English
|
||||
locale. That is a UI check and I did not perform it. What I can say is narrower: every host serves the
|
||||
tag's bundle and every host restarted `officer` afterwards, so the fix is present in the code each
|
||||
host is running. Whether it renders is unconfirmed.
|
||||
- **Minesweeper's absence was not checked.** `/minesweeper` was not requested on any host, and the dock
|
||||
was not inspected.
|
||||
- **`macbookpro` reports no plugins installed** while `gen-plugin-schemas` finds three schema files on
|
||||
disk (`atelier`, `kiosk`, `music`). So that host has plugin *schemas* without registered plugins. This
|
||||
predates the release and nothing in the plan covers it, so I left it alone — but it explains why the
|
||||
atelier constraint churn cleared on `alpha` and not here, and it means `macbookpro`'s plugins are not
|
||||
being updated by the deploy at all.
|
||||
- **`plugins:update` restarted sidecars on four hosts before the plan's own restart step**, as
|
||||
`docs/servers.md` warns. Counted per host above rather than left as "restarted: officer".
|
||||
|
||||
## One note for `docs/servers.md`
|
||||
|
||||
The database URL key is **not the same on every host.** `alpha` has only `POSTGRES_URL`; `fofos`,
|
||||
`production-pertento-bridge`, `deadpool-rezio-bridge` and `macbookpro` have only
|
||||
`OFFICERDEV_POSTGRES_URL`. `drizzle.config.ts` reads `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`, so both
|
||||
work for the app — but a rollout that queries the database directly must follow the same precedence or
|
||||
it verifies the wrong database and says "schema ✓" about it. This cost one failed step on `fofos` before
|
||||
being caught, and I re-checked `alpha` afterwards to confirm it holds only the one key and that its
|
||||
verification was therefore sound.
|
||||
|
||||
`macbookpro` also has no `timeout(1)`. A remote command wrapping anything in `timeout` fails there with
|
||||
`command not found` — and it fails *silently* in the sense that what you see is the next command's error,
|
||||
not the missing binary's.
|
||||
@@ -0,0 +1,146 @@
|
||||
# 2026.08.23.06
|
||||
|
||||
Swedish, a plugin's capabilities read where they live instead of being copied, dev tools as a property of a
|
||||
role, a commander as a directory rather than a session, and uploads that land atomically.
|
||||
|
||||
**There are TWO schema changes and `bun db:push` can apply NEITHER of them.** Both stop and ask a question
|
||||
no terminal is there to answer, and then exit 0. Read "The schema changes" before running anything.
|
||||
|
||||
**There is also a one-off cleanup on three hosts**, and skipping it means the agent reads one affordance
|
||||
twice. See "The orphan copies".
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Swedish.** `sv-SE`, complete across all three catalogues — six shipped locales now.
|
||||
- **Parity checks itself.** The three parity tests listed their locales by hand, so adding a sixth added a
|
||||
file none of them looked at; they read the directory now. They also check both directions — the old check
|
||||
only asked whether every English key was present, so a rename reported half of itself.
|
||||
- **A plugin's capabilities are LOADED, not copied.** Affordances, notifications and skills are read from
|
||||
each installed and enabled plugin's own directory. `capabilities/` is meant to be forked and was
|
||||
accumulating files nobody authored. A copy also went stale on update and survived a disable.
|
||||
- **Roles phase 3.** `rolePermitsDevTools()` was `role === 'Developer'` gating a Postgres role and a
|
||||
rootless Docker daemon. Two boolean columns carry it now, so a role created at runtime can hold dev tools.
|
||||
- **Installing a plugin grants it to every role.** Deny-by-default meant every install needed a second
|
||||
decision on another screen, which mostly got forgotten. The owner revokes instead. Rows, not a rule
|
||||
change — absence still denies.
|
||||
- **A commander is a directory, not a session.** Its own `$DATA_PATH/<email>/commanders/<id>/` with a
|
||||
`CLAUDE.md` written at creation. `commander-block.ts` is gone.
|
||||
- **Uploads land atomically** — written aside and renamed, so a watcher never sees a half-written file.
|
||||
Same for SendOff.
|
||||
- **Whisper priming prompts for 23 more languages.** Inert until an STT server is configured; none is.
|
||||
|
||||
## The schema changes
|
||||
|
||||
Two tables. **Run this SQL first**, then `bun db:push` to confirm it took.
|
||||
|
||||
**Why not `db:push`:** `roles` drops a column while adding two, and `command_hq_sessions` does the same
|
||||
shape — drizzle-kit cannot tell a drop-plus-add from a rename, so it asks. With no terminal it bails, the
|
||||
wrapper still exits 0, and the `postdb:push` seed then fails against an unchanged schema. Verified on
|
||||
`officer-dev` twice. See `src/databases/CLAUDE.md`.
|
||||
|
||||
Both blocks are idempotent and safe to run twice.
|
||||
|
||||
```sql
|
||||
-- ── roles: dev tools become a property of the role ──────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE used int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO used FROM roles WHERE provisioning_script IS NOT NULL;
|
||||
IF used > 0 THEN
|
||||
RAISE EXCEPTION 'STOP: % role(s) have a provisioning_script. Do not continue; report this.', used;
|
||||
END IF;
|
||||
EXCEPTION WHEN undefined_column THEN
|
||||
NULL; -- already dropped by a previous run
|
||||
END $$;
|
||||
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
|
||||
-- ── command_hq_sessions: the row owns its identity, the transcript id becomes a pointer ──────────
|
||||
-- Every existing commander is DELETED rather than migrated. This is deliberate and the owner's call:
|
||||
-- a commander's identity, directory and CLAUDE.md are all new, so migrating means writing a path that
|
||||
-- runs once and never again. Re-arm afterwards; it also exercises the flow this release exists to fix.
|
||||
DELETE FROM command_hq_sessions;
|
||||
|
||||
ALTER TABLE command_hq_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS dir_name text NOT NULL DEFAULT '';
|
||||
ALTER TABLE command_hq_sessions ALTER COLUMN dir_name DROP DEFAULT;
|
||||
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS reference_paths text[] NOT NULL DEFAULT '{}';
|
||||
```
|
||||
|
||||
**Tell the owner what you deleted** before running it, and put it in the report — a commander is somebody's
|
||||
standing job, and "we removed yours" is not a footnote. On `officer-dev` it was one, `Platform Deployer`,
|
||||
already deleted there.
|
||||
|
||||
Then verify **by query**, not by an exit code:
|
||||
|
||||
```sql
|
||||
SELECT name, is_system, grants_postgres, grants_docker FROM roles ORDER BY name;
|
||||
|
||||
SELECT column_name, is_nullable FROM information_schema.columns
|
||||
WHERE table_name = 'command_hq_sessions' ORDER BY column_name;
|
||||
```
|
||||
|
||||
**`Developer` must have both flags true and the other three both false.** If Developer comes back false the
|
||||
seed did not run — fix it before restarting, or Developer accounts lose their Postgres role and Docker
|
||||
daemon at their next provisioning. `provisioning_script` must be absent.
|
||||
|
||||
`command_hq_sessions` must show `dir_name` and `reference_paths`, with `session_id` nullable.
|
||||
|
||||
## The orphan copies
|
||||
|
||||
**`alpha`, `fofos` and `production-pertento-bridge` each have
|
||||
`capabilities/affordances/officerdev/gitea/`** — published by the mechanism this release removes. The gitea
|
||||
plugin still ships that affordance in its own directory, and affordances are deliberately NOT
|
||||
de-duplicated, so after this release both load and the agent reads the same guidance twice.
|
||||
|
||||
```bash
|
||||
cd <the capabilities directory beside the checkout>
|
||||
git status --porcelain # expect only ?? extensions/ afterwards
|
||||
rm -rf affordances/officerdev
|
||||
```
|
||||
|
||||
Safe because it is a copy: the source is `plugins/gitea/affordances/`, which is what now gets read.
|
||||
`deadpool-rezio-bridge` has none; check anyway.
|
||||
|
||||
**`extensions/` stays.** The platform's own bootstrap writes it every boot. This release gitignores it, and
|
||||
the ignore arrives with the capabilities pull in step 4.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.06`
|
||||
2. **The SQL above**, both blocks, then the two verification queries. Stop on the `RAISE EXCEPTION`.
|
||||
3. `bun db:push` — expect **no changes and no prompt**. If it asks anything, the SQL did not take.
|
||||
4. `bun run plugins:update` — this pulls the capabilities repository too, which is how the new
|
||||
`.gitignore` arrives.
|
||||
5. **The orphan removal**, on the three hosts that have one.
|
||||
6. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 6. Step 4 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It has
|
||||
`psql` now and a `.zshenv` that puts it on a non-interactive PATH, so the SQL can be run there directly.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**Open a chat and ask the agent what skills it has.** The preamble is built from a new code path —
|
||||
`capability-sources.ts` resolving roots per session — and the failure mode is an agent that quietly knows
|
||||
less rather than an error. It should name the commander, deploy and platform skills, plus anything a plugin
|
||||
ships on that host. On a host with the gitea plugin, its affordance should appear **once**.
|
||||
|
||||
**The language picker should offer six.** Switch to Swedish and look at a *panel*, not just the shell.
|
||||
|
||||
**Creating a commander should produce a directory.** `$DATA_PATH/<email>/commanders/<n>/` holding
|
||||
`CLAUDE.md` and `state/`. The setup pane asks for a *reference path* now, not a working directory.
|
||||
|
||||
No direct SQL beyond the block above is needed, and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped. Down, per `docs/servers.md`.
|
||||
- **`officer-dev`** — skip it. Both schema changes are already applied and verified by query there, its
|
||||
capabilities repository is clean, and restarting `officer` on the host running the rollout ends the
|
||||
rollout. Name it as skipped and say it is current.
|
||||
@@ -0,0 +1,131 @@
|
||||
# 2026.08.23.06 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
|
||||
rollout) and `edge-pertento-bridge` (listed under *Deliberately skipped* in `docs/servers.md`).
|
||||
|
||||
Every host went `e15ab230` → `2646842c` by `git merge --ff-only deploy/2026.08.23.06`. No host was
|
||||
ahead, none had diverged, and no tracked file was dirty anywhere. `--force` was never used and no SQL
|
||||
beyond the plan's block was run.
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — ✅
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
|
||||
- **SQL:** both blocks applied. `RAISE EXCEPTION` did not fire — no role held a `provisioning_script`.
|
||||
`DELETE FROM command_hq_sessions` removed **0 rows**; the table was already empty.
|
||||
- **Verified by query** on `officer_dev`: `Developer` `grants_postgres=t grants_docker=t`; `Admin`,
|
||||
`Member`, `Super Admin` all `f/f`; `provisioning_script` absent (0 columns); `dir_name` and
|
||||
`reference_paths` present `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt. Seed reported `0 seeded, 4 already present` — the Developer flags were
|
||||
written by the seed's re-assert path, confirmed by re-querying after the push, not by the message.
|
||||
- `bun run plugins:update` — **13/13 in 54s**, exit 0, `capabilities: pulled`. Every plugin's sidecar
|
||||
restarted as part of this step: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, minesweeper,
|
||||
music, offscale, photos, soulseek, transmission, vault.
|
||||
- **Orphan removed:** `capabilities/affordances/officerdev/` existed and was deleted. Capabilities tree
|
||||
clean afterwards — no leftover `?? extensions/`, so the new `.gitignore` had already arrived.
|
||||
- `pm2 restart officer` — restart_time 72 → 73. **All 20 processes `online`.**
|
||||
|
||||
### `fofos` — ✅
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote HTTPS, no tracked changes.
|
||||
- **Untracked file left in place:** `scripts/chk.ts`. Noted, not a reason to skip, not touched.
|
||||
- **Database is `officer`, not `officer_dev`, and the URL is in `OFFICERDEV_POSTGRES_URL`** rather than
|
||||
`POSTGRES_URL`. Matched the code's own precedence (`OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`,
|
||||
`officer_db/src/db.ts:9`) so the migration went to the database the server actually opens.
|
||||
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt, no plugin schemas on disk.
|
||||
- `bun run plugins:update` — **1/1 in 7s**, exit 0, `capabilities: pulled`. `officer-gitea` sidecar
|
||||
restarted by this step.
|
||||
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
|
||||
- `pm2 restart officer` — 25 → 26. **All 8 processes `online`.**
|
||||
|
||||
### `macbookpro` — ✅ (awake, with two deviations worth recording)
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
|
||||
- **SQL:** both blocks applied. `psql` is at `/opt/homebrew/opt/libpq/bin/psql` and on the
|
||||
non-interactive PATH, as the plan said. `DELETE` removed **0 rows**.
|
||||
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt, run with `/opt/homebrew/bin` **appended** to PATH.
|
||||
- `bun run plugins:update` — exit 0, but **`no plugins installed — nothing to update`**, so the step
|
||||
exited before its capabilities pull. See *What I could not do* below.
|
||||
- **No orphan to remove.** `~/projects/officer-suite/capabilities` is **not a git checkout** — it holds
|
||||
only empty `agents/ extensions/ processes/ skills/ tasks/ tools/` and has no `affordances/` at all.
|
||||
- `pm2 restart officer` (run as its own command, PATH untouched) — 12 → 13. **All 6 processes `online`.**
|
||||
|
||||
### `production-pertento-bridge` — ✅
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
|
||||
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||
- **Verified by query** on `officer_dev`: `Developer` `t/t`, other three `f/f`, `provisioning_script`
|
||||
absent, `dir_name` and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt. Its shell prints an nvm/`.npmrc` `globalconfig` warning before every
|
||||
command — cosmetic, pre-existing, unrelated to this release.
|
||||
- `bun run plugins:update` — **1/1 in 5s**, exit 0, `capabilities: pulled`. `officer-gitea` restarted.
|
||||
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
|
||||
- `pm2 restart officer` — 4 → 5. **All 15 processes `online`** (nine of them are this host's non-Officer
|
||||
Pertento jobs — Crons, Dashboard, Tracking, Uptime — none of which were touched).
|
||||
|
||||
### `deadpool-rezio-bridge` — ✅ (login `br`)
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
|
||||
- Database is `officer` via `OFFICERDEV_POSTGRES_URL`, same shape as `fofos`.
|
||||
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt, 2 plugin schemas (email, offscale).
|
||||
- `bun run plugins:update` — **2/2 in 8s**, exit 0, `capabilities: pulled`. The **email plugin advanced
|
||||
`c60e3d6` → `c1c3674`** — the only plugin anywhere in this rollout that was not already current.
|
||||
`officer-email` and `officer-offscale` sidecars restarted by this step.
|
||||
- **No orphan**, as the plan predicted; checked anyway and `affordances/officerdev` does not exist.
|
||||
- `pm2 restart officer` — 10 → 11. **All 9 processes `online`.**
|
||||
|
||||
## Skipped
|
||||
|
||||
- **`officer-dev`** — the host this rollout ran from. Nothing pulled, nothing restarted. Confirmed
|
||||
current rather than assumed: HEAD is `2646842c`, the tag's commit, and a direct query shows `Developer`
|
||||
`t/t` with the other three `f/f`, `provisioning_script` absent, `dir_name`/`reference_paths` present and
|
||||
`session_id` nullable. Its own `officer` process is untouched and still runs the pre-restart build of
|
||||
this commit — **the owner restarts it deliberately, as the last step.**
|
||||
- **`edge-pertento-bridge`** — in *Deliberately skipped* since 2026-08-23. Not contacted, no retry budget
|
||||
spent.
|
||||
|
||||
## Commanders deleted
|
||||
|
||||
**None, on any host.** `command_hq_sessions` held **0 rows everywhere** — every `DELETE` reported
|
||||
`DELETE 0`. Each host was counted by query *before* the delete ran, so this is measured rather than
|
||||
inferred. Nobody's standing job was removed by this release.
|
||||
|
||||
`officer-dev`'s one commander, `Platform Deployer`, had already been deleted there before the rollout
|
||||
began, per the plan.
|
||||
|
||||
## What I could not do
|
||||
|
||||
- **`macbookpro` did not get the capabilities pull.** `bun run plugins:update` exits early with `no
|
||||
plugins installed` and never reaches the capabilities repository, which the plan named as how the new
|
||||
`.gitignore` arrives. It does not matter on that host — its capabilities directory is not a git
|
||||
checkout, so there is no repository for a `.gitignore` to land in and no `affordances/` to be
|
||||
duplicated. I did not invent a `git pull` the plan had not authorised. **Nothing is outstanding on that
|
||||
host; the note is about the step's shape, not a missing change.**
|
||||
- **The release's own "what to check" list is unrun.** Asking an agent to name its skills, switching a
|
||||
panel to Swedish, and creating a commander to confirm it produces a directory are interactive checks in
|
||||
a browser. This deploy agent cannot make them, so **no host has been confirmed working by use** — only
|
||||
by schema query and process state.
|
||||
- **`officer-dev` needs `pm2 restart officer`** to run the code it already has checked out.
|
||||
|
||||
## Two things worth carrying forward
|
||||
|
||||
**`fofos` and `deadpool-rezio-bridge` name their database differently from the other three** — the URL
|
||||
lives in `OFFICERDEV_POSTGRES_URL` and the database is `officer`, not `officer_dev`. Any future plan step
|
||||
that reaches for `POSTGRES_URL` alone will connect to a socket that is not there, which is what it did
|
||||
here on the first attempt. The code's precedence is `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`; follow it
|
||||
rather than either name.
|
||||
|
||||
**The seed's own output does not prove the Developer flags were written.** It prints `0 seeded, 4 already
|
||||
present` on every host, because it counts inserts and the roles already existed — while the flags are
|
||||
re-asserted by a separate update path. On all five hosts the flags read `f/f` immediately after the SQL
|
||||
and `t/t` only after `db:push`. Nothing was wrong; the message is simply not the evidence, and the query
|
||||
after the push is.
|
||||
@@ -0,0 +1,43 @@
|
||||
# 2026.08.24.01
|
||||
|
||||
One frontend file: the transcribe button is hidden in the file viewer's audio player.
|
||||
|
||||
**No schema change.** Nothing under `src/databases/**/schema.ts` moved. `bun db:push` is still a step
|
||||
because it is cheap and it proves the database agrees with the code, but it must report no changes.
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **The transcribe button is hidden.** It calls `POST /file-browser/transcribe`, which refuses on every
|
||||
server here because no STT endpoint is configured anywhere — a control that can only fail, sitting beside
|
||||
OCR and extract-audio, which work. Hidden rather than deleted: speech-to-text is moving to a plugin and
|
||||
the wiring stays correct for when it lands.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.24.01`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: this release
|
||||
touched no schema, so a diff means that host and this tree disagree about something else.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
Open a `.m4a` in the file browser. The audio player's header should show a download and a close icon and
|
||||
**no transcribe icon**. An image should still offer OCR and a video should still offer extract audio — if
|
||||
either of those disappeared, too much was hidden.
|
||||
|
||||
A stale bundle is the likely failure. Hard refresh before deciding it did not work.
|
||||
|
||||
No direct SQL is needed and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`.
|
||||
- **`officer-dev`** — skip it. Restarting `officer` on the host running the rollout ends the rollout. Name
|
||||
it as skipped and say whether it is current.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026.08.24.01 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan, neither a failure.
|
||||
|
||||
Every host went `2646842c` → `c813eef6`, the tag commit, by fast-forward. No host was dirty in tracked
|
||||
files. No `bun install` was run — the plan forbade it and `bun.lock` was untouched. No direct SQL was run
|
||||
and none was authorised.
|
||||
|
||||
| Host | Before → after | db:push | plugins | `officer` | Result |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `2646842c` → `c813eef6` | applied 2 statements (see below) | 13/13 | online, 73 → 74 | ✅ |
|
||||
| `fofos` | `2646842c` → `c813eef6` | applied 2 statements | 1/1 (gitea) | online, 26 → 27 | ✅ |
|
||||
| `production-pertento-bridge` | `2646842c` → `c813eef6` | applied 2 statements | 1/1 (gitea) | online, 5 → 6 | ✅ |
|
||||
| `deadpool-rezio-bridge` | `2646842c` → `c813eef6` | applied 2 statements | 2/2 (email, offscale) | online, 11 → 12 | ✅ |
|
||||
| `macbookpro` | `2646842c` → `c813eef6` | applied 2 statements | none installed | online, 13 → 14 | ✅ |
|
||||
| `officer-dev` | `c813eef6` (already) | not run | not run | **not restarted** | ⏭ skipped — self |
|
||||
| `edge-pertento-bridge` | — | — | — | — | ⏭ skipped — `docs/servers.md` |
|
||||
|
||||
Every process on every host reported `online` after the restart: alpha 20/20, fofos 8/8,
|
||||
production-pertento-bridge 15/15, deadpool 9/9, macbookpro 6/6. No process came back `errored` and none
|
||||
failed to restart.
|
||||
|
||||
## The plan said `db:push` must report no changes. It did not, on any host.
|
||||
|
||||
This is a finding, and it is **not** what the plan's stop-condition describes, which is why the rollout
|
||||
continued.
|
||||
|
||||
`bun db:push` printed `Changes applied` on all five hosts. Run again in `--strict --verbose`, it wants the
|
||||
same two statements every time:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
|
||||
ALTER TABLE "music_now_playing" DROP CONSTRAINT "pk_music_now_playing";
|
||||
ALTER TABLE "music_now_playing" ADD CONSTRAINT "pk_music_now_playing" PRIMARY KEY("user_id","device");
|
||||
```
|
||||
|
||||
**The same diff appears on `officer-dev`, this release's own source host, whose database has been running
|
||||
this schema for days.** That is the decisive check: the plan's condition is "that host and this tree
|
||||
disagree about something else", and no host disagrees with the tree — the tree disagrees with itself,
|
||||
identically, everywhere. It is the drizzle-kit mis-diff already documented in `src/databases/CLAUDE.md`
|
||||
under "Composite keys", here on a named composite **primary** key rather than a unique constraint, plus a
|
||||
spurious array-default diff.
|
||||
|
||||
Verified by query per host rather than by exit code, as the doctrine requires:
|
||||
|
||||
- `alpha` — `pk_music_now_playing` = `PRIMARY KEY (user_id, device)`; `reference_paths` default
|
||||
`'{}'::text[]`. Both exactly what the schema code says.
|
||||
- `fofos`, `production-pertento-bridge`, `deadpool-rezio-bridge`, `macbookpro` — `reference_paths`
|
||||
default `'{}'::text[]`. `music_now_playing` **does not exist** on these four (`information_schema`
|
||||
count 0): the music plugin is not installed there, so there is nothing for that half of the diff to
|
||||
touch. Not a fault.
|
||||
|
||||
So no host's schema is wrong. What is wrong is that `db:push` cannot serve as the proof step this plan
|
||||
wanted it to be — it reports work on a database that already matches.
|
||||
|
||||
**What the owner should know:** on `alpha`, where the table exists, every deploy drops and re-adds a live
|
||||
table's primary key for no reason. Harmless so far and verified intact afterwards, but it is real DDL on
|
||||
real data, repeated each release. The fix belongs in the schema code, not in a rollout.
|
||||
|
||||
## Notes
|
||||
|
||||
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
|
||||
cannot touch it.
|
||||
- `bun run plugins:update` restarts each plugin's sidecar itself. On `alpha` that is 13 sidecars bouncing
|
||||
before the `officer` restart the plan asked for. Listed above as the plugin count; "restarted" is
|
||||
therefore more than `officer` alone on every host but `macbookpro`.
|
||||
- `officer-dev` is **current in tree** at `c813eef6` but its `officer` process was not restarted, so it is
|
||||
still serving the previous bundle. Restarting it is the owner's deliberate last step.
|
||||
|
||||
## Not verified by this deploy
|
||||
|
||||
The plan's visual check — open a `.m4a` and confirm the audio player shows no transcribe icon, while an
|
||||
image still offers OCR and a video still offers extract-audio. That needs a browser and a hard refresh,
|
||||
and no host was checked that way. The code is deployed; that it looks right is unconfirmed.
|
||||
@@ -0,0 +1,54 @@
|
||||
# 2026.08.24.02
|
||||
|
||||
Speech-to-text leaves the platform. It is a plugin now — `transcribe`, in the catalogue.
|
||||
|
||||
**No schema change.** Nothing under `src/databases/**/schema.ts` moved. `bun db:push` stays a step because
|
||||
it is cheap and proves the database agrees with the code, but it must report no changes.
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **`api/stt/` and `server-settings/stt.ts` are gone**, along with `POST /chat/stt`,
|
||||
`POST /file-browser/transcribe`, the FileViewer handler behind the button hidden last release, the
|
||||
Settings → Speech to Text screen and the orphaned locale keys in all six locales.
|
||||
- **The transcription logic moved byte for byte** into `plugins/transcribe` — the two-pass
|
||||
detect-then-transcribe, the 25 language priming prompts, the timeouts. Verified identical, not rewritten.
|
||||
- **`transcribe` is in the catalogue**, so it can be installed from `/plugins` on any host that wants it.
|
||||
|
||||
## Installing the plugin is NOT part of this release
|
||||
|
||||
This release only removes. A host that had speech-to-text configured loses it until somebody installs the
|
||||
plugin and points it at a server — and no host here had it configured, so nothing is lost anywhere.
|
||||
|
||||
**Do not install it as part of the rollout.** Which hosts get transcription is the owner's decision, made
|
||||
per host, and an install grants access to every role. Report that the catalogue entry is available; leave
|
||||
it there.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.24.02`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: this release
|
||||
touched no schema, so a diff means that host and this tree disagree about something else.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**Settings should no longer offer Speech to Text.** That screen is gone, and its absence is the visible
|
||||
change. If it is still there, the frontend bundle is stale — hard refresh before deciding it failed.
|
||||
|
||||
The audio player's header should still show download and close, and still no transcribe icon. Images
|
||||
should still offer OCR and videos extract audio; those stay in the platform for now.
|
||||
|
||||
No direct SQL is needed and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`.
|
||||
- **`officer-dev`** — skip it. Restarting `officer` on the host running the rollout ends the rollout. It
|
||||
already has this code and the plugin installed; name it as skipped and say so.
|
||||
@@ -0,0 +1,67 @@
|
||||
# 2026.08.24.02 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan, neither a failure.
|
||||
|
||||
Rolled out `deploy/2026.08.24.02` → `e0d80fd3`. Tag and `package.json` agreed. Every target moved
|
||||
`c813eef6` → `e0d80fd3` — one commit, no host ahead, no host behind.
|
||||
|
||||
Speech-to-text left the platform. Nothing was installed in its place: the `transcribe` plugin is in the
|
||||
catalogue and available, and per the plan no host was given it. That is the owner's decision, per host.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Before | After | db:push | plugins | officer restart | processes |
|
||||
| ---- | ------ | ----- | ------- | ------- | --------------- | --------- |
|
||||
| `alpha` | `c813eef6` | `e0d80fd3` | no changes | 13/13 | 74 → 75 | 20/20 online |
|
||||
| `fofos` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 27 → 28 | 8/8 online |
|
||||
| `production-pertento-bridge` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 6 → 7 | 15/15 online |
|
||||
| `deadpool-rezio-bridge` | `c813eef6` | `e0d80fd3` | no changes | 2/2 | 12 → 13 | 9/9 online |
|
||||
| `macbookpro` | `c813eef6` | `e0d80fd3` | no changes | none installed | 14 → 15 | 6/6 online |
|
||||
|
||||
Every host ran the plan's four steps in order, all four exited 0, and none was stopped early. Checkout
|
||||
paths were taken from each `officer` process's `pm_cwd`, not assumed — `alpha` resolved to
|
||||
`~/officerdev/platform`, not its stale second checkout. `deadpool-rezio-bridge` was reached as `br`.
|
||||
`macbookpro` was awake; its `bun` commands ran with `/opt/homebrew/bin` appended to PATH and `pm2` as a
|
||||
separate command.
|
||||
|
||||
## Schema — verified, not assumed
|
||||
|
||||
The plan says no schema changed and `bun db:push` must report no changes. **It reported none on all five
|
||||
hosts**, and the evidence is that it emitted no DDL: no `ALTER`, no `CREATE`, no statement of any kind
|
||||
between reading the config and finishing. `[✓] Changes applied` alone was not treated as evidence —
|
||||
`drizzle-kit push` prints that line whether or not it did anything, and it can exit 0 on a half-applied
|
||||
change.
|
||||
|
||||
`stdin` was `/dev/null` on every host, so no prompt could be answered and none was. No direct SQL was
|
||||
needed, none was authorised, and none was run. `--force` was not used anywhere.
|
||||
|
||||
The seed steps that follow `push` were no-ops everywhere, which is the same answer from a second angle:
|
||||
`0 seeded, 4 already present` for roles on all five, and every transcript already recorded.
|
||||
|
||||
## Skipped
|
||||
|
||||
- **`officer-dev`** — the host this rollout ran on. Not pulled, nothing restarted. It already carries
|
||||
`e0d80fd3` and has the `transcribe` plugin installed. Restarting `officer` here ends the rollout
|
||||
mid-flight, so the whole host is left alone deliberately. **The owner restarts it, as the last step,
|
||||
once happy with the rest.**
|
||||
- **`edge-pertento-bridge`** — deliberately skipped per `docs/servers.md`, down since 2026-08-23 by the
|
||||
owner's call. Not contacted at all, so it cost nothing this time.
|
||||
|
||||
## Notes — nothing here needs action
|
||||
|
||||
- **`plugins:update` bounced far more than `officer`.** Sequential per plugin, each printing
|
||||
`sidecar: started`: 13 sidecars on `alpha`, 2 on `deadpool`, 1 each on `fofos` and
|
||||
`production-pertento-bridge`. A report listing only `officer` under "restarted" would understate what
|
||||
moved. All came back online.
|
||||
- **`fofos` has one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could never
|
||||
touch it, and nobody's work was discarded to ship this.
|
||||
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell: `.npmrc` carries a
|
||||
`globalconfig`/`prefix` setting incompatible with nvm. Cosmetic, pre-existing, unrelated to this
|
||||
release, and it did not affect any step. Not fixed — that is not a rollout's job.
|
||||
|
||||
## What was not verified
|
||||
|
||||
The plan's visible check — that Settings no longer offers Speech to Text — is a **browser** check, and
|
||||
this rollout has no browser. The code carrying that screen is gone from every host's tree, which is
|
||||
verified; that the rendered UI reflects it is not, and a stale JS bundle would look identical to a failed
|
||||
deploy. Hard refresh before concluding anything from it.
|
||||
@@ -0,0 +1,124 @@
|
||||
# 2026.08.25.01
|
||||
|
||||
Commanders become usable, agents get a credential that can be bounded, and a nudge finally arrives.
|
||||
|
||||
96 commits — the largest release so far, and most of it is one night's work on commanders driven by
|
||||
watching real setup runs.
|
||||
|
||||
**One schema change**, additive and safe. **No dependency change** — `bun.lock` is untouched.
|
||||
**Do not run `bun install`.**
|
||||
|
||||
**`edge-pertento-bridge` is NOT a target for this release.** It needs a schema step no other host does.
|
||||
See "Edge is excluded, and why" — this is the important part of this plan.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Commanders.** The template a new commander is created with was rewritten across the night: it asks
|
||||
one question per turn in the owner's own language, proves its action on a single item before arming,
|
||||
writes its own `CLASSIFICATION.md` and `DISPATCHING.md` rather than editing the platform's file, and
|
||||
records per step in its ledger. The Stop control works, a deleted commander stays deleted, and its
|
||||
children no longer appear in `/chat`.
|
||||
- **Every agent session gets a key, scoped to `notify`.** A chat can send you a notification through
|
||||
the platform instead of having no sanctioned way to reach you. It is minted per session and revoked
|
||||
with it.
|
||||
- **Credential narrowing** — `api_keys.permissions` (an allow list) and `api_keys.excluded_kinds`
|
||||
(resolved against the live registry). Applied **before** the owner bypass, which makes it the only
|
||||
thing that can bound a Super Admin — the case that matters, because a commander holds the owner's
|
||||
identity unattended for months. This is the schema change.
|
||||
- **`notify` moved from `app` to `core`.** It was grantable, nothing granted it, and every Member's app
|
||||
was silently 403'd registering its own push token. On the bridges that is four accounts.
|
||||
- **The loopback notify door is closed.** `/_officer/` paths now require a producer secret the sidecar
|
||||
issues at registration. It previously accepted any caller that set a header.
|
||||
- **Sidecar proxy timeouts are OFF.** `TIMEOUTS_ON = false` in `create-proxy.ts`, one line to reverse.
|
||||
Bun's `server.timeout()` stores one byte, so declaring 1800 seconds gave a _shorter_ timeout than
|
||||
declaring nothing. More permissive than what these hosts run today — a long transcription or a film
|
||||
no longer gets cut.
|
||||
- **A nudge always arrives.** Three refusals removed from the nudger. Read that commit before judging
|
||||
it: the old guards meant no nudge could ever fire on a machine running commanders, silently.
|
||||
- **Plugin tasks appear in the file browser**, and a direct task run is direct — no modal, no job row.
|
||||
- **`/todos`**, a kanban board over the repo's own `todo/` tree, owner-only.
|
||||
- **Two editor fixes** — one buffer per file across panels, and the model picker reading the right id.
|
||||
|
||||
## The schema change
|
||||
|
||||
Two nullable columns on `api_keys`:
|
||||
|
||||
```
|
||||
permissions text[] null
|
||||
excluded_kinds text[] null
|
||||
```
|
||||
|
||||
Both null means unnarrowed, which is what every existing key is — including the mobile apps'. **No
|
||||
existing credential changes behaviour.** Nothing is dropped and nothing is rewritten.
|
||||
|
||||
`bun db:push` applies it. **Do not take the exit code as the answer** — drizzle-kit can print an error
|
||||
and still exit 0. Verify on each host, against that host's own database:
|
||||
|
||||
```sql
|
||||
select column_name from information_schema.columns
|
||||
where table_name = 'api_keys' and column_name in ('permissions','excluded_kinds');
|
||||
```
|
||||
|
||||
Two rows, or the host is not done. `permissions` alone is a half-applied push — stop and report it.
|
||||
|
||||
## Every host except edge
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.25.01`
|
||||
2. `bun db:push` — then **run the query above**. Two rows.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It is
|
||||
a laptop and may be asleep — unreachable is a skip with a note, not a failure.
|
||||
|
||||
## Edge is excluded, and why
|
||||
|
||||
`edge-pertento-bridge` came back on 2026-08-24 at `deploy/2026.08.22.02`, **288 commits behind**. It is
|
||||
a deploy target again and its row is in `docs/servers.md`, but it must not be rolled out with the rest,
|
||||
and the reason was verified on the host rather than inferred:
|
||||
|
||||
- **It has no `roles` table.** `to_regclass('roles')` is null.
|
||||
- **Its two accounts hold `Super Admin` and `Developer`.**
|
||||
|
||||
Current schema makes `users.role` a foreign key onto `roles.name`. Every other host got `roles` created
|
||||
and seeded by `2026.08.23.04`, then the foreign key added by `2026.08.23.05` — two releases, in order,
|
||||
against an already-populated table. Edge would do both in one `db:push`, and the seeder runs from
|
||||
`postdb:push`, which is **after** the push completes. So the constraint would be created against an
|
||||
empty `roles` while `users.role` holds two values that are not in it.
|
||||
|
||||
That fails. Worse, it can fail and still exit 0, leaving the schema half-applied — which is exactly the
|
||||
warning `2026.08.23.05` carried.
|
||||
|
||||
**It also missed two plans with one-off steps** that a single fast-forward does not replay:
|
||||
`2026.08.23.05`'s pre-flight SQL, and `2026.08.23.06`'s orphan-affordance cleanup.
|
||||
|
||||
So: **name edge as skipped, do not attempt it, and do not report it as a failure.** Bringing it forward
|
||||
is a watched, by-hand operation — push, seed, push again, verify the constraint — and it is being done
|
||||
separately by the owner and this session, immediately after this rollout.
|
||||
|
||||
**One more thing about edge, for whoever reaches it later:** from `officer-dev` the name
|
||||
`edge-pertento-bridge` does not resolve. It answers on **`100.64.0.15`**. `docs/servers.md` has the
|
||||
address; use it.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**A commander can be created and asks its first question in your language.** That is the release, and
|
||||
it is the thing most likely to reveal a stale bundle — hard refresh before deciding anything failed.
|
||||
|
||||
**A Member's phone can register for notifications.** `notify` is core now, so an account that was
|
||||
403'ing should simply work.
|
||||
|
||||
**Nothing else should have changed for an ordinary signed-in owner.** Every narrowing column is null on
|
||||
every existing key.
|
||||
|
||||
No direct SQL is needed on any host in this release, and none is authorised. The query in "The schema
|
||||
change" is a read.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It is already on this code.
|
||||
- **`edge-pertento-bridge`** — skipped, see above. Not a failure.
|
||||
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||
- **`macbookpro`** — a target if it is awake.
|
||||
@@ -0,0 +1,131 @@
|
||||
# 2026.08.25.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 `007cc9a1` = `deploy/2026.08.25.01` exactly.
|
||||
|
||||
The schema change was verified **by query on each host's own database**, not by exit code. All five
|
||||
returned both columns.
|
||||
|
||||
First release under a new watcher. The claim was taken by the watcher before this agent was spawned;
|
||||
this agent recorded `done` after the rollout.
|
||||
|
||||
## The schema change
|
||||
|
||||
Two nullable columns on `api_keys`. The plan's query, run per host:
|
||||
|
||||
```sql
|
||||
select column_name from information_schema.columns
|
||||
where table_name = 'api_keys' and column_name in ('permissions','excluded_kinds');
|
||||
```
|
||||
|
||||
| Host | `permissions` | `excluded_kinds` | Result |
|
||||
| ---------------------------- | ------------- | ---------------- | ---------------- |
|
||||
| `alpha` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `fofos` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `production-pertento-bridge` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `deadpool-rezio-bridge` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `macbookpro` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
|
||||
No half-applied push anywhere — no host returned `permissions` alone.
|
||||
|
||||
`bun db:push` exited 0 on all five and printed `Changes applied`. That was treated as not-evidence, per
|
||||
the plan; the table above is the evidence. `postdb:push` seeders ran clean everywhere (`seed:roles`
|
||||
0 seeded / 4 already present on all five).
|
||||
|
||||
**No direct SQL was run on any host.** None was authorised and none was needed.
|
||||
**No `bun install` was run on any host**, per the plan — `bun.lock` is untouched by this release.
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — success
|
||||
|
||||
- HEAD `23657210` (`deploy/2026.08.24.02-29-g23657210`) → `007cc9a1` (`deploy/2026.08.25.01`)
|
||||
- Tree clean of tracked changes. No untracked files.
|
||||
- Path `/home/pastilhas/officerdev/platform`, taken from PM2 `pm_cwd` — **not** the stale second
|
||||
checkout at `~/dockers/officer.dev/platform`.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — **14/14 updated in 57s**, exit 0. Fourteen plugin sidecars restarted
|
||||
themselves during this step (atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, minesweeper,
|
||||
music, offscale, photos, soulseek, transcribe, transmission, vault).
|
||||
- `pm2 restart officer` — `restart_time` 2 → 3, `online`.
|
||||
- Health after: **21/21 processes online**, none errored.
|
||||
|
||||
### `fofos` — success
|
||||
|
||||
- HEAD `e0d80fd3` (`deploy/2026.08.24.02`) → `007cc9a1`
|
||||
- Tree clean of **tracked** changes. One untracked file present: `scripts/chk.ts`. Noted, not touched,
|
||||
not a reason to skip — a fast-forward could not have affected it.
|
||||
- Remote is HTTPS here, as documented. Pulled in place.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — 1/1 (gitea), exit 0; its sidecar restarted itself.
|
||||
- `pm2 restart officer` — `restart_time` 28 → 29, `online`.
|
||||
- Health after: **10/10 processes online**, none errored.
|
||||
- The August root-ownership problem did not recur: git and the plugin step both ran clean.
|
||||
|
||||
### `production-pertento-bridge` — success
|
||||
|
||||
- HEAD `e0d80fd3` (`deploy/2026.08.24.02`) → `007cc9a1`
|
||||
- Tree clean, no untracked files.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — 1/1 (gitea), exit 0.
|
||||
- `pm2 restart officer` — `restart_time` 7 → 8, `online`.
|
||||
- Health after: **15/15 processes online**, none errored.
|
||||
- Cosmetic only: the interactive shell prints an nvm warning about a `globalconfig`/`prefix` in
|
||||
`~/.npmrc`. It did not affect any step. Not fixed — outside a rollout's scope.
|
||||
|
||||
### `deadpool-rezio-bridge` — success
|
||||
|
||||
- Logged in as **`br`**, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
|
||||
- HEAD `e0d80fd3` (`deploy/2026.08.24.02`) → `007cc9a1`
|
||||
- Tree clean, no untracked files.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — 2/2 (email, offscale), exit 0; both sidecars restarted themselves.
|
||||
- `pm2 restart officer` — `restart_time` 13 → 14, `online`.
|
||||
- Health after: **9/9 processes online**, none errored.
|
||||
|
||||
### `macbookpro` — success (it was awake)
|
||||
|
||||
- Awake and reachable, so it was a target rather than a skip.
|
||||
- HEAD `8e2670ff` (`deploy/2026.08.24.02-96-g8e2670ff`, one commit short of the tag) → `007cc9a1`
|
||||
- Tree clean, no untracked files.
|
||||
- `/opt/homebrew/bin` **appended** to PATH for the `bun` commands; `pm2` run as a separate command
|
||||
through `$SHELL -ic`, per `docs/servers.md`. Both worked.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — `no plugins installed — nothing to update`, exit 0. Nothing restarted here
|
||||
by that step.
|
||||
- `pm2 restart officer` — `restart_time` 15 → 16, `online`.
|
||||
- Health after: **6/6 processes online**, none errored.
|
||||
|
||||
## Skipped
|
||||
|
||||
### `officer-dev` — skipped, and deliberately
|
||||
|
||||
The host this rollout ran on. Not pulled, not restarted, nothing touched. Restarting `officer` here
|
||||
would have ended the rollout mid-flight.
|
||||
|
||||
Its checkout is **ahead** of the tag: HEAD `e1f5d01b`, two commits past `007cc9a1`, with uncommitted
|
||||
work belonging to another session (`src/servers/hono.ts`, `src/workspaces/hooks/src/useClient.ts`, and
|
||||
an untracked `src/servers/hono-error-shape.test.ts`). It therefore already holds this release's code and
|
||||
more. **The owner restarts it deliberately when they are happy with the rest** — that is the one
|
||||
outstanding action from this deploy.
|
||||
|
||||
### `edge-pertento-bridge` — skipped by the plan. NOT a failure.
|
||||
|
||||
Excluded by name in `deploy/2026.08.25.01.md`, which is the authority here. It has no `roles` table
|
||||
while both its accounts hold roles, so a single `db:push` would create the foreign key against an empty
|
||||
`roles` and fail — possibly while still exiting 0. It also missed two releases carrying one-off steps
|
||||
that a fast-forward does not replay.
|
||||
|
||||
Not contacted at all. No time spent on it. Bringing it forward is a watched, by-hand operation the owner
|
||||
is doing separately.
|
||||
|
||||
## What did not get done
|
||||
|
||||
- `officer-dev` was not restarted — by design, see above. It is the only host not running a
|
||||
freshly-started `officer` process, though it is on newer code than the tag.
|
||||
- No verification of the release's *behaviour* was attempted: whether a commander asks its first
|
||||
question in the owner's language, and whether a Member's phone can now register for notifications,
|
||||
are the plan's "what to check" items and they need a human with a browser. Hard-refresh before
|
||||
judging either — a stale bundle looks exactly like a failure.
|
||||
- Documentation was not checked or edited. `bun run docs:check` is the release-cutter's step, not this
|
||||
agent's.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 2026.08.26.01
|
||||
|
||||
A plugin can own a chat — its own directory, its own brief, its own history — and a dead commander watcher
|
||||
wakes itself up.
|
||||
|
||||
22 commits. **No schema change** and **no dependency change**: nothing under `src/databases/**/schema.ts`
|
||||
moved and `bun.lock` is untouched. **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 to the platform's own tables.** A host with the `proxy-manager` plugin installed will see
|
||||
that plugin's three tables created — that is the plugin, not this release, and it is expected.
|
||||
|
||||
## Why this one matters more than its size suggests
|
||||
|
||||
**`officerdev` gained an export that a shipped plugin already imports.** The `proxy-manager` plugin in the
|
||||
catalogue imports `ChatHeader` from `officerdev`, which exists only from `2ed47fa6` — after the last
|
||||
release. On a host running `2026.08.25.01` the plugin installs, its frontend rebuild fails with
|
||||
`No matching export … for import "ChatHeader"`, and the SPA silently keeps its previous bundle: the plugin
|
||||
appears installed and its route redirects to home.
|
||||
|
||||
That is exactly what happened on `fofos` tonight. **So this release is what makes that plugin installable
|
||||
anywhere**, and any host that has already installed it is in the broken state until this lands.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **A plugin can declare a chat context.** `ChatPanelWrapper` takes `chatContext`, so a plugin's chats run
|
||||
in `$DATA_PATH/<email>/plugins/<appName>/chats` — resolved server-side, never a path from the client.
|
||||
`plugins` joins `commanders` as a sibling of `home` that a member OWNS, or their turn dies on its own
|
||||
working directory (the `general_chat_sessions` failure, third time).
|
||||
- **A plugin briefs its chats with a file.** `plugins/<name>/chat/CLAUDE.md` is copied into that directory
|
||||
on every chat that starts there, replacing per-turn prompt prefixes.
|
||||
- **A plugin can list its own chats** — `/chat/sessions?context=plugin&contextId=<appName>`.
|
||||
- **Sonnet is the default when nobody chose.** Every surface without a picker was running Opus, because the
|
||||
fallback was the bare harness and bare means "whatever the CLI picks". A stored bare harness is now read
|
||||
as unset, which is what made the change appear to do nothing on machines that had one.
|
||||
- **A chat's API key follows its holder, minus `admin`** — was `notify` only. Note this takes effect in
|
||||
`officer-claude-code`, NOT `officer`.
|
||||
- **A locked screen persists sizes, not structure.** A plugin or core screen changing its layout now
|
||||
reaches people who have already opened it, and the `appTypes` repair can no longer be written back as
|
||||
though the user had chosen it.
|
||||
- **A dead commander watcher wakes its commander**, and the guard that was silently disabling that is
|
||||
fixed — it asked about background tasks when it meant "is the agent mid-turn", and a commander's watcher
|
||||
is itself a background task.
|
||||
- Theme browser and Spotlight keybindings; two `todo/` entries; the CLAUDE.md touchpoints audit.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.26.01`
|
||||
2. `bun db:push` — expect **no changes** to platform tables. If it wants to alter one, stop and report it.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. Asleep
|
||||
is a skip with a note, not a failure.
|
||||
|
||||
## `officer-claude-code` is NOT restarted by this, and that is deliberate
|
||||
|
||||
One change in here — the widened chat credential — lives in the agent sidecar rather than in `officer`, so
|
||||
step 4 does not activate it. **Do not restart `officer-claude-code` as part of the rollout.** It ends every
|
||||
live agent session on that host, including any agent running the rollout itself, and it is the owner's call
|
||||
when to take that. Say in the report that it is pending; do not do it.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It already has this code.
|
||||
- **`edge-pertento-bridge`** — skipped, unchanged from last release. It is 300+ commits behind with **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. Do not contact it.
|
||||
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||
- **`macbookpro`** — a target if it is awake.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**On `fofos` specifically**, because it is the reason for the release: after step 4, `/proxy-manager` should
|
||||
render rather than redirecting to home, and the log should say `[plugins] frontend built` with no
|
||||
`rebuild failed` line after it. If it still redirects, the bundle is stale — hard refresh before concluding
|
||||
anything.
|
||||
|
||||
Everywhere else: a chat should default to **sonnet** rather than opus or fable in the model picker. That is
|
||||
the most visible change and the easiest to confirm.
|
||||
|
||||
No direct SQL is needed on any host, and none is authorised.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Every place the platform writes, appends to, or reads `CLAUDE.md` — an audit
|
||||
|
||||
Written 2026-08-25, after a request to inventory the whole mechanism before changing any part of it.
|
||||
The scope was `platform/src`, `platform/scripts`, and every plugin currently checked out under
|
||||
`platform/plugins/`. Related design docs: [`commander-directory.md`](./commander-directory.md) (why a
|
||||
commander's `CLAUDE.md` exists at all), [`plugin-capabilities.md`](./plugin-capabilities.md) (the
|
||||
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.
|
||||
|
||||
| # | 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` |
|
||||
|
||||
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:
|
||||
|
||||
- **Nothing here is a per-turn cost.** All three writers touch disk once (creation, chat-start,
|
||||
migration) rather than on every message. The alternative — a `promptPrefix` riding every turn — is
|
||||
exactly what this replaced, twice (once for commanders, once for plugin chats), and the reason given
|
||||
both times is the same: paid forever, invisible as a design artefact, and visible in the worst place
|
||||
(a three-word message with a paragraph of preamble silently attached).
|
||||
- **Two mechanisms that look similar are deliberately NOT this.** The owner's affordances/skills
|
||||
preamble and a commander's old per-turn block go through the Claude Code SDK's
|
||||
`systemPrompt: { preset, append }` option — in memory, per session, never touching disk. See
|
||||
"Explicitly not written" below for why that line was drawn where it was.
|
||||
|
||||
## The three writers
|
||||
|
||||
### 1. Commander creation — the generator
|
||||
|
||||
`src/servers/api/command-hq/directory.ts:653`, `createCommanderDirectory()`.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
```ts
|
||||
writeFileSync(
|
||||
join(dir, 'CLAUDE.md'),
|
||||
commanderClaudeMd({
|
||||
name: params.name,
|
||||
referencePath: params.referencePath,
|
||||
loopPidPath: loopPidPath(email, dirName),
|
||||
spawnedPath: spawnedRecordPath(email, dirName),
|
||||
credentialPath,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
`commanderClaudeMd()` (same file, line 173) is the template — the commander's name, the reference path
|
||||
it was pointed at, where its loop pid lives, where its spawned-process record lives, and where its
|
||||
platform credential is (or an explicit statement that minting one failed, so the commander is told
|
||||
rather than left to discover the gap itself — the comment at line 662 is explicit that this ordering,
|
||||
credential before `CLAUDE.md`, exists so the file can describe the credential truthfully).
|
||||
|
||||
Plain `writeFileSync`, not atomic — defensible because it runs once, at creation, with nothing else able
|
||||
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
|
||||
months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
|
||||
|
||||
### 2. Plugin chat briefs — the copier
|
||||
|
||||
`src/servers/api/chat/websocket.ts:237`, `installPluginChatBrief()`.
|
||||
|
||||
```ts
|
||||
async function installPluginChatBrief(appName: string, dir: string): Promise<void> {
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled);
|
||||
if (!state) return;
|
||||
|
||||
const source = join(state.plugin.dir, 'chat', 'CLAUDE.md');
|
||||
if (!existsSync(source)) return;
|
||||
|
||||
const target = join(dir, 'CLAUDE.md');
|
||||
const next = await Bun.file(source).text();
|
||||
if (existsSync(target) && (await Bun.file(target).text()) === next) return;
|
||||
await writeAtomic(target, next);
|
||||
} catch (err) {
|
||||
console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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`
|
||||
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/`.
|
||||
|
||||
The source is `<plugin-repo>/chat/CLAUDE.md` — a file the plugin author ships in their own repository,
|
||||
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.
|
||||
|
||||
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
|
||||
write failure logs and continues rather than blocking the chat from starting.
|
||||
|
||||
**Of the 19 plugins checked out on this machine today, exactly one ships a brief: `proxy-manager`.**
|
||||
See "Plugins, one by one" below.
|
||||
|
||||
### 3. The credential backfill — the appender
|
||||
|
||||
`scripts/backfill-commander-credentials.ts`.
|
||||
|
||||
Not part of the runtime — a manual migration, re-run by hand after a deploy that changes what a
|
||||
commander's `CLAUDE.md` should say about itself (`bun run --env-file=.env scripts/backfill-commander-credentials.ts`
|
||||
on each host). Exists because a commander predating some capability (first: platform credentials; later:
|
||||
the notify route) has a `CLAUDE.md` that never mentions it, and deleting-and-recreating the directory to
|
||||
fix that would destroy the one thing a commander actually is — a long-running, self-extended
|
||||
conversation about a job.
|
||||
|
||||
```ts
|
||||
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
|
||||
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
|
||||
if (existing.includes(marker)) return false;
|
||||
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, claudeMd);
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
Idempotent by construction: each section carries a marker string (`officer.env` for the credential
|
||||
section, `/api/notify/_officer/notify` for the notify section) and is only appended when that marker is
|
||||
absent — so re-running the script, or running it against a `CLAUDE.md` a human hand-edited to already
|
||||
describe the same thing, does not duplicate anything. Two sections are currently backfilled this way:
|
||||
the credential path (added when `credential.ts` first shipped) and the notify route (added later,
|
||||
separately, because "arrived later" — commanders that most need a credential already have one by the
|
||||
time this runs).
|
||||
|
||||
This is the only writer of the three that **mutates an existing file** rather than producing a
|
||||
first-time one. Everything else in the mechanism either generates once (item 1) or overwrites wholesale
|
||||
on a genuine content diff (item 2) — appending-if-missing is specific to this being a repair pass over
|
||||
files a human may have already been editing by hand.
|
||||
|
||||
## The one reader
|
||||
|
||||
`scripts/docs-check.ts`, lines 92 and 108. Reads `platform/CLAUDE.md` and `officer-dev/CLAUDE.md` — not
|
||||
a plugin's, not a commander's — to check a documented numeric claim (permission-registry entry count,
|
||||
PM2 process count) against a value derived at run time, and fails the check on drift. This is `bun
|
||||
docs:check`, required before every release. It never writes.
|
||||
|
||||
Included here for completeness since the request was "writing OR touching" — this is the only touch
|
||||
that is not a write.
|
||||
|
||||
## Explicitly not written — and why, so the mechanism isn't mistaken for having a gap
|
||||
|
||||
Three places came up in the trace that look like they should write a `CLAUDE.md` and deliberately don't.
|
||||
Worth recording, because each one used to work differently and the comments at each site exist
|
||||
specifically to stop a future change from "fixing" this back in.
|
||||
|
||||
- **The owner's and every session's affordances/skills preamble** —
|
||||
`src/servers/sidecar/claude/claude-manager.ts:779-798`. Passed as
|
||||
`systemPrompt: { type: 'preset', preset: 'claude_code', append: preamble }` to the Claude Code SDK's
|
||||
`query()` call, in memory, every session, never to a file. The comment there gives the reason this
|
||||
differs from the commander/plugin mechanism above: this is "the one place every session goes through,
|
||||
owner and member alike" — a file can't cover that, because `user-instance.ts` (next point) refuses to
|
||||
write one for the owner at all, and a member's isolated home is a narrower audience than "every
|
||||
session." This is also where a commander's own preamble used to live, as a per-turn block
|
||||
(`commander-block.ts`, deleted 2026-08-24) — it could never resolve on a commander's first turn, since
|
||||
the block was built once per session and the first turn has no transcript id yet. Moving that
|
||||
information into a file the directory-creation step writes before any turn spawns (item 1 above) is
|
||||
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
|
||||
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` +
|
||||
`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.
|
||||
|
||||
## Plugins, one by one
|
||||
|
||||
`platform/plugins/CLAUDE.md` states the ground rule: **plugins are not part of this repository.** Every
|
||||
`plugins/<name>/` directory is gitignored here and exists on disk only because `git clone` put it there
|
||||
when it was installed on this machine — `/plugins/*/` in `.gitignore`. So "plugins, one by one" below
|
||||
means the 19 currently checked out here, not a catalogue.
|
||||
|
||||
The only convention a plugin can participate in is the one item 2 (above) reads: a `chat/CLAUDE.md` file
|
||||
at the top of the plugin's own repository, picked up automatically the moment that plugin's chat panel
|
||||
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. |
|
||||
|
||||
**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
|
||||
follows (frozen lockfiles, composite keys, script-task self-containment) — not a filesystem touch. The
|
||||
one exception, `proxy-manager`, participates in item 2 purely by shipping a static file at a path the
|
||||
platform already knows to look for; it contains no code of its own that touches `CLAUDE.md`.
|
||||
|
||||
## What this means for a future change
|
||||
|
||||
Anyone changing this mechanism has exactly three call sites to reconcile, plus the two "explicitly not
|
||||
written" boundaries above that a change should not cross without a deliberate decision to do so:
|
||||
|
||||
- Change what a **new** commander is told → edit `commanderClaudeMd()` in `directory.ts`. Existing
|
||||
commanders will not retroactively pick up the change; that is what the backfill script pattern (item 3)
|
||||
is for, and a new field needs its own marker and its own `appendIfMissing` call, following the same
|
||||
script.
|
||||
- Change what a **plugin chat** is told → that lives entirely in the plugin's own `chat/CLAUDE.md`; the
|
||||
platform's role is only the copy-if-changed mechanics in `installPluginChatBrief`, which no plugin
|
||||
author needs to touch.
|
||||
- Add the same brief mechanism to a **new surface** (not commander, not plugin chat) → follow the pattern
|
||||
in `resolveChatCwd()`: a directory the surface owns, `mkdirSync` before the write, and a decision about
|
||||
whether the content is generate-once (commander) or copy-and-diff (plugin) — the two existing writers
|
||||
chose differently for reasons specific to who edits the file afterward, and a third surface should make
|
||||
that choice deliberately rather than default to whichever is closer in the file.
|
||||
@@ -3,6 +3,17 @@
|
||||
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through
|
||||
it, kept current as answers landed.
|
||||
|
||||
**Superseded in part, 2026-08-24, by `docs/commander-directory.md`.** A commander's identity moved from
|
||||
Claude's transcript id to its own `command_hq_sessions` row, and it gained its own directory
|
||||
(`$DATA_PATH/<email>/commanders/<dirName>/`, `CLAUDE.md` + `state/`) created BEFORE any turn spawns. That
|
||||
makes several sections below describe the PREVIOUS mechanism rather than the current one — notably
|
||||
"How a commander is created" (creation is no longer session-first), "Working directory" (a commander no
|
||||
longer shares one directory per user, nor chooses an arbitrary one — it has its own), and the `POST
|
||||
/commanders` / `POST /commanders/new` rows in "API reference" (both now require `name` and `referencePath`
|
||||
and no longer accept an arbitrary `cwd`). This file was not rewritten to match — read
|
||||
`docs/commander-directory.md` first for anything those sections describe, and treat the code
|
||||
(`src/servers/api/command-hq/`) as the source of truth where the two disagree.
|
||||
|
||||
**Status:** data model, the full REST API including both creation paths (web and `POST
|
||||
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
|
||||
failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# A commander is a directory, not a session
|
||||
|
||||
**Status: phase 1 built 2026-08-24** — schema, directory creation, `CLAUDE.md` at creation, the inverted
|
||||
`POST /commanders` → start-turn → point-at-transcript order, `resolveChatCwd` resolving server-side, and the
|
||||
frontend composer split (`NewCommanderSetup` → `ActiveNewCommander`) in `src/servers/api/command-hq/` and
|
||||
`src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx`. `bunx tsgo`/`./node_modules/.bin/tsgo`
|
||||
clean, suite green. **`bun db:push` has not been run** — the owner reviews and runs the database work
|
||||
separately; see the schema section below for what changed and why nothing here is a migration. Phase 2
|
||||
(a commander becomes a git repository) is still not built.
|
||||
|
||||
## What is wrong now
|
||||
|
||||
A commander's identity is borrowed. `command_hq_sessions.session_id` is Claude's **transcript id**, minted
|
||||
by the CLI as the first turn begins — verified: the id on the live row has a transcript file under
|
||||
`~/.claude/projects/`. Everything about a commander hangs off that one borrowed value, and three problems
|
||||
fall out of it.
|
||||
|
||||
**An ordering problem.** `POST /command-hq/commanders` takes a `sessionId` the caller already holds and
|
||||
validates the transcript exists, so a chat session is created first and filed as a commander second. But
|
||||
the working directory is fixed when the session spawns — before the id exists. There is no point in the
|
||||
current flow where a commander can be started *in a directory named after itself*.
|
||||
|
||||
**A first-turn interlock that is a side effect.** `commanderBlock` compiles the paths a commander must
|
||||
record itself at into every turn, and on the first turn they cannot be resolved, so the block is empty.
|
||||
The commander skill turns that into a rule: *"if you have not been given paths, you are on your first
|
||||
turn."* It works, and it is a signal derived from a failure rather than something anybody said. That
|
||||
block's own comment explains the awkwardness — it rides `effectivePrompt` rather than the session
|
||||
preamble, because the preamble is built once and the first turn has no id to build it from.
|
||||
|
||||
**Losing the conversation loses the commander.** State lives in the transcript and in a second tree,
|
||||
`commander-state/<transcript id>/`. A session that dies takes its commander with it.
|
||||
|
||||
## The design
|
||||
|
||||
**The directory IS the commander. The session is how you talk to it.**
|
||||
|
||||
```
|
||||
$DATA_PATH/<email>/commanders/<commander id>/
|
||||
CLAUDE.md written at creation, extended by the commander as it learns the job
|
||||
spawned session ids of the agents its loop started
|
||||
<whatever it writes> scripts, procedures, its record of what it has handled
|
||||
```
|
||||
|
||||
The id is one the **platform** owns, generated when the commander is created. Nothing waits on the CLI.
|
||||
|
||||
The flow becomes linear:
|
||||
|
||||
1. The user gives a name and a reference path, and clicks Start.
|
||||
2. The platform creates the row and the directory.
|
||||
3. It writes `CLAUDE.md` into the directory: the name, the reference path, and the standing guidance
|
||||
`commanderBlock` currently injects.
|
||||
4. It spawns the session with `cwd` set to that directory.
|
||||
5. The transcript id arrives later and is recorded **on the row**, as a pointer to the current
|
||||
conversation.
|
||||
|
||||
**The API does not change.** Same endpoints, same shape from the client's side. What changes is the server
|
||||
being able to do things in an order that works.
|
||||
|
||||
## What this buys
|
||||
|
||||
**`CLAUDE.md` needs no mechanism.** Claude Code reads one from the working directory at session start. So
|
||||
the living document is free: the commander edits its own file, and the next turn has it. No injection, no
|
||||
per-turn cost, no block riding `effectivePrompt`.
|
||||
|
||||
**A commander survives its conversation.** Everything it knows is on disk. A session that dies can be
|
||||
replaced by a fresh one in the same directory, and the commander carries on with its scripts, its
|
||||
procedures and its record of what it has already handled. Today that is a total loss.
|
||||
|
||||
**One directory instead of two.** `commander-state/<id>/` collapses in. Deleting a commander becomes
|
||||
deleting a directory, which is also the only way to be sure nothing is left behind.
|
||||
|
||||
**The first turn can be stated rather than inferred.** With the file written before the session exists,
|
||||
the platform can simply say which turn it is on. The interlock stops being a trick.
|
||||
|
||||
## The reference path
|
||||
|
||||
**What was the working directory becomes a reference** — what the commander operates ON, not where it
|
||||
lives. One is collected in the start dialog, because a person can name one thing and asking for a list up
|
||||
front gets a worse answer than asking later.
|
||||
|
||||
**More can be added in conversation.** A voice-memos commander watches one folder; a life manager might
|
||||
watch memos, scans and a mailbox. So this is a list on the row, seeded with one, and the commander writes
|
||||
additions into its own `CLAUDE.md` as they are agreed.
|
||||
|
||||
## The two tiers, and why a commander should get cheaper
|
||||
|
||||
This is the point of the whole design, and it generalises past any particular commander.
|
||||
|
||||
**The script gathers. The agent judges.**
|
||||
|
||||
A commander has two kinds of work and they have opposite economics. A script runs per event — every email,
|
||||
every voice memo — and costs nothing, so there is no ceiling on volume. An agent runs per batch, on
|
||||
material a script already assembled, and costs a turn every time it wakes. So the rule for building any
|
||||
commander is: push as much as possible down into the script, and let the agent see only what actually
|
||||
needs judgement.
|
||||
|
||||
Detecting a one-time code in an email is a script. Reading fifty messages and deciding which of them the
|
||||
owner would want to know about is an agent. A watcher that transcribes a recording is a script; deciding
|
||||
what a week of transcripts means is an agent.
|
||||
|
||||
**And the tiers are not fixed, which is the part that matters.**
|
||||
|
||||
Because the commander owns its own scripts — they are files in its directory, and it can edit them — every
|
||||
correction moves work downward. The owner says "this one was missed, it had eight digits rather than six",
|
||||
and a decision that required judgement becomes a rule that does not. The agent tier gets thinner; the free
|
||||
tier gets better.
|
||||
|
||||
**A commander that runs for six months should cost less per event than it did in week one, not more.**
|
||||
|
||||
That is the opposite of how automation usually ages, and it is a direct consequence of two decisions made
|
||||
here rather than a hope: the agent owns its scripts, and the corrections arrive through dialogue rather
|
||||
than through a ticket to somebody else. Nothing improves a system it cannot edit.
|
||||
|
||||
Two things this requires, and both are cheap if done from the start:
|
||||
|
||||
- **The script's verdict must be reproducible.** Not logged — RE-RUN. The input is still in the mailbox or
|
||||
the folder, and the script is deterministic, so diagnosing a miss is running it again and watching. A
|
||||
log would be a second copy of something already on disk.
|
||||
- **A change is checked against what came before.** The corpus already exists — the last few hundred
|
||||
messages, the transcripts so far. Re-run the old script and the new one over it and compare; anything
|
||||
whose verdict changed is either the bug just fixed or one just introduced. Without that, the fix for
|
||||
eight-digit codes quietly starts flagging every order confirmation, unattended, at three in the morning.
|
||||
|
||||
`capabilities/skills/commander/SKILL.md` should teach this. Somebody building the fourth commander needs
|
||||
the rule more than they need the worked example.
|
||||
|
||||
## What the directory holds: definition versus state
|
||||
|
||||
Two kinds of thing live in there and they must not mix, because one of them travels and the other must
|
||||
never.
|
||||
|
||||
```
|
||||
$DATA_PATH/<email>/commanders/<commander id>/
|
||||
CLAUDE.md DEFINITION — the living document, extended as the job is agreed
|
||||
parameters.* DEFINITION — the things a fork has to change. Paths, cadences, thresholds
|
||||
procedures/ DEFINITION — what a spawned agent reads at fire time
|
||||
scripts/ DEFINITION — the loops
|
||||
state/ INSTANCE — what it has handled, what it spawned, its loop's pid
|
||||
```
|
||||
|
||||
**Separate from the first commit, not sorted out later.** A shareable bundle is "everything except
|
||||
`state/`" only if that boundary is structural; mixed together, extracting one means picking files apart by
|
||||
hand and getting it wrong once. Same reasoning as plugin capabilities being read in place rather than
|
||||
copied — a directory boundary beats a convention, because a convention is what a tired person violates.
|
||||
|
||||
The failure it prevents is specific: a recipient's commander starting up believing it has already handled
|
||||
your recordings, and silently doing nothing.
|
||||
|
||||
**Parameters are separate from procedure, or forking means editing code.** With `~/Documents/VoiceMemos`
|
||||
baked into a script, a fork is a debugging session. Declared in one place, a fork is a conversation — the
|
||||
new owner is asked where their memos land and the commander rewrites its own file. That difference is the
|
||||
whole point of the thing being an agent rather than a template.
|
||||
|
||||
## Phase 2 — a commander becomes a git repository
|
||||
|
||||
**Not now.** Recorded because the phase 1 layout has to leave room for it, and it costs nothing today to
|
||||
leave that room.
|
||||
|
||||
**There is no button.** The owner asks the commander, in conversation, to make itself shareable, and it
|
||||
does — `git init`, a `.gitignore` that excludes `state/`, a first commit. That is the same shape as
|
||||
everything else here: the commander maintains its own directory, and the platform does not reach in.
|
||||
|
||||
**What it needs to know goes in the commander SKILL, not an affordance.** The layout, what is tracked,
|
||||
what is ignored and how credentials are handled is reference material — read on the one day somebody asks
|
||||
for it. An affordance's body is charged to every turn of every commander forever, and this would earn that
|
||||
cost approximately never. `capabilities/skills/commander/SKILL.md` gains a section.
|
||||
|
||||
**A commander never holds a service's connection details, and that is a boundary rather than a
|
||||
precaution.** The Whisper URL is not the commander's business: it knows the platform can transcribe and
|
||||
asks the platform, which holds the connection. So there is usually nothing sensitive in a commander to
|
||||
leak, and the sharing story is simple for a reason rather than by luck.
|
||||
|
||||
Where a commander genuinely does need a secret of its own, the same rule applies for the obvious reason: a
|
||||
credential is definition-shaped — it looks like configuration — so excluding `state/` does not protect it,
|
||||
and a shared repository is exactly the artefact where one gets published and stays published. Name what
|
||||
must be configured; let the platform hold the value.
|
||||
|
||||
**Whether the platform is actually configured is not this system's problem.** A commander depending on
|
||||
transcription can be armed on a server where nobody set up a Whisper endpoint, and it would produce
|
||||
nothing. That is a system administration mistake, and the answer is already in the commander skill rather
|
||||
than in new machinery: **do the first run by hand, then arm.** A first run against something unconfigured
|
||||
fails in front of the person doing it. The same rule catches the shared case — somebody forking this onto
|
||||
their own machine finds out on their first run instead of at 3am.
|
||||
|
||||
The forking story then reads: clone, open a conversation, answer where your memos land and what you want
|
||||
done with them, and the commander rewrites its own parameters and arms.
|
||||
|
||||
## The consequence for the skill
|
||||
|
||||
`capabilities/skills/commander/SKILL.md` says turn one is orientation — *"read where you actually are
|
||||
before you ask anything"* — and that assumes `cwd` is the thing worth exploring. Under this design the
|
||||
commander's own directory is nearly empty on turn one and the interesting thing is somewhere else.
|
||||
|
||||
That guidance has to change with the code, or every new commander will orient on an empty folder and ask
|
||||
a worse question than it does today.
|
||||
|
||||
## The schema change, and why there is no migration
|
||||
|
||||
`session_id` stops being the commander's identity and becomes a nullable pointer to its current
|
||||
conversation. The row gains its own id, its directory, and its reference paths.
|
||||
|
||||
**Nothing is migrated.** The one commander on this machine is deleted and re-armed from scratch on the new
|
||||
system — the owner's call, made on 2026-08-24, and the right one: it is unarmed and has never fired, so
|
||||
migrating it would mean writing and testing a path that runs exactly once and then never again. Re-arming
|
||||
also exercises the flow this design exists to fix, which a migration would route around.
|
||||
|
||||
That decision has a shelf life. It is free while one commander exists and unarmed; it stops being free the
|
||||
moment somebody is relying on one. Build this before that happens, or write the migration.
|
||||
|
||||
## What this does not decide
|
||||
|
||||
**Whether a commander's loops are scripted or agentic.** Both, in practice — a watcher that transcribes
|
||||
does not need judgement and should not spend a turn per file, while a job that reads transcripts and
|
||||
decides what to do with each of them is agentic by nature. The directory holds the procedure either way.
|
||||
|
||||
**How a commander learns what the platform can do.** It reads the generated platform skill and, for a
|
||||
plugin, that plugin's own skill. That is a separate gap: a plugin must ship a skill documenting its routes
|
||||
or a commander can see it is installed and still not know how to call it.
|
||||
@@ -22,20 +22,79 @@ Two details from that report do most of the diagnostic work:
|
||||
|
||||
Do not re-investigate these without new evidence.
|
||||
|
||||
| Candidate | Why it is out |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
|
||||
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
|
||||
| Two Monaco editors sharing a model | The app's only other mount, `FileViewer/renderers/EditorRenderer.tsx`, passes no `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`. |
|
||||
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
|
||||
| Candidate | Why it is out |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
|
||||
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
|
||||
| ~~Two Monaco editors sharing a model~~ **— WRONG, see below** | This asked which OTHER component mounts Monaco and answered `EditorRenderer` (still true: no `path`, so `inmemory://model/N`, no collision). It never asked whether the **code editor collides with ITSELF**. It does — the panel is addable to a dashboard twice. |
|
||||
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
|
||||
|
||||
That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic
|
||||
`executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about.
|
||||
|
||||
One thing to check first, because it is recent and it is ours: `2b70a570` gave `<Editor>` a real `path`,
|
||||
so models are keyed by file URI globally rather than per mount. Two panels open on the same file now share
|
||||
one model. That should be an improvement, and it is a change in exactly the layer that turns out to be
|
||||
involved.
|
||||
## Two code-editor panels share one model and two buffers (2026-08-25)
|
||||
|
||||
The suspicion recorded here — `2b70a570` giving `<Editor>` a real `path`, so models are keyed by file URI
|
||||
**globally** rather than per mount — is correct, and it is worse than "should be an improvement". Verified
|
||||
by reading `@monaco-editor/react@4.7.0`'s built source, not inferred:
|
||||
|
||||
- **Model lookup is `getModel(Uri.parse(path)) || createModel(value, …)`.** An existing model for that path
|
||||
wins and the second mount's `value` is **ignored at creation**. So the second panel to open a file adopts
|
||||
the first panel's buffer.
|
||||
- **The value-sync effect then overwrites it**, because `value !== editor.getValue()`:
|
||||
`executeEdits('', [{ range: getFullModelRange(), text: value }])`.
|
||||
- **The guard that suppresses `onChange` during that write is per-INSTANCE.** The package sets an internal
|
||||
flag around its own `executeEdits` and checks it inside `onDidChangeModelContent`. Panel B's flag is not
|
||||
panel A's. So B's programmatic write **fires A's `onChange`** with B's content.
|
||||
- **Unmount disposes the shared model.** `keepCurrentModel` defaults to `false` and we never set it, so the
|
||||
cleanup calls `editor.getModel()?.dispose()`. And `CodeEditor` mounts `<Editor key={activeFile.path}>`,
|
||||
so this runs on every **tab switch**, not only on panel close — switching tabs in one panel disposes the
|
||||
model another panel is still displaying.
|
||||
|
||||
The other half is ours: `useEditorState` holds `files` in a plain `useState`, so it is **per panel
|
||||
instance**. Its own comment says so — "this app also mounts as a dashboard panel, and a dashboard can hold
|
||||
two of them". One shared model, two independent `content` values, and an `onChange` that crosses between
|
||||
them. `CodeEditor`'s handler does `setContent(activeFile.path, value)`, so panel A's buffer silently becomes
|
||||
panel B's content and `isDirty` flips with no keystroke — dirty tab, content the user never typed, disk
|
||||
untouched, fixed by reopening. That is the reported symptom's exact shape.
|
||||
|
||||
**What this does NOT yet explain is how JOB OUTPUT becomes one of those `value`s.** Nothing in the app
|
||||
writes job output into `useEditorState` — `openFile`/`setContent`/`reloadFile` are called only from
|
||||
`CodeEditor.tsx`, and the task log renders through shiki. So this is a real defect that produces the right
|
||||
shape, and it is not proven to be the reported instance. **The trap is still the thing that names the
|
||||
text's source.** Do not delete it on the strength of this section.
|
||||
|
||||
## Fixed 2026-08-25: the buffer is shared, because the model already was
|
||||
|
||||
`useEditorState` now keys buffers by path in one store shared across panels, and keeps only the tab set and
|
||||
the active path per panel. The rule that matters: **`openFile` discards the content it is handed when the
|
||||
file is already open somewhere else.** That mirrors what Monaco does with the model, and it is what removes
|
||||
the divergence — with both panels reading one buffer there is never a `value` for the sync effect to
|
||||
reconcile, so the `executeEdits` never runs and the cross-panel `onChange` never fires.
|
||||
|
||||
A buffer outlives the tab that opened it. `holders` is the set of panels with the file open, and the entry
|
||||
is dropped when the last one closes, so closing a tab in one panel cannot take unsaved work out from under
|
||||
another. Closing the last one still discards unsaved edits, exactly as before.
|
||||
|
||||
`<Editor keepCurrentModel>` is the other half, and it was needed under any fix: the default disposes the
|
||||
shared model on unmount, and `key={activeFile.path}` makes that every **tab switch**, not just a panel
|
||||
close. The cost is one retained model per file opened in a session.
|
||||
|
||||
Two things worth knowing:
|
||||
|
||||
- **Writes go through `queryClient.setQueryData`, not `useGlobal`'s setter.** `useGlobal` applies a
|
||||
functional update to the `data` captured in render rather than to what is in the cache now. With one
|
||||
writer that is invisible; this slot has one writer per panel, which is the whole scenario.
|
||||
- **The trap can now fire legitimately.** Two panels on one file share a model, so a large paste in one
|
||||
fires the other's `onChange` while it is unfocused — which is what `reportSuspiciousEdit` looks for. A
|
||||
report whose `stack` runs through the editor's own `onChange` with another panel open is that, not the
|
||||
bug. This is the reason to keep reading the `stack` rather than the count.
|
||||
|
||||
Pinned by `useEditorState.test.ts` (9 tests): adoption, cross-panel edit, close-with-holders, drop on last
|
||||
close, release on unmount, and that the tab set stays per panel.
|
||||
|
||||
**Still not closed.** This removes the mechanism, not the mystery: where the log text came from is still
|
||||
unknown, and nothing found so far writes job output into an editor buffer. Keep the trap.
|
||||
|
||||
## When it is reported
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Filesystem quotas — the prerequisite for per-user and per-team storage limits
|
||||
|
||||
**Status: planned, not done. Nothing in the platform reads or writes a quota yet.**
|
||||
|
||||
This is the host-side half. It makes limits *enforceable*; it does not create any. The platform side —
|
||||
the owner declaring defaults and maxima, and provisioning applying them — is `docs/roles-and-teams.md`
|
||||
→ "Quotas", and it is not built either. Do this first, because a policy nothing enforces is worse than
|
||||
no policy: it reads as a limit and is not one.
|
||||
|
||||
## Why the kernel and not the platform
|
||||
|
||||
Members hold `terminal` at `write` and have their own Linux accounts. A quota the platform tracks by
|
||||
counting bytes through the file browser is decorative — a member opens a terminal, or connects over SSH
|
||||
with their own key, and writes past it without the platform seeing the write.
|
||||
|
||||
Only the filesystem can refuse. So this is a provisioning feature, not an accounting one.
|
||||
|
||||
## Where it matters
|
||||
|
||||
**`edge-pertento`.** It is the first real multi-user deployment and the only host that will hold accounts
|
||||
that are not the owner's. It already has one account, which is the argument for doing this soon rather
|
||||
than carefully later: moving a home directory is easy once and tedious per person.
|
||||
|
||||
`officer-dev` is a development machine and will not be multi-user. `alpha` is the owner's own. Neither
|
||||
needs this, and neither can do it without the same downtime, so there is no reason to.
|
||||
|
||||
## What to check BEFORE booting into rescue
|
||||
|
||||
Run these on the live machine. They decide whether the procedure below is the right one.
|
||||
|
||||
```bash
|
||||
findmnt -no FSTYPE,SOURCE,TARGET / # expect ext4, and note the real device
|
||||
findmnt -no OPTIONS / | tr ',' '\n' | grep -i quota # expect nothing — that is why we are here
|
||||
sudo tune2fs -l "$(findmnt -no SOURCE /)" | grep -iE 'features|Inode size'
|
||||
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT
|
||||
df -h /home
|
||||
```
|
||||
|
||||
Three things decide the plan:
|
||||
|
||||
1. **Is `/home` its own filesystem, or part of `/`?** On a stock Hetzner image it is part of `/`, so the
|
||||
root filesystem is what gains the feature.
|
||||
2. **Inode size.** Only matters if you use project quotas — they need 256 bytes and cannot be enabled on a
|
||||
128-byte filesystem without recreating it. The plan below avoids project quotas for exactly this reason.
|
||||
3. **Is the device under LVM?** `alpha` is; `officer-dev` is not. It changes only the device path.
|
||||
|
||||
## The plan: user quotas and group quotas, not project quotas
|
||||
|
||||
Two mount options cover both cases and neither needs a feature the filesystem might lack:
|
||||
|
||||
- **`usrquota`** — a limit per Linux user. This is the per-account home directory limit.
|
||||
- **`grpquota`** — a limit per Linux group. **This is how a team's shared folder gets a limit**, because a
|
||||
team is already a Linux group (see `docs/roles-and-teams.md` — the group is what keeps one team out of
|
||||
another's folder, not the permission grant).
|
||||
|
||||
Project quotas (`prjquota`) would let a *directory* carry a limit independent of ownership. That is more
|
||||
flexible and it is not needed: every case here is "this user" or "this team", both of which are already
|
||||
identities the kernel knows. Skipping it also skips the inode-size problem entirely.
|
||||
|
||||
**For the group quota to work, the team folder must be `setgid`** (`chmod g+s`), so files created inside it
|
||||
inherit the team's group and are charged to it. Without that, a member's files are charged to the member's
|
||||
own primary group and the team limit counts nothing. This is the single easiest thing to get wrong here.
|
||||
|
||||
## Procedure
|
||||
|
||||
Downtime is roughly ten minutes, most of it reboots. The owner has said downtime is acceptable.
|
||||
|
||||
**1. Install the tools, on the live machine.**
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y quota
|
||||
```
|
||||
|
||||
**2. Take note of the root device**, from the pre-flight above — `/dev/sda1` on a stock image,
|
||||
`/dev/mapper/<vg>-<lv>` under LVM. Every command below uses it.
|
||||
|
||||
**3. Boot into Hetzner rescue** from the Cloud console, and do not mount the filesystem.
|
||||
|
||||
**4. Check it, then add the feature.** `tune2fs` refuses on a filesystem that is not clean, and the check
|
||||
is worth running anyway before changing metadata:
|
||||
|
||||
```bash
|
||||
e2fsck -f /dev/sda1
|
||||
tune2fs -O quota /dev/sda1
|
||||
tune2fs -l /dev/sda1 | grep -i features # 'quota' must now appear
|
||||
```
|
||||
|
||||
`quota` here is the ext4 *journaled quota* feature. It is what lets quotas come up with the filesystem
|
||||
rather than needing a `quotacheck` scan at every boot, which on 320 GB is not quick.
|
||||
|
||||
**5. Add the mount options.** Back on the normal system — or by mounting the root filesystem from rescue
|
||||
and editing it there — the `/` line in `/etc/fstab` gains `usrquota,grpquota`:
|
||||
|
||||
```
|
||||
UUID=<unchanged> / ext4 defaults,usrquota,grpquota 0 1
|
||||
```
|
||||
|
||||
**6. Reboot into the normal system and confirm.**
|
||||
|
||||
```bash
|
||||
findmnt -no OPTIONS / | tr ',' '\n' | grep quota # usrquota, grpquota
|
||||
sudo quotaon -p / # both should report "is on"
|
||||
sudo repquota -s / # a table, not an error
|
||||
```
|
||||
|
||||
## Verify it actually refuses
|
||||
|
||||
Enabling quotas and setting none is the failure that looks like success. Prove enforcement once, on a
|
||||
throwaway account, before trusting it:
|
||||
|
||||
```bash
|
||||
sudo useradd -m quotaprobe
|
||||
sudo setquota -u quotaprobe 10240 10240 0 0 / # 10 MB soft and hard
|
||||
sudo -u quotaprobe dd if=/dev/zero of=/home/quotaprobe/fill bs=1M count=50
|
||||
# expect: "Disk quota exceeded" and a file of about 10 MB, NOT 50
|
||||
sudo userdel -r quotaprobe
|
||||
```
|
||||
|
||||
If that writes 50 MB, quotas are not on, whatever the mount options say.
|
||||
|
||||
## What this does not do
|
||||
|
||||
- **It sets no limits.** Every existing account is unlimited until something assigns a quota. That is the
|
||||
correct intermediate state: the machine gains the ability, nobody's behaviour changes.
|
||||
- **It does not touch the platform.** `provision-os.ts` does not call `setquota` yet, and there is nowhere
|
||||
for the owner to declare a default. That is the other half.
|
||||
- **It does not apply to the team folders**, because there are no team folders yet. When they arrive they
|
||||
must be `setgid` or their group quota counts nothing.
|
||||
|
||||
## When the platform half is built
|
||||
|
||||
`POST /users/:id/provision-linux` already exists as a retry path and is the natural home for this: it
|
||||
becomes "re-apply current policy to this account", covering both a new account and a changed limit.
|
||||
|
||||
**On a host where quotas are not enabled, the platform must say so** — "policy recorded, not enforceable on
|
||||
this host" — rather than storing numbers that do nothing. Same rule as the plugin dependency check: refuse
|
||||
or report, never silently no-op. `quotaon -p` is the check, and it is cheap enough to run per request.
|
||||
+57
-1
@@ -1,6 +1,10 @@
|
||||
# Localization
|
||||
|
||||
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
|
||||
**Status: BUILT and shipping six locales** — en-GB, pt-PT, es-ES, de-DE, fr-FR, sv-SE. The mechanism
|
||||
is done; what remains is coverage, which is the count-taking below.
|
||||
|
||||
**To add a seventh language, read "Adding a locale" at the bottom and nothing else.** It is seven
|
||||
files and the order does not matter, but missing one is silent — see the failure it is written from.
|
||||
|
||||
This is an execution plan. It is written to be handed to an agent who has not had the conversation
|
||||
that produced it, so it states the decisions and the reasons for them, not just the steps.
|
||||
@@ -412,3 +416,55 @@ else will ever notice.
|
||||
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
|
||||
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||
|
||||
---
|
||||
|
||||
## Adding a locale
|
||||
|
||||
Seven files. Six are mechanical; the seventh is the one that gets missed.
|
||||
|
||||
**Three catalogues, one per workspace.** Copy `en-GB.json` beside itself and translate the values,
|
||||
keeping every key:
|
||||
|
||||
```
|
||||
src/apps/officer-web/locales/<code>.json app.* the shell, screens, settings
|
||||
src/workspaces/officerdev/src/locales/<code>.json panel.* the windowed apps
|
||||
src/workspaces/components/locales/<code>.json ui.* shared components
|
||||
```
|
||||
|
||||
**Three registrations, one per catalogue.** Each is an `import` plus a line in a map:
|
||||
|
||||
```
|
||||
src/apps/officer-web/lib/catalogues.ts
|
||||
src/workspaces/officerdev/src/locales/index.ts
|
||||
src/workspaces/components/locales/index.ts
|
||||
```
|
||||
|
||||
**And the picker**, which is the one that gets missed the other way round:
|
||||
|
||||
```
|
||||
src/apps/officer-web/lib/locales.ts SHIPPED_LOCALES
|
||||
```
|
||||
|
||||
### Why this is spelled out rather than left to reading the code
|
||||
|
||||
es-ES, de-DE and fr-FR once shipped translated, in perfect key parity, listed in the picker — and
|
||||
completely dead. All three registration files still imported only en-GB and pt-PT, so the merge
|
||||
produced three empty catalogues and the UI rendered raw keys in three of five languages.
|
||||
|
||||
Nothing caught it. The parity tests compare the JSON files against each other and every file was
|
||||
correct; a locale that is never imported is invisible to them by construction. The picker was right.
|
||||
Four edits in four files with no single place that knew all four had happened.
|
||||
|
||||
### What checks you now
|
||||
|
||||
Run `bun test` and it is settled. Two things stand where nothing stood:
|
||||
|
||||
- `src/apps/officer-web/lib/catalogues.test.ts` asserts every locale in `SHIPPED_LOCALES` resolves to
|
||||
a populated catalogue in all three namespaces, and matches en-GB's size. A locale in the picker but
|
||||
not imported fails here. So does one imported but empty.
|
||||
- The three `parity.test.ts` files read their own directory rather than listing locales by hand, so a
|
||||
new `<code>.json` is checked the moment it exists — for missing keys AND extra ones, because
|
||||
`findMissingKeys` alone reports half of a rename.
|
||||
|
||||
Then `bunx tsgo` and `bunx prettier --write` on what you touched, as everywhere else.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# A plugin's capabilities are LOADED, never copied
|
||||
|
||||
**Status: built.** Skills, affordances and notification catalogues landed in `2026.08.23.06`; tasks — the
|
||||
read-write one, and the last piece — landed on 2026-08-24. The four publishers are gone.
|
||||
|
||||
## The problem, as it is on disk right now
|
||||
|
||||
`capabilities/` is a git repository. The intent is that anyone can fork it, keep their own tasks, skills
|
||||
and affordances in it, and pull core's on top. That only works if the directory contains exactly two
|
||||
things: what core ships, and what the owner wrote.
|
||||
|
||||
It does not. Four publishers copy a plugin's files into it at install time — assets, affordances,
|
||||
notification catalogues and skills — so a fork accumulates files it did not author. On this machine today
|
||||
`skills/email-db/` and `extensions/` sit untracked in that repository, published there by plugins. Commit
|
||||
them and the fork carries another plugin's work; leave them and `git status` is permanently noisy. There
|
||||
is no third answer, because nothing on disk says which files came from where.
|
||||
|
||||
Copying is also wrong in a second way that has nothing to do with git. **A copy goes stale.**
|
||||
`plugins:update` pulls new plugin code and the copied capabilities keep describing the old version until
|
||||
something republishes them. And a copy survives `plugins:disable`, so a disabled plugin's skill stays in
|
||||
the agent's index — the plugin is off and the agent still believes it is there.
|
||||
|
||||
## The design
|
||||
|
||||
**A plugin keeps its capabilities in its own directory, and the platform reads them where they are.**
|
||||
|
||||
```
|
||||
capabilities/ the owner's fork: core's, plus their own. Nothing else, ever.
|
||||
tasks/ skills/ affordances/ notifications/
|
||||
|
||||
plugins/<name>/ the plugin's own repository, cloned on install
|
||||
tasks/ skills/ affordances/ notifications/
|
||||
```
|
||||
|
||||
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
|
||||
which is the whole reason this beats scoping the paths. A namespace makes provenance _readable_; separate
|
||||
directories make it _structural_.
|
||||
|
||||
Four consequences, and each of them is a bug that stops existing:
|
||||
|
||||
- **A fork stays clean.** `capabilities/` contains only what its owner is responsible for, so pulling from
|
||||
upstream merges without touching anything a plugin put there.
|
||||
- **Capabilities update with the plugin.** `plugins:update` is a `git pull` in the plugin's directory, and
|
||||
the capabilities are in that directory. There is nothing left to go stale.
|
||||
- **Disable actually disables.** The load list is built from plugins that are installed AND enabled, so a
|
||||
disabled plugin contributes nothing. Today its copies remain.
|
||||
- **Uninstall needs no cleanup step.** The directory goes and its capabilities go with it. No list of
|
||||
filenames to be careful with, and no way to delete the owner's own work by getting that list wrong.
|
||||
|
||||
## What has to change
|
||||
|
||||
**The loaders take a list of roots.** `loadSkills(root)` and `loadAffordances(root)` already take one with
|
||||
a default, so this is a small change rather than a rewrite. Notifications the same.
|
||||
|
||||
**The list is resolved per session, not at module load.** `affordanceBlock()` and `skillsBlock()` are
|
||||
called once per session in `sidecar/claude/claude-manager.ts` — deliberately, so a capability written an
|
||||
hour ago is offered on the next session. That is also why they can afford to ask the database which
|
||||
plugins are installed: once per session, not once per turn.
|
||||
|
||||
**Tasks are the awkward one, because that store is read-write.** `api/tasks/task-files.ts` creates, edits
|
||||
and deletes as well as reads. Reading must span every source; writing must only ever touch the owner's
|
||||
directory. A plugin's task is read-only by nature and the UI has to know, or it will offer an Edit that
|
||||
silently writes into a directory the next `plugins:update` overwrites.
|
||||
|
||||
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
|
||||
search every source rather than assuming one root.
|
||||
|
||||
**How it went in, since it turned out to be one function rather than several.** Every path that runs a
|
||||
task — the file browser's context menu, both runner sockets, the job queue, the pipeline executor —
|
||||
resolves it through `getTaskByDirName` and then reads `implementation` off the record. So the sources go
|
||||
in there and in `listTasks`, and a plugin's task becomes runnable everywhere at once with its identity
|
||||
unchanged: still a directory name, still what `pipeline_jobs` stores.
|
||||
|
||||
The record carries `plugin` — the app name, or null for the owner's own. That one field does the rest:
|
||||
`updateTask` and `deleteTask` throw on it, `DELETE /api/tasks/:name` answers 409 naming the plugin, and
|
||||
the capability screen replaces the edit and delete controls with a line saying whose it is. Without the
|
||||
delete guard the old code was quietly a no-op — `rm -rf` on a path in the owner's store that does not
|
||||
exist, then `{ ok: true }`, and the task still in the menu.
|
||||
|
||||
`getTaskByDirName` also refuses a name containing a separator. It always joined a caller-supplied string
|
||||
onto a path; searching several roots is the moment that stops being one directory nobody could escape.
|
||||
|
||||
**The four publishers are deleted**, along with their install and uninstall wiring:
|
||||
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
|
||||
last was written on 2026-08-23 and is superseded before it ever shipped.
|
||||
|
||||
`app-store/assets` is a different case and stays. Those are images served over HTTP from a
|
||||
platform-managed path, not capabilities read from disk, and the copy is what makes them reachable.
|
||||
|
||||
## The migration
|
||||
|
||||
One-off, and it is deletion rather than movement. The copies already in `capabilities/` have nothing to
|
||||
remove them once the publishers are gone — `skills/email-db/` and `extensions/` on this machine, and
|
||||
whatever the equivalent is per host. They are copies of files that still exist in each plugin's own
|
||||
directory, so deleting them loses nothing.
|
||||
|
||||
Check each host rather than assuming this one is representative.
|
||||
|
||||
## What this does not decide
|
||||
|
||||
**Where the owner's own capabilities live inside their fork.** Flat at the top of each type directory is
|
||||
what happens today and needs no change. Scoping them is a separate question and this design does not
|
||||
force it either way.
|
||||
|
||||
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
|
||||
task with the same directory name. The tie-break shipped as first-come with the owner's store searched
|
||||
first, which settles the case that matters — a plugin cannot take over a name its owner is already
|
||||
using — and leaves two plugins colliding with each other resolved by discovery order, which is arbitrary.
|
||||
Worth revisiting when a second plugin ships a task; not before.
|
||||
+76
-13
@@ -179,14 +179,21 @@ D8's warning in front of whoever writes the first script.
|
||||
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
|
||||
4. Teams UI, and the account screen showing which teams a user is in and as what.
|
||||
|
||||
### Phase 5 — the thing that actually unblocks all of it
|
||||
### Phase 5 — account creation — **DONE, and it was done before this phase was written**
|
||||
|
||||
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
|
||||
for accounts that cannot yet exist.
|
||||
`POST /api/users` is owner-gated and live, with a form under Settings → User Management: same password
|
||||
validator as bootstrap, a role checked against the `roles` table, `Super Admin` refused outright, an
|
||||
optional inbound SSH key, and Linux provisioning with `POST /users/:id/provision-linux` to retry it.
|
||||
Alongside it: role change, password reset, delete.
|
||||
|
||||
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
|
||||
find nothing to test it with. **It may be worth doing first.**
|
||||
It shipped in `69a31051`. This section spent days telling readers it did not exist and sequencing work
|
||||
around building it — and on 2026-08-23 it produced a concrete cost, when a recommendation to "do phase 5
|
||||
first" was made from this text and withdrawn only because someone opened `api/users/` to write the spec.
|
||||
|
||||
The general lesson, since it is the second time this exact shape has bitten: `bun run docs:check` compares
|
||||
numbers, so counts stay honest while prose rots quietly. **A phase marked as not built is worth verifying
|
||||
against the tree before it is planned around**, not because the plan was careless but because a plan is
|
||||
written once and the code moves every day.
|
||||
|
||||
---
|
||||
|
||||
@@ -207,13 +214,69 @@ find nothing to test it with. **It may be worth doing first.**
|
||||
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
|
||||
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
|
||||
|
||||
## Teams — decided 2026-08-23
|
||||
|
||||
The four open questions were answered by the owner. Recorded here so phase 4 is implementation rather
|
||||
than another round of design.
|
||||
|
||||
**A team owns a shared folder.** Not only an authorization grouping — storage is the part that was
|
||||
actually wanted. Team *dashboards* are a probable later addition and were explicitly deferred, because
|
||||
they need a permission shape nothing here has: people who can edit a dashboard's STRUCTURE versus people
|
||||
who only see what it renders. Every grant today is read-or-write over a whole capability, so that is a new
|
||||
model rather than a new level. Build the folder; leave room for the dashboards.
|
||||
|
||||
**Any Admin may create a team**, not the owner alone. Teams are self-service for people already trusted to
|
||||
administer the platform. This is what makes the quota section below necessary rather than optional: an
|
||||
Admin creating a team is an Admin allocating disk.
|
||||
|
||||
**A team may grant a capability the member's own role lacks** — the union, per D6 — with one rule on top:
|
||||
**whoever creates the team can only attach capabilities they hold themselves.** Escalation is capped at the
|
||||
creator's own reach rather than the whole registry. `execution` and `admin` stay ungrantable regardless,
|
||||
and `confined` still resolves to nothing for an account with no Linux user.
|
||||
|
||||
**Deleting a role is refused while anyone holds it, in a team or globally.** The same rule D4 already
|
||||
applies to global holders, extended rather than special-cased; the refusal should name the teams.
|
||||
|
||||
### Two things phase 4 must not get wrong
|
||||
|
||||
**The shared folder is enforced by the kernel, not by the grant.** Every role already holds `files` at
|
||||
`write` — they are the seeded `confined` defaults — so the capability is not what keeps one team out of
|
||||
another's folder. A Linux group per team is. The grant says whether someone can use the files surface at
|
||||
all; group membership says which folders they reach. Both are required and they are not substitutes.
|
||||
|
||||
**The grant cache is keyed on ROLE today**, deliberately: "at most one entry per role, and a new account
|
||||
needs no warm-up" (`permissions/authorize.ts`). Teams end that — effective permissions become role plus
|
||||
memberships, which is per-user. That property is easy to lose without noticing and awkward to recover, so
|
||||
it should be a decision in the implementation rather than a side effect.
|
||||
|
||||
## Quotas — the model, and the prerequisite nobody can skip
|
||||
|
||||
The owner declares platform-level defaults and maxima: per user, and per team. Admins allocate inside
|
||||
them. A change re-applies at the Linux level through `POST /users/:id/provision-linux`, which already
|
||||
exists as a retry path and becomes "re-apply current policy to this account".
|
||||
|
||||
**Enforcement is the kernel's job, not the platform's.** Members hold `terminal` at `write` and have their
|
||||
own Linux accounts, so a quota the platform tracks by counting bytes through the file browser is
|
||||
decorative — a member opens a terminal or SSHes in and writes past it. Only filesystem quotas bind.
|
||||
|
||||
**Which means a filesystem that can carry them.** Checked on 2026-08-23: neither `officer-dev` nor `alpha`
|
||||
can. Both are ext4 with homes on the root filesystem, no `usrquota`/`grpquota`/`prjquota` mount options,
|
||||
and neither has the ext4 `quota` or `project` feature. Enabling `project` needs `tune2fs -O project` on an
|
||||
UNMOUNTED filesystem, and `/` cannot be unmounted on a running server — so it is a rescue boot, not a
|
||||
config change.
|
||||
|
||||
The cheap moment is when a host is built, by giving the Linux homes their own filesystem with quota
|
||||
features from the start. Retrofitting a live host is expensive and gets more so with every account on it.
|
||||
|
||||
**Where this actually matters is `edge-pertento`** — the first real multi-user deployment, and the only
|
||||
one that will have accounts that are not the owner's. `officer-dev` is a development machine and will not
|
||||
be multi-user; alpha is the owner's own. Check edge-pertento's disk before it gains more accounts.
|
||||
|
||||
**On a host that cannot enforce, the platform says so** — "policy recorded, not enforceable here" — rather
|
||||
than storing numbers that do nothing. A feature that silently does nothing is worse than one that admits
|
||||
it cannot.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
|
||||
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
|
||||
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
|
||||
or a self-service one.
|
||||
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
|
||||
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
|
||||
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
|
||||
None outstanding for phase 4. The four that were here are answered above.
|
||||
holds globally; the same question exists per team and is not answered here.
|
||||
|
||||
+6
-8
@@ -30,6 +30,7 @@ Seven, all running.
|
||||
| `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]` |
|
||||
|
||||
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||
@@ -40,15 +41,12 @@ reachable from inside only.
|
||||
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
|
||||
and it must not be reported as one.
|
||||
|
||||
| Host | Since | Why |
|
||||
| ---------------------- | ---------- | ---------------------------------------------------------------- |
|
||||
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
|
||||
None today. `edge-pertento-bridge` was here from 2026-08-23 and came back up on 2026-08-24; it is a
|
||||
deploy target again and its row is in the table above.
|
||||
|
||||
Its row, kept so restoring it is one move:
|
||||
|
||||
```
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
```
|
||||
It was down long enough to matter, so expect it to be behind: when it returned it was 215 commits back at
|
||||
`2026.08.22.02`, with no `roles` table and no commanders. A host coming off this list needs its schema
|
||||
brought forward before it is treated as ordinary.
|
||||
|
||||
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
|
||||
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.05",
|
||||
"version": "2026.08.26.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Give a credential to every commander that predates them.
|
||||
|
||||
A commander created before `credential.ts` existed has no `officer.env` and no key row, so it cannot
|
||||
reach the platform at all — which is the state that made the first one curl the whisper container
|
||||
directly. Deleting and recreating it would fix that and throw away the conversation, which is the one
|
||||
thing a commander is: a long-running agreement about a job.
|
||||
|
||||
So this is additive and re-runnable:
|
||||
|
||||
- a commander that already has `officer.env` is left alone, whole;
|
||||
- `CLAUDE.md` is APPENDED to, never rewritten — it is a living document its commander has been
|
||||
extending, and the whole point of not recreating it is that it survives;
|
||||
- the key is named `commander:<dirName>`, so it revokes with the commander like any other.
|
||||
|
||||
Run it after a deploy that introduces credentials, on each host:
|
||||
|
||||
bun run --env-file=.env scripts/backfill-commander-credentials.ts
|
||||
*/
|
||||
import { readFileSync, existsSync, appendFileSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getUsers, listCommandHqSessions } from 'officerdb';
|
||||
import { writeCommanderCredential, commanderEnvPath } from '../src/servers/api/command-hq/credential';
|
||||
import { credentialSection, notifySection } from '../src/servers/api/command-hq/directory';
|
||||
import { getCommanderDir } from '../src/servers/data-path';
|
||||
import { lookupOsUser } from '../src/servers/os-user';
|
||||
|
||||
/** Best effort, and only for a member: the owner already owns everything under DATA_PATH. */
|
||||
async function giveToAccount(osUser: string | null, path: string): Promise<void> {
|
||||
if (!osUser) return;
|
||||
const ids = await lookupOsUser(osUser);
|
||||
if (!ids) return;
|
||||
Bun.spawnSync(['sudo', '-n', 'chown', `${ids.uid}:${ids.gid}`, path]);
|
||||
}
|
||||
|
||||
// Per user, because `listCommandHqSessions` is scoped by one — which is the right shape everywhere else
|
||||
// and only awkward here. There are few accounts and few commanders; this runs once per deploy.
|
||||
const rows: Array<{
|
||||
dirName: string | null;
|
||||
name: string | null;
|
||||
userId: number;
|
||||
email: string;
|
||||
osUser: string | null;
|
||||
}> = [];
|
||||
for (const user of await getUsers()) {
|
||||
for (const row of await listCommandHqSessions(user.id)) {
|
||||
rows.push({ dirName: row.dirName, name: row.name, userId: user.id, email: user.email, osUser: user.osUser });
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) console.log('no commanders on this host');
|
||||
|
||||
for (const row of rows) {
|
||||
const label = `${row.dirName} (${row.name})`;
|
||||
|
||||
if (!row.dirName) {
|
||||
console.log(`skip ${label} — no directory name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dir = getCommanderDir(row.email, row.dirName);
|
||||
if (!existsSync(dir)) {
|
||||
console.log(`skip ${label} — no directory on disk`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const claudeMd = join(dir, 'CLAUDE.md');
|
||||
|
||||
/**
|
||||
* Append a section only when it is not already described.
|
||||
*
|
||||
* The marker is a string the section itself contains, so a commander whose CLAUDE.md was hand-edited to
|
||||
* 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') : '';
|
||||
if (existing.includes(marker)) return false;
|
||||
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, claudeMd);
|
||||
return true;
|
||||
};
|
||||
|
||||
const envPath = commanderEnvPath(row.email, row.dirName);
|
||||
|
||||
if (!existsSync(envPath)) {
|
||||
const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName });
|
||||
chmodSync(path, 0o600);
|
||||
await giveToAccount(row.osUser, path);
|
||||
await appendIfMissing('officer.env', credentialSection(path));
|
||||
console.log(`MINT ${label} — ${path}`);
|
||||
} else {
|
||||
console.log(`ok ${label} — already has a credential`);
|
||||
}
|
||||
|
||||
/*
|
||||
Separately, because it arrived later and the commanders that need it most already have a credential.
|
||||
Without it a commander does not know Officer HAS a notification route, so it reaches for whatever its
|
||||
harness offers — a different channel, with its own rules about when it may interrupt.
|
||||
*/
|
||||
if (await appendIfMissing('/api/notify/_officer/notify', notifySection())) {
|
||||
console.log(`NOTIFY ${label} — told how to reach the owner`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
+46
-9
@@ -6,13 +6,25 @@
|
||||
* bun run notify:test --user 2 # someone else
|
||||
* bun run notify:test --code officer/core:job --id 412
|
||||
*
|
||||
* Talks to the sidecar over loopback, which is how every other producer reaches it — so this exercises
|
||||
* the real path rather than a special one. It reports what the channels actually did, because the thing
|
||||
* that wastes a day here is that a send with nothing configured returns success and delivers nothing.
|
||||
* Goes through the PLATFORM, with a key it mints and revokes for the one send — the same door an app or
|
||||
* a commander uses, so this exercises the real path rather than a special one. It reports what the
|
||||
* channels actually did, because the thing that wastes a day here is that a send with nothing configured
|
||||
* returns success and delivers nothing.
|
||||
*
|
||||
* It used to POST to the sidecar directly on loopback. That stopped working on 2026-08-25, when the
|
||||
* sidecar began requiring proof that the platform is the caller — anything on the machine could otherwise
|
||||
* notify any user, which on a box running agents is anything with a shell. The secret lives only in the
|
||||
* server's memory, so a CLI cannot present it and should not be able to.
|
||||
*
|
||||
* Minting a key is legitimate HERE and nowhere else: this runs as the owner, on the owner's machine, with
|
||||
* the database open in front of it. Anything that cannot already do that has no business sending either.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../src/servers/data-path';
|
||||
import { mintApiKey } from '../src/servers/auth-token';
|
||||
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
||||
import { PORT, OFFICER_API_URL } from '../src/servers/officer-url.mjs';
|
||||
|
||||
const arg = (name: string): string | undefined => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -69,12 +81,37 @@ const body = {
|
||||
|
||||
console.log(`[notify:test] sending ${JSON.stringify(body)}`);
|
||||
|
||||
const res = await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
// A key for this one send, revoked immediately afterwards whatever happens. Named so a leftover from a
|
||||
// killed run is obvious in the key list rather than looking like something a person made.
|
||||
const KEY_NAME = 'notify:test (temporary)';
|
||||
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
|
||||
const { secret, keyHash, prefix } = mintApiKey();
|
||||
await createApiKey({ userId, name: KEY_NAME, keyHash, prefix });
|
||||
|
||||
const publicHost = (() => {
|
||||
try {
|
||||
return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${OFFICER_API_URL}/api/notify/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
Authorization: `Bearer ${secret}`,
|
||||
// Origin validation matches Host against PUBLIC_URL, so a bare loopback request is refused.
|
||||
Host: publicHost ?? `127.0.0.1:${PORT}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
} finally {
|
||||
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
|
||||
}
|
||||
|
||||
const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array<Record<string, unknown>> };
|
||||
|
||||
|
||||
+31
-17
@@ -7,31 +7,45 @@
|
||||
*
|
||||
* ── Why these four are `isSystem` ──
|
||||
*
|
||||
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
|
||||
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
|
||||
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
|
||||
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
|
||||
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
|
||||
* Each is named by name somewhere in the code: this file matches `'Developer'`, `'Admin'` and `'Member'`
|
||||
* by name on every run, and `bootstrap.ts` writes `'Super Admin'`. Renaming or deleting one of those is not
|
||||
* a data change, it is a code change that has not happened — so the flag exists for the delete and rename
|
||||
* paths to refuse on, rather than for the failure to turn up later as a missing database role or an
|
||||
* account that cannot log in.
|
||||
*
|
||||
* ── `description` versus `grantsPostgres`/`grantsDocker` ──
|
||||
*
|
||||
* `seedRole` treats these two kinds of column differently, and it matters here specifically because this
|
||||
* is the one caller that seeds `isSystem: true` rows. `description` is written once, on insert, and never
|
||||
* touched again — an edit made on one server must not be reverted by the next deploy. `grantsPostgres` and
|
||||
* `grantsDocker` ARE re-asserted every run for a system role, because they are not the operator's data,
|
||||
* they are what "Developer" MEANS — the same standing as the name itself. `rolePermitsDevTools()` used to
|
||||
* be that declaration, hardcoded as `role === 'Developer'`; this table is where it lives now, and this
|
||||
* SEED array is the only place either flag is set for these four roles. See `seedRole`'s own comment in
|
||||
* `officer_db/src/roles/queries.ts` for the mechanics.
|
||||
*
|
||||
* ── What this deliberately does NOT do ──
|
||||
*
|
||||
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
|
||||
* and adds the constraint in the same run, so every existing account violates it — verified against a
|
||||
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
|
||||
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
|
||||
*
|
||||
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
|
||||
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
|
||||
* unattended.
|
||||
*
|
||||
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
|
||||
* Never overwrite `description`, as above. And never touch a CUSTOM role's grants — those are set through
|
||||
* `PATCH /api/roles/:name/grants` (`setRoleGrants`), which itself refuses to touch a system role for the
|
||||
* mirror-image reason: writing there would look like it worked and then silently revert on the next run of
|
||||
* this script.
|
||||
*/
|
||||
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||
|
||||
const SEED: { name: string; description: string }[] = [
|
||||
type SeedRoleEntry = { name: string; description: string; grantsPostgres?: boolean; grantsDocker?: boolean };
|
||||
|
||||
// Match today's behaviour exactly: only Developer passed `rolePermitsDevTools` before this table existed,
|
||||
// so Developer is the only role seeded with either flag true.
|
||||
const SEED: SeedRoleEntry[] = [
|
||||
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
|
||||
{
|
||||
name: 'Developer',
|
||||
description: 'A member who is also trusted with developer tooling on the machine',
|
||||
grantsPostgres: true,
|
||||
grantsDocker: true,
|
||||
},
|
||||
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||
];
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@ LAST_SKIPPED=()
|
||||
# distribution's default alongside it. It stays in core because anything that
|
||||
# compiles a native module needs it, but it is the one to move out first if that
|
||||
# ever bites.
|
||||
#
|
||||
# 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 —
|
||||
# and the commander has no way to know the tool is missing until it tries. macOS
|
||||
# has no equivalent in the package sets here; a commander there polls.
|
||||
pkgs_core() {
|
||||
case "$PM" in
|
||||
apt)
|
||||
@@ -103,17 +109,17 @@ pkgs_core() {
|
||||
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 \
|
||||
poppler-utils radicale fail2ban unattended-upgrades
|
||||
inotify-tools poppler-utils radicale 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 \
|
||||
poppler radicale fail2ban
|
||||
inotify-tools poppler radicale fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
poppler-utils radicale fail2ban
|
||||
inotify-tools poppler-utils radicale fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
|
||||
@@ -466,32 +466,73 @@ disable_sleep() {
|
||||
# systemd-networkd actually manages the network — a server or cloud image, via
|
||||
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
|
||||
#
|
||||
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
|
||||
# but the wait-online unit is still enabled and waits for a link that will never
|
||||
# be configured. It gives up after its full timeout, on every boot.
|
||||
# Where NetworkManager owns the network instead, systemd-networkd configures
|
||||
# nothing, but the wait-online unit is still enabled and waits for a link that
|
||||
# will never come up. It gives up after its full timeout, on every boot.
|
||||
#
|
||||
# So the fix is masking one unit, and only on the second stack. Do NOT
|
||||
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
|
||||
# networkd-managed box that brings it up with no network on the next boot, no
|
||||
# ssh, and nothing but the provider's rescue console.
|
||||
#
|
||||
# ── "is networkd running" is the wrong question ──
|
||||
#
|
||||
# It was the test here until 2026-08-24, and it is why this section did nothing
|
||||
# on the machine it was written for. Both stacks can be ACTIVE at once: on the
|
||||
# dev box NetworkManager owned enp1s0 and wlp2s0, systemd-networkd was up and
|
||||
# managing not one of eighty links, and wait-online still burned its full two
|
||||
# minutes on every boot — `Timeout occurred while waiting for network
|
||||
# connectivity`, at 18:31:05 to 18:33:05, in the journal.
|
||||
#
|
||||
# What matters is whether networkd CONFIGURED anything, which `networkctl` will
|
||||
# say. A link it owns reads `configured`; a link it is ignoring reads
|
||||
# `unmanaged`. If every link is unmanaged, the wait cannot ever be satisfied and
|
||||
# masking it is safe.
|
||||
|
||||
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
|
||||
|
||||
# Reported to the operator, so it has to describe the machine rather than pick a
|
||||
# winner: both daemons running with networkd idle is the exact case this section
|
||||
# used to get wrong, and printing "NetworkManager" for it hid that.
|
||||
network_manager_name() {
|
||||
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
|
||||
local nm=no nd=no
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null && nm=yes
|
||||
systemctl is-active --quiet systemd-networkd.service 2>/dev/null && nd=yes
|
||||
|
||||
if [[ $nm == yes && $nd == yes ]]; then
|
||||
if networkd_configures_a_link; then
|
||||
echo "both — networkd configures at least one link"
|
||||
else
|
||||
echo "NetworkManager (networkd is up but configures nothing)"
|
||||
fi
|
||||
elif [[ $nm == yes ]]; then
|
||||
echo "NetworkManager"
|
||||
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
|
||||
elif [[ $nd == yes ]]; then
|
||||
echo "systemd-networkd"
|
||||
else
|
||||
echo "neither — unclear"
|
||||
fi
|
||||
}
|
||||
|
||||
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
|
||||
# Anything else, including "cannot tell", is left alone.
|
||||
# Does networkd actually configure a link on this machine?
|
||||
#
|
||||
# `networkctl list --no-legend` puts the SETUP state last on each line. Anything
|
||||
# other than unmanaged/pending/n-a means networkd owns that link and the wait is
|
||||
# real work. Missing networkctl counts as "yes, it does" — an unanswerable
|
||||
# question must not be read as permission to mask.
|
||||
networkd_configures_a_link() {
|
||||
command -v networkctl &>/dev/null || return 0
|
||||
networkctl list --no-legend 2>/dev/null | awk '{ print $NF }' |
|
||||
grep -qvE '^(unmanaged|pending|n/a)$'
|
||||
}
|
||||
|
||||
# Is the wait actually pointless here? NetworkManager in charge, and networkd
|
||||
# either absent or configuring nothing. Anything else, including "cannot tell",
|
||||
# is left alone.
|
||||
wait_online_is_spurious() {
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
|
||||
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null || return 1
|
||||
systemctl is-active --quiet systemd-networkd.service 2>/dev/null || return 0
|
||||
! networkd_configures_a_link
|
||||
}
|
||||
|
||||
# What that unit actually cost on this boot, straight from systemd's own
|
||||
|
||||
@@ -1170,18 +1170,22 @@ elif ! skip; then
|
||||
echo ""
|
||||
info "Boot hang — a unit that can add two minutes to every boot"
|
||||
echo " On a machine where NetworkManager owns the network, systemd-networkd"
|
||||
echo " runs nothing — but systemd-networkd-wait-online is still enabled, and"
|
||||
echo " waits for a link that will never come up. It gives up after its full"
|
||||
echo " timeout, every single boot."
|
||||
echo " configures nothing — but systemd-networkd-wait-online is still"
|
||||
echo " enabled, and waits for a link that will never come up. It gives up"
|
||||
echo " after its full timeout, every single boot."
|
||||
echo ""
|
||||
echo " Note both daemons can be running at once. What decides it is whether"
|
||||
echo " networkd CONFIGURED a link, not whether it is up."
|
||||
echo ""
|
||||
echo " network managed by: $(network_manager_name)"
|
||||
echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot"
|
||||
|
||||
if ! wait_online_is_spurious; then
|
||||
echo ""
|
||||
echo " systemd-networkd is live here, so that unit is doing real work."
|
||||
echo " systemd-networkd configures a link here, so that unit is doing"
|
||||
echo " real work."
|
||||
echo " Masking it would be how this machine comes up with no network."
|
||||
SUMMARY+=("Boot hang: nothing to fix — networkd is in charge")
|
||||
SUMMARY+=("Boot hang: nothing to fix — networkd configures a link")
|
||||
else
|
||||
echo ""
|
||||
if is_role dev; then
|
||||
|
||||
+6
-136
@@ -1,140 +1,10 @@
|
||||
/**
|
||||
* Regenerate `TODO.md` — the index over `todo/`.
|
||||
* Regenerate `TODO.md` — the index over `todo/`. `bun run todo:index` after any change to the tree.
|
||||
*
|
||||
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
|
||||
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
|
||||
* it describes.
|
||||
* The logic lives in `src/servers/api/todos/todo-tree.ts`, shared with the `/api/todos` router so the
|
||||
* board and this CLI cannot disagree about the format; this file is only the command.
|
||||
*/
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { writeIndex } from '../src/servers/api/todos/todo-tree';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname;
|
||||
const OUT = join(ROOT, 'todo');
|
||||
|
||||
export type Item = {
|
||||
id: string;
|
||||
title: string;
|
||||
theme: string;
|
||||
status: string;
|
||||
created: string;
|
||||
/** `urgent` or empty — see todo/README.md for why there is no scale. */
|
||||
priority: string;
|
||||
due: string;
|
||||
order: number;
|
||||
file: string;
|
||||
};
|
||||
|
||||
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
|
||||
export function frontmatter(text: string): Record<string, string> {
|
||||
const end = text.indexOf('\n---', 4);
|
||||
if (!text.startsWith('---') || end === -1) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.slice(4, end).split('\n')) {
|
||||
const m = line.match(/^([a-z_]+):\s*(.*)$/);
|
||||
if (!m || !m[1]) continue;
|
||||
const raw = (m[2] ?? '').trim();
|
||||
out[m[1]] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
|
||||
const themes = readdirSync(OUT)
|
||||
.filter((d) => statSync(join(OUT, d)).isDirectory())
|
||||
.sort()
|
||||
.map((theme) => {
|
||||
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
|
||||
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
|
||||
const items = files
|
||||
.filter((f) => f !== '_index.md')
|
||||
.map((f) => {
|
||||
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
|
||||
return {
|
||||
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
|
||||
title: fm.title ?? f,
|
||||
theme,
|
||||
status: fm.status ?? 'open',
|
||||
created: fm.created ?? '',
|
||||
priority: fm.priority ?? '',
|
||||
due: fm.due ?? '',
|
||||
order: Number(fm.order ?? 0),
|
||||
file: `todo/${theme}/${f}`,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return { theme, heading: meta.heading ?? theme, items };
|
||||
});
|
||||
return { themes };
|
||||
}
|
||||
|
||||
const HEADER = `# TODO — the index
|
||||
|
||||
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
|
||||
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
|
||||
Format and the two-way-sync contract: \`todo/README.md\`.
|
||||
|
||||
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
|
||||
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
|
||||
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
|
||||
deleting it have been removed rather than left to mislead the next reader.
|
||||
|
||||
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||
`;
|
||||
|
||||
function render(): string {
|
||||
const { themes } = readTree();
|
||||
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
|
||||
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
|
||||
|
||||
const rows = themes
|
||||
.filter((t) => t.items.length)
|
||||
.map((t) => {
|
||||
const o = t.items.filter((i) => i.status !== 'done').length;
|
||||
const d = t.items.length - o;
|
||||
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
|
||||
});
|
||||
|
||||
const recent = themes
|
||||
.flatMap((t) => t.items)
|
||||
.filter((i) => i.status !== 'done')
|
||||
.sort((a, b) => b.created.localeCompare(a.created))
|
||||
.slice(0, 10)
|
||||
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
|
||||
|
||||
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
|
||||
// whole split risks. List them explicitly rather than letting them age out of view.
|
||||
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
|
||||
|
||||
// Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others
|
||||
// by theme, is an item nobody sees on the day it matters.
|
||||
const urgent = themes
|
||||
.flatMap((t) => t.items)
|
||||
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
|
||||
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
|
||||
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
|
||||
|
||||
return [
|
||||
HEADER,
|
||||
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
|
||||
`## Themes\n`,
|
||||
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
|
||||
`| Theme | Open | Closed |`,
|
||||
`| --- | --- | --- |`,
|
||||
...rows,
|
||||
``,
|
||||
`## Records — no items, kept for the reasoning\n`,
|
||||
...notes,
|
||||
``,
|
||||
`## Newest ten\n`,
|
||||
...recent,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
writeFileSync(join(ROOT, 'TODO.md'), render());
|
||||
console.log('TODO.md regenerated');
|
||||
}
|
||||
writeIndex();
|
||||
console.log('TODO.md regenerated');
|
||||
|
||||
@@ -107,6 +107,7 @@ 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="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export type CapabilityDetail = CapabilitySummary & {
|
||||
rawFrontmatter: string;
|
||||
filePath: string;
|
||||
chatSessionId: string | null;
|
||||
/** Only tasks carry this today: the plugin that ships it, or null/absent when it is the owner's own. */
|
||||
plugin?: string | null;
|
||||
};
|
||||
|
||||
type CapabilityListProps = {
|
||||
@@ -538,7 +540,17 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
|
||||
</span>
|
||||
{detail && (
|
||||
{/*
|
||||
A plugin's capability is read where it lives, in a git checkout that `plugins:update`
|
||||
pulls. So there is nothing to offer here: an edit would be overwritten by the next update,
|
||||
and the delete refuses server-side (409). Say whose it is instead of showing two controls
|
||||
that cannot work.
|
||||
*/}
|
||||
{detail?.plugin ? (
|
||||
<span className="text-xs text-duck-dark/40">
|
||||
{t('capability.fromPlugin', { plugin: detail.plugin })}
|
||||
</span>
|
||||
) : detail ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
@@ -553,7 +565,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { WorkspaceView, useCommanderFilesPanel } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||
|
||||
export const CommandHqScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
||||
const ephemeral = useCommanderFilesPanel();
|
||||
|
||||
/*
|
||||
Move a saved three-column layout onto the two-column one.
|
||||
@@ -26,6 +27,9 @@ export const CommandHqScreen = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
{/* The commander's own file browser is a layout of its own, rendered beside this one — it never
|
||||
enters `workspace.value`, so this screen stays locked and the allow-list does not have to know
|
||||
about it. Same trade `/files` makes for the file viewer. */}
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
@@ -33,6 +37,7 @@ export const CommandHqScreen = () => {
|
||||
allowed: ['command-hq-categories', 'command-hq-grid', 'command-hq-detail'],
|
||||
fallback: 'command-hq-detail',
|
||||
}}
|
||||
ephemeral={ephemeral}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Star } from 'lucide-react';
|
||||
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||
@@ -24,10 +24,10 @@ const {
|
||||
*
|
||||
* ── The overlay ──
|
||||
*
|
||||
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
||||
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
||||
* you browse, so as little of it as possible should be covered.
|
||||
* `ctrl+shift+↑/↓` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal with no
|
||||
* backdrop rather than a modal dialog: you are meant to watch the application change while you browse,
|
||||
* so nothing of it should be covered.
|
||||
*
|
||||
* Ported from the owner's `vimazing`, keys and all.
|
||||
*/
|
||||
@@ -35,12 +35,10 @@ export const ThemeSwitcher = () => {
|
||||
const { theme, themeId, setTheme, themes } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
||||
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
|
||||
/*
|
||||
One ordering, used by both lists.
|
||||
The DROPDOWN's ordering.
|
||||
|
||||
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
||||
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
||||
@@ -57,6 +55,36 @@ export const ThemeSwitcher = () => {
|
||||
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
|
||||
/*
|
||||
The OVERLAY's ordering: the same rotation, favourites IN PLACE.
|
||||
|
||||
The dropdown lifts favourites to a top block, and the overlay used to as well — which made keyboard
|
||||
browsing hop into the block and back out, because the rotation and the display disagreed about what
|
||||
"next" meant. In the overlay you are browsing, not picking from a menu, so it shows ONE list in
|
||||
rotation order and the favourites filter (`tab`, or the star button) is how the shortlist is reached.
|
||||
*/
|
||||
const overlayList = useMemo(() => {
|
||||
const rotated = rotateToSelected(themes, themeId);
|
||||
return onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
|
||||
const browseOrder = useMemo(() => overlayList.map((entry) => entry.id), [overlayList]);
|
||||
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys({
|
||||
onToggleFavorite: toggle,
|
||||
browseOrder,
|
||||
onToggleFilter: () => setOnlyFavorites((was) => !was),
|
||||
});
|
||||
|
||||
// The filter can pull the focused theme out from under the browse — flipping to favourites-only while
|
||||
// an unstarred one is focused. Land on the first of what is now shown rather than focusing nothing.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (overlayList.some((entry) => entry.id === focused)) return;
|
||||
const first = overlayList[0];
|
||||
if (first) setFocused(first.id);
|
||||
}, [open, overlayList, focused, setFocused]);
|
||||
|
||||
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
||||
|
||||
return (
|
||||
@@ -156,9 +184,9 @@ export const ThemeSwitcher = () => {
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
// No backdrop at all — the whole point of the browse is watching the app change behind the
|
||||
// list, so nothing may sit between them. The full-screen div stays for click-outside-to-close.
|
||||
className="fixed inset-0 z-[900] flex items-center justify-center"
|
||||
// Deliberately light: the point is to see the app change behind it.
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--background) 55%, transparent)' }}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
@@ -170,11 +198,31 @@ export const ThemeSwitcher = () => {
|
||||
}}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
||||
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
||||
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
||||
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
||||
{ordered.map((entry) => {
|
||||
{/* The filter, mirrored from the dropdown — same state, so the two surfaces never disagree
|
||||
about what "the list" currently is. `f` does the same thing from the keyboard. */}
|
||||
<div className="mb-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOnlyFavorites((was) => !was)}
|
||||
className={`inline-flex cursor-pointer items-center gap-1.5 rounded px-2 py-0.5 text-xs font-medium transition-opacity ${
|
||||
onlyFavorites ? 'opacity-100' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||
{onlyFavorites
|
||||
? t('theme.favouritesOnly', { count: favorites.size })
|
||||
: t('theme.favourites', { count: favorites.size })}
|
||||
</button>
|
||||
</div>
|
||||
{overlayList.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-xs opacity-60">{t('theme.noFavourites')}</p>
|
||||
)}
|
||||
{/* FIXED dimensions, not max/content: flipping the favourites filter must not resize the
|
||||
panel — the list is 200+ themes on one side of the flip and a handful on the other, and
|
||||
a modal that jumps between two sizes on every `f` reads as broken. Long names truncate;
|
||||
they already did when the widest name happened to be filtered out. */}
|
||||
<ul className="flex h-[70vh] w-[26rem] max-w-[80vw] flex-col gap-1 overflow-y-auto">
|
||||
{overlayList.map((entry) => {
|
||||
const isFocused = entry.id === focused;
|
||||
return (
|
||||
<li key={entry.id}>
|
||||
@@ -206,9 +254,7 @@ export const ThemeSwitcher = () => {
|
||||
? t('theme.unstarNamed', { name: entry.name })
|
||||
: t('theme.starNamed', { name: entry.name })
|
||||
}
|
||||
title={
|
||||
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
|
||||
}
|
||||
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
|
||||
onClick={(ev) => {
|
||||
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||
// the opposite of what starring is for.
|
||||
|
||||
@@ -218,18 +218,23 @@ export const Spotlight = () => {
|
||||
// anywhere — including inside a panel that has its own key handling.
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
|
||||
// `code`, not `key`: ctrl+space reports `key: ' '` on most layouts and something else on a few, while
|
||||
// the physical key is `Space` everywhere.
|
||||
//
|
||||
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
|
||||
// both is the bug that took a capture-phase listener to fix on the theme side.
|
||||
// Every other modifier must be ABSENT. `ctrl+shift+space` and `ctrl+alt+space` are the theme
|
||||
// browser, and one chord opening both is the bug that took a capture-phase listener to fix on the
|
||||
// theme side.
|
||||
//
|
||||
// Was shift+space until 2026-08-25: typing fires shift+space constantly (any capitalized word
|
||||
// followed by a space), so the launcher kept popping open mid-sentence. Ctrl+space doesn't happen in
|
||||
// normal typing.
|
||||
//
|
||||
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
|
||||
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
|
||||
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
|
||||
// below covers the only thing the guard was protecting against — a layout that would otherwise type
|
||||
// a character.
|
||||
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
|
||||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
|
||||
ev.preventDefault();
|
||||
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
|
||||
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
LayoutGrid,
|
||||
Lock,
|
||||
Mail,
|
||||
Mic,
|
||||
ScanText,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
@@ -156,7 +155,6 @@ const SETTINGS_ITEMS: SettingsItem[] = [
|
||||
},
|
||||
// System — SystemSettings.tsx
|
||||
{ id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
|
||||
{ id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
|
||||
{ id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
|
||||
{
|
||||
id: 'set:maintenance',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LayoutNode, AppRegistryMeta } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
|
||||
// The screen every plugin route renders. THE PLUGIN DOES NOT RENDER A SCREEN.
|
||||
//
|
||||
@@ -27,7 +27,12 @@ export function PluginScreen({
|
||||
layout: LayoutNode;
|
||||
}) {
|
||||
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
|
||||
const workspace = useDashboardState<LayoutNode>(`screens/plugin/${appName}`, layout);
|
||||
//
|
||||
// `useAuthoredLayout`, not `useDashboardState`: the STRUCTURE here is the plugin author's and comes from
|
||||
// `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 allowed = panels.map((panel) => panel.key);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type SttConfig = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const STTSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ['STT_CONFIG'],
|
||||
queryFn: () => client.get<SttConfig | null>('/server-settings/stt'),
|
||||
});
|
||||
|
||||
const [url, setUrl] = useState('http://localhost:64201');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
setUrl(config.url);
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async (ev?: FormEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await client.put('/server-settings/stt', { url });
|
||||
queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] });
|
||||
toast.success(t('settings.server.stt.saved'));
|
||||
} catch {
|
||||
toast.error(t('settings.server.stt.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (isTesting) return;
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const res = await client.post<{ success?: boolean; error?: string }>('/server-settings/stt/test', { url });
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success(t('settings.server.stt.reachable'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Connection failed';
|
||||
try {
|
||||
if (raw) msg = JSON.parse(raw).error ?? msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.stt.serverUrl')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
placeholder="http://localhost:64201"
|
||||
value={url}
|
||||
onChange={(ev) => setUrl(ev.target.value)}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={isTesting}
|
||||
className="h-11 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{isTesting ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Bot, Hammer, Mail, Mic, ScanText, Settings, Volume2 } from 'lucide-react';
|
||||
import { Bot, Hammer, Mail, ScanText, Settings, Volume2 } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
|
||||
@@ -7,7 +7,6 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
|
||||
import { SMTPSection } from './ServerSettings/SMTPSection';
|
||||
import { MaintenanceSection } from './ServerSettings/MaintenanceSection';
|
||||
import { TTSSection } from './ServerSettings/TTSSection';
|
||||
import { STTSection } from './ServerSettings/STTSection';
|
||||
import { OCRSection } from './ServerSettings/OCRSection';
|
||||
|
||||
const groups: SettingsSectionGroup[] = [
|
||||
@@ -22,13 +21,6 @@ const groups: SettingsSectionGroup[] = [
|
||||
descriptionKey: 'settings.sections.tts.description',
|
||||
content: <TTSSection />,
|
||||
},
|
||||
{
|
||||
key: 'stt',
|
||||
icon: Mic,
|
||||
titleKey: 'settings.sections.stt.label',
|
||||
descriptionKey: 'settings.sections.stt.description',
|
||||
content: <STTSection />,
|
||||
},
|
||||
{
|
||||
key: 'ocr',
|
||||
icon: ScanText,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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' }} />;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './TodosScreen';
|
||||
@@ -23,3 +23,4 @@ export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './SendOff';
|
||||
export * from './Todos';
|
||||
|
||||
@@ -7,6 +7,7 @@ import appEnGB from '../locales/en-GB.json';
|
||||
import appEsES from '../locales/es-ES.json';
|
||||
import appFrFR from '../locales/fr-FR.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
import appSvSE from '../locales/sv-SE.json';
|
||||
|
||||
/**
|
||||
* The three per-workspace catalogues, merged into one map per shipped locale (D6): `app.*` from
|
||||
@@ -33,6 +34,7 @@ const appCatalogs: TranslationMap = {
|
||||
'es-ES': appEsES,
|
||||
'de-DE': appDeDE,
|
||||
'fr-FR': appFrFR,
|
||||
'sv-SE': appSvSE,
|
||||
};
|
||||
|
||||
export const catalogues: TranslationMap = Object.fromEntries(
|
||||
|
||||
@@ -19,7 +19,7 @@ const autonym = (code: string): string => {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT', 'fr-FR', 'de-DE', 'es-ES'].map((code) => ({
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT', 'fr-FR', 'de-DE', 'es-ES', 'sv-SE'].map((code) => ({
|
||||
code,
|
||||
label: autonym(code),
|
||||
}));
|
||||
|
||||
@@ -145,10 +145,6 @@
|
||||
"label": "Text-zu-Sprache",
|
||||
"description": "TTS-Anbieter und Stimme"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Sprache-zu-Text",
|
||||
"description": "Whisper-Server-URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Vision-Modell für Textextraktion"
|
||||
@@ -234,12 +230,6 @@
|
||||
"saveFailed": "OCR-Einstellungen konnten nicht gespeichert werden",
|
||||
"reachable": "Vision-Modell-Server ist erreichbar"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "Whisper-Server-URL",
|
||||
"saved": "STT-Einstellungen gespeichert",
|
||||
"saveFailed": "STT-Einstellungen konnten nicht gespeichert werden",
|
||||
"reachable": "Whisper-Server ist erreichbar"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI usw.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
@@ -488,11 +478,11 @@
|
||||
"codeEditor": "Code-Editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen · Strg+Umschalt+F {{starAction}}",
|
||||
"pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+↑/↓ durchsuchen · Strg+Umschalt+F {{starAction}}",
|
||||
"pillLabel": "Design: {{name}}",
|
||||
"star": "markieren",
|
||||
"unstar": "Markierung aufheben",
|
||||
"cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen",
|
||||
"cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+↑/↓ durchsuchen",
|
||||
"everyShortcutHint": "Strg+? für alle Tastenkombinationen",
|
||||
"favouritesOnly": "Nur Favoriten ({{count}})",
|
||||
"favourites": "Favoriten ({{count}})",
|
||||
@@ -504,7 +494,7 @@
|
||||
"addToFavouritesKey": "Zu Favoriten hinzufügen (f)",
|
||||
"removeFromFavouritesKey": "Aus Favoriten entfernen (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f markieren · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen"
|
||||
"browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f Favoriten · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gehe zu…",
|
||||
@@ -692,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Keine Treffer",
|
||||
"emptyFile": "Leere Datei",
|
||||
"fromPlugin": "Gehört zum Plugin {{plugin}} — schreibgeschützt",
|
||||
"createFailed": "{{kind}} konnte nicht erstellt werden",
|
||||
"deleteFailed": "{{kind}} konnte nicht gelöscht werden",
|
||||
"searchPlaceholder": "{{kind}} durchsuchen…",
|
||||
|
||||
@@ -145,10 +145,6 @@
|
||||
"label": "Text to Speech",
|
||||
"description": "TTS provider and voice"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Speech to Text",
|
||||
"description": "Whisper server URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Vision model for text extraction"
|
||||
@@ -234,12 +230,6 @@
|
||||
"saveFailed": "Failed to save OCR settings",
|
||||
"reachable": "Vision model server is reachable"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "Whisper Server URL",
|
||||
"saved": "STT settings saved",
|
||||
"saveFailed": "Failed to save STT settings",
|
||||
"reachable": "Whisper server is reachable"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-compatible (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
@@ -488,11 +478,11 @@
|
||||
"codeEditor": "Code Editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+↑/↓ browse · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Theme: {{name}}",
|
||||
"star": "star",
|
||||
"unstar": "unstar",
|
||||
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+space browse",
|
||||
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+↑/↓ browse",
|
||||
"everyShortcutHint": "ctrl+? for every shortcut",
|
||||
"favouritesOnly": "Favourites only ({{count}})",
|
||||
"favourites": "Favourites ({{count}})",
|
||||
@@ -504,7 +494,7 @@
|
||||
"addToFavouritesKey": "Add to favourites (f)",
|
||||
"removeFromFavouritesKey": "Remove from favourites (f)",
|
||||
"active": "active",
|
||||
"browseHint": "j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel"
|
||||
"browseHint": "j/k or ↑/↓ move · l or → preview · f favourites · h or ← reset · enter keep · esc cancel"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Go to…",
|
||||
@@ -692,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "No matches",
|
||||
"emptyFile": "Empty file",
|
||||
"fromPlugin": "Ships with the {{plugin}} plugin — read-only",
|
||||
"createFailed": "Could not create the {{kind}}",
|
||||
"deleteFailed": "Could not delete the {{kind}}",
|
||||
"searchPlaceholder": "Search {{kind}}…",
|
||||
|
||||
@@ -145,10 +145,6 @@
|
||||
"label": "Texto a voz",
|
||||
"description": "Proveedor de TTS y voz"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Voz a texto",
|
||||
"description": "URL del servidor Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modelo de visión para extracción de texto"
|
||||
@@ -234,12 +230,6 @@
|
||||
"saveFailed": "No se pudieron guardar los ajustes de OCR",
|
||||
"reachable": "El servidor del modelo de visión está accesible"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL del servidor Whisper",
|
||||
"saved": "Ajustes de STT guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de STT",
|
||||
"reachable": "El servidor Whisper está accesible"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatible con OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
@@ -488,11 +478,11 @@
|
||||
"codeEditor": "Editor de código"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ recorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ recorrer · ctrl+shift+↑/↓ explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "marcar",
|
||||
"unstar": "desmarcar",
|
||||
"cycleHint": "ctrl+shift+←/→ recorrer · ctrl+shift+space explorar",
|
||||
"cycleHint": "ctrl+shift+←/→ recorrer · ctrl+shift+↑/↓ explorar",
|
||||
"everyShortcutHint": "ctrl+? para todos los atajos",
|
||||
"favouritesOnly": "Solo favoritos ({{count}})",
|
||||
"favourites": "Favoritos ({{count}})",
|
||||
@@ -504,7 +494,7 @@
|
||||
"addToFavouritesKey": "Añadir a favoritos (f)",
|
||||
"removeFromFavouritesKey": "Quitar de favoritos (f)",
|
||||
"active": "activo",
|
||||
"browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f marcar · h o ← reiniciar · enter mantener · esc cancelar"
|
||||
"browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f favoritos · h o ← reiniciar · enter mantener · esc cancelar"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Ir a…",
|
||||
@@ -692,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Sin coincidencias",
|
||||
"emptyFile": "Archivo vacío",
|
||||
"fromPlugin": "Viene del plugin {{plugin}} — solo lectura",
|
||||
"createFailed": "No se pudo crear el {{kind}}",
|
||||
"deleteFailed": "No se pudo eliminar el {{kind}}",
|
||||
"searchPlaceholder": "Buscar {{kind}}…",
|
||||
|
||||
@@ -145,10 +145,6 @@
|
||||
"label": "Synthèse vocale",
|
||||
"description": "Fournisseur et voix pour la synthèse vocale"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Reconnaissance vocale",
|
||||
"description": "URL du serveur Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modèle de vision pour l'extraction de texte"
|
||||
@@ -234,12 +230,6 @@
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres OCR",
|
||||
"reachable": "Le serveur du modèle de vision est accessible"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL du serveur Whisper",
|
||||
"saved": "Paramètres de reconnaissance vocale enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres de reconnaissance vocale",
|
||||
"reachable": "Le serveur Whisper est accessible"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatible OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
@@ -488,11 +478,11 @@
|
||||
"codeEditor": "Éditeur de code"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Thème : {{name}} · ctrl+shift+←/→ parcourir · ctrl+shift+space explorer · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Thème : {{name}} · ctrl+shift+←/→ parcourir · ctrl+shift+↑/↓ explorer · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Thème : {{name}}",
|
||||
"star": "marquer",
|
||||
"unstar": "démarquer",
|
||||
"cycleHint": "ctrl+shift+←/→ parcourir · ctrl+shift+space explorer",
|
||||
"cycleHint": "ctrl+shift+←/→ parcourir · ctrl+shift+↑/↓ explorer",
|
||||
"everyShortcutHint": "ctrl+? pour tous les raccourcis",
|
||||
"favouritesOnly": "Favoris uniquement ({{count}})",
|
||||
"favourites": "Favoris ({{count}})",
|
||||
@@ -504,7 +494,7 @@
|
||||
"addToFavouritesKey": "Ajouter aux favoris (f)",
|
||||
"removeFromFavouritesKey": "Retirer des favoris (f)",
|
||||
"active": "actif",
|
||||
"browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f marquer · h ou ← réinitialiser · entrée garder · esc annuler"
|
||||
"browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f favoris · h ou ← réinitialiser · entrée garder · esc annuler"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Aller à…",
|
||||
@@ -692,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Aucun résultat",
|
||||
"emptyFile": "Fichier vide",
|
||||
"fromPlugin": "Fourni par le plugin {{plugin}} — lecture seule",
|
||||
"createFailed": "Impossible de créer le {{kind}}",
|
||||
"deleteFailed": "Impossible de supprimer le {{kind}}",
|
||||
"searchPlaceholder": "Rechercher {{kind}}…",
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('officer-web (app.*) locale catalogs', () => {
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,10 +145,6 @@
|
||||
"label": "Texto para fala",
|
||||
"description": "Fornecedor de TTS e voz"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Fala para texto",
|
||||
"description": "URL do servidor Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modelo de visão para extração de texto"
|
||||
@@ -234,12 +230,6 @@
|
||||
"saveFailed": "Falha ao guardar as definições de OCR",
|
||||
"reachable": "O servidor do modelo de visão está acessível"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL do servidor Whisper",
|
||||
"saved": "Definições de STT guardadas",
|
||||
"saveFailed": "Falha ao guardar as definições de STT",
|
||||
"reachable": "O servidor Whisper está acessível"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatível com OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
@@ -488,11 +478,11 @@
|
||||
"codeEditor": "Editor de código"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+↑/↓ explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "marcar",
|
||||
"unstar": "desmarcar",
|
||||
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+space explorar",
|
||||
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+↑/↓ explorar",
|
||||
"everyShortcutHint": "ctrl+? para todos os atalhos",
|
||||
"favouritesOnly": "Apenas favoritos ({{count}})",
|
||||
"favourites": "Favoritos ({{count}})",
|
||||
@@ -504,7 +494,7 @@
|
||||
"addToFavouritesKey": "Adicionar aos favoritos (f)",
|
||||
"removeFromFavouritesKey": "Remover dos favoritos (f)",
|
||||
"active": "ativo",
|
||||
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · h ou ← reiniciar · enter manter · esc cancelar"
|
||||
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f favoritos · h ou ← reiniciar · enter manter · esc cancelar"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Ir para…",
|
||||
@@ -692,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Sem correspondências",
|
||||
"emptyFile": "Ficheiro vazio",
|
||||
"fromPlugin": "Vem do plugin {{plugin}} — só de leitura",
|
||||
"createFailed": "Não foi possível criar o {{kind}}",
|
||||
"deleteFailed": "Não foi possível eliminar o {{kind}}",
|
||||
"searchPlaceholder": "Pesquisar {{kind}}…",
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Spara",
|
||||
"saving": "Sparar...",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "E-post",
|
||||
"username": "Användarnamn",
|
||||
"name": "Namn",
|
||||
"namePlaceholder": "Ditt namn",
|
||||
"updated": "Profilen uppdaterad",
|
||||
"updateFailed": "Det gick inte att uppdatera profilen",
|
||||
"selectImage": "Välj en bildfil",
|
||||
"imageTooLarge": "Bilden måste vara mindre än 384 kB"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "En API-nyckel loggar in som dig, utan lösenord och utan att gå ut. En nyckel fungerar överallt — en nyckel är inte knuten till en app. Att ge varje enhet sin egen är bara så att du kan stänga av en utan att röra resten. Skicka den som",
|
||||
"loading": "Läser in nycklar…",
|
||||
"mintedTitle": "Nyckel för ”{{name}}” — visas en gång",
|
||||
"savedIt": "Jag har sparat den",
|
||||
"copyKey": "Kopiera nyckel",
|
||||
"copied": "Nyckel kopierad",
|
||||
"copyFailed": "Det gick inte att kopiera — markera och kopiera manuellt",
|
||||
"newKey": "Ny nyckel",
|
||||
"namePlaceholder": "iPhone, Musik-appen, laptop-CLI…",
|
||||
"create": "Skapa",
|
||||
"createFailed": "Det gick inte att skapa nyckeln",
|
||||
"noneYet": "Inga nycklar än.",
|
||||
"noneInUse": "Inga nycklar används.",
|
||||
"revoked": "återkallad",
|
||||
"expired": "utgången",
|
||||
"lastUsed": "senast använd {{date}}",
|
||||
"neverUsed": "aldrig använd",
|
||||
"forget": "Ta bort denna post",
|
||||
"forgetFailed": "Det gick inte att ta bort den",
|
||||
"revoke": "Återkalla",
|
||||
"revokeDone": "Återkallad — allt som använder den nyckeln loggas ut",
|
||||
"revokeFailed": "Det gick inte att återkalla",
|
||||
"hideDead": "Dölj återkallade och utgångna",
|
||||
"showDead": {
|
||||
"one": "Visa {{count}} återkallad eller utgången nyckel",
|
||||
"other": "Visa {{count}} återkallade eller utgångna nycklar"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Röst",
|
||||
"refresh": "Uppdatera röster",
|
||||
"serverDefault": "Serverstandard",
|
||||
"hint": "Välj en röst för text-till-tal. Behåll serverstandard för att använda rösten administratören har konfigurerat.",
|
||||
"stop": "Stoppa",
|
||||
"listen": "Lyssna"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Synlig",
|
||||
"hidden": "Dold",
|
||||
"dragToShow": "Dra objekt hit för att visa i dockan",
|
||||
"allVisible": "Alla objekt synliga",
|
||||
"reset": "Återställ till standard"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Språk du talar",
|
||||
"addLanguage": "Lägg till ett språk...",
|
||||
"defaultLabel": "Standardspråk",
|
||||
"defaultHint": "Används för lokalisering av gränssnittet.",
|
||||
"translateToLabel": "Översätt till",
|
||||
"translateToHint": "Målspråk vid översättning av innehåll du inte talar."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Nuvarande lösenord",
|
||||
"currentPasswordPlaceholder": "Nuvarande lösenord",
|
||||
"newPasswordLabel": "Nytt lösenord",
|
||||
"newPasswordPlaceholder": "Nytt lösenord",
|
||||
"confirmPasswordLabel": "Bekräfta nytt lösenord",
|
||||
"confirmPasswordPlaceholder": "Bekräfta nytt lösenord",
|
||||
"changeButton": "Byt lösenord",
|
||||
"changingButton": "Byter...",
|
||||
"passwordChanged": "Lösenordet ändrat",
|
||||
"passwordChangeFailed": "Det gick inte att byta lösenord"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Standardmodell",
|
||||
"sameAsChat": "Samma som chattstandard",
|
||||
"taskDefaultsSaved": "Standardvärden för uppgifter sparade",
|
||||
"saveFailed": "Det gick inte att spara inställningarna",
|
||||
"defaultChat": "Standardmodell för chatt",
|
||||
"defaultChatHint": "Används när en ny chatt startas från hemskärmen",
|
||||
"defaultProject": "Standardmodell för projekt",
|
||||
"defaultProjectHint": "Används när en ny chatt startas inuti en projektdashboard",
|
||||
"defaultTask": "Standardmodell för uppgifter",
|
||||
"defaultTaskHint": "Används när uppgifter körs från filhanteraren",
|
||||
"aiDefaultsSaved": "Standardvärden för AI-modell sparade"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profil",
|
||||
"hint": "avatar, namn, e-post",
|
||||
"description": "Avatar, namn och e-post"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Byt lösenord",
|
||||
"description": "Uppdatera ditt lösenord"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "API-nycklar",
|
||||
"hint": "nycklar som loggar in som dig",
|
||||
"description": "Nycklar som loggar in som dig, kan återkallas en i taget"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Språk",
|
||||
"hint": "talade och översättning",
|
||||
"description": "Talade, standard och översättning"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Röst",
|
||||
"hint": "text-till-tal",
|
||||
"description": "Inställning för text-till-tal-röst"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Docka",
|
||||
"hint": "välj och ordna om",
|
||||
"description": "Välj och ordna om objekt i dockan"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Mina modeller",
|
||||
"description": "Visa eller dölj modeller för dig själv"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Kanalmodeller",
|
||||
"description": "Modeller som andra konton än ägaren kan nå"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Standardmodeller",
|
||||
"description": "Standardmodeller för chatt, projekt och uppgifter"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Leverantörer",
|
||||
"hint": "körmiljöer",
|
||||
"description": "Fjärran och lokala AI-leverantörer"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Text till tal",
|
||||
"description": "TTS-leverantör och röst"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Synmodell för textextrahering"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Bygg om och starta om",
|
||||
"hint": "underhåll",
|
||||
"description": "Läs in ändringar utan en terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "utgående e-post",
|
||||
"description": "Leverantör för e-postleverans"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Klient-ID och hemlighet för Google-API:er"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "API-token för webbskrapningsaktörer"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Kalender- och kontaktsynkronisering",
|
||||
"hint": "applösenord",
|
||||
"description": "Lösenord per enhet för CalDAV-/CardDAV-klienter"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Konton",
|
||||
"hint": "användare",
|
||||
"description": "Vem som har åtkomst, och som vad"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Behörigheter",
|
||||
"description": "Vad varje roll kan nå"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Sök inställningar…",
|
||||
"selectSection": "Välj en sektion",
|
||||
"groups": {
|
||||
"models": "Modeller",
|
||||
"defaults": "Standardvärden",
|
||||
"providers": "Leverantörer",
|
||||
"ai": "AI",
|
||||
"maintenance": "Underhåll",
|
||||
"email": "E-post"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profil",
|
||||
"ai": "AI",
|
||||
"system": "System",
|
||||
"integrations": "Integrationer",
|
||||
"users": "Användare"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Inga modeller tillgängliga. Konfigurera API-nycklar under Leverantörer.",
|
||||
"dragToShow": "Dra modeller hit för att visa",
|
||||
"allVisible": "Alla modeller synliga",
|
||||
"dragToEnable": "Dra modeller hit för att aktivera",
|
||||
"allEnabled": "Alla modeller aktiverade",
|
||||
"visible": "Synlig",
|
||||
"hidden": "Dold",
|
||||
"enabled": "Aktiverad",
|
||||
"disabled": "Inaktiverad",
|
||||
"refresh": "Uppdatera modeller från leverantörer",
|
||||
"refreshed": "Modeller uppdaterade",
|
||||
"refreshFailed": "Det gick inte att uppdatera modellerna"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Företag",
|
||||
"personal": "Personligt"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Läser in…",
|
||||
"provider": "Leverantör",
|
||||
"model": "Modell",
|
||||
"voice": "Röst",
|
||||
"ocr": {
|
||||
"serverUrl": "URL till synmodellsserver",
|
||||
"refreshModels": "Uppdatera modeller",
|
||||
"selectModel": "Välj en modell",
|
||||
"saved": "OCR-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara OCR-inställningarna",
|
||||
"reachable": "Synmodellsservern är nåbar"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI, m.fl.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Uppdatera röster",
|
||||
"selectVoice": "Välj en röst",
|
||||
"saved": "TTS-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara TTS-inställningarna",
|
||||
"playing": "Spelar upp testljud",
|
||||
"testFailed": "Testet misslyckades — det gick inte att nå TTS-servern"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (lokal testning)",
|
||||
"resend": "Resend",
|
||||
"custom": "Anpassad SMTP",
|
||||
"apiKey": "API-nyckel",
|
||||
"host": "Värd",
|
||||
"port": "Port",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"tls": "TLS/SSL",
|
||||
"fromName": "Avsändarnamn",
|
||||
"fromEmail": "Avsändaradress",
|
||||
"sendTest": "Skicka testmejl",
|
||||
"userExample": "user@example.com",
|
||||
"recipientExample": "recipient@example.com",
|
||||
"saved": "SMTP-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara SMTP-inställningarna",
|
||||
"connected": "Anslutningen lyckades",
|
||||
"testSent": "Testmejl skickat till {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Uppdatera plattformen",
|
||||
"updateHint": "Ändrar ingenting som körs. Allt uppdateringen behöver listas här, och det är alltid ditt beslut.",
|
||||
"rebuild": "Bygg om gränssnittet",
|
||||
"restart": "Starta om servern",
|
||||
"upToDate": "Redan uppdaterad",
|
||||
"rebuilt": "Gränssnittet ombyggt — uppdatera sidan för att se det",
|
||||
"restarting": "Startar om — sidan återansluter av sig själv",
|
||||
"pulled": {
|
||||
"one": "Hämtade {{count}} commit",
|
||||
"other": "Hämtade {{count}} commits"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Kontrollerar…",
|
||||
"localProviders": "Lokala leverantörer",
|
||||
"remoteProviders": "Fjärrleverantörer",
|
||||
"connect": "Anslut",
|
||||
"authenticate": "Autentisera",
|
||||
"connectLocal": "Anslut lokal leverantör",
|
||||
"connectProvider": "Anslut leverantör",
|
||||
"none": "Inga leverantörer hittades.",
|
||||
"updatePiMono": "Uppdatera Pi Mono",
|
||||
"removeProvider": "Ta bort leverantör",
|
||||
"namePlaceholder": "Namn (t.ex. Min Ollama)",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"apiKey": "API-nyckel",
|
||||
"editKey": "Redigera nyckel",
|
||||
"disconnect": "Koppla från leverantör",
|
||||
"saveKey": "Spara nyckel",
|
||||
"enterKey": "Ange API-nyckel",
|
||||
"search": "Sök leverantörer…",
|
||||
"connectFailed": "Det gick inte att ansluta",
|
||||
"authFailed": "Autentiseringen misslyckades",
|
||||
"saveFailed": "Det gick inte att spara leverantören",
|
||||
"connectedTo": "Ansluten till {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Läser in behörigheter…",
|
||||
"permissionsFailed": "Det gick inte att läsa in behörigheterna.",
|
||||
"deniedUnlessGranted": "Allt nekas om det inte beviljas här.",
|
||||
"read": "Läs",
|
||||
"full": "Fullständig",
|
||||
"noAccess": "Ingen åtkomst",
|
||||
"role": "Roll",
|
||||
"permissionsSaved": "Sparade vad {{role}} kan nå",
|
||||
"copyBeforeClosing": "Kopiera det du behöver innan du stänger — inget av det kan visas igen.",
|
||||
"password": "Lösenord",
|
||||
"linuxAccount": "Linuxkonto",
|
||||
"sshKey": "Deras publika SSH-nyckel",
|
||||
"addToGitea": "Lägg till detta i deras Gitea-konto",
|
||||
"linuxIncomplete": "Kontot fungerar, men dess Linux-del blev inte klar",
|
||||
"done": "Klar",
|
||||
"addAccount": "Lägg till konto",
|
||||
"newAccount": "Nytt konto",
|
||||
"email": "E-post",
|
||||
"name": "Namn",
|
||||
"username": "Användarnamn",
|
||||
"usernameHint": "Bokstäver, siffror, punkter, bindestreck och understreck.",
|
||||
"generatePassword": "Generera ett lösenord",
|
||||
"copyPassword": "Kopiera lösenordet",
|
||||
"passwordHint": "Minst 12 tecken, med stora och små bokstäver, en siffra och ett specialtecken.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
|
||||
"passwordCopied": "Lösenordet kopierat",
|
||||
"loadingAccounts": "Läser in konton…",
|
||||
"accountsFailed": "Det gick inte att läsa in kontona.",
|
||||
"removeAccount": "Ta bort konto",
|
||||
"generateNewPassword": "Generera ett nytt lösenord",
|
||||
"serverOwner": "Serverägare",
|
||||
"copySshTitle": "Kopiera deras publika SSH-nyckel (lägg till den i deras Gitea-konto)",
|
||||
"resetPasswordTitle": "Generera ett nytt lösenord — visas en gång, och loggar ut dem överallt",
|
||||
"copyNewPassword": "Kopiera det nya lösenordet",
|
||||
"publicKeyCopied": "Publik nyckel kopierad",
|
||||
"copyFailed": "Det gick inte att kopiera — markera den och kopiera för hand",
|
||||
"repairFor": "Reparera {{email}}s Linuxkonto",
|
||||
"linuxAccountFor": "Linuxkonto: {{osUser}} — klicka för att reparera eller ersätta deras SSH-nyckel",
|
||||
"copySshFor": "Kopiera {{email}}s publika SSH-nyckel",
|
||||
"resetPasswordFor": "Återställ {{email}}s lösenord",
|
||||
"removeFor": "Ta bort {{email}}",
|
||||
"provisionPrompt": "Linuxkonto för {{email}}.\n\nKlistra in en publik SSH-nyckel för att tillåta dem att SSH:a in, eller lämna tomt för att behålla den nuvarande.",
|
||||
"createLinux": "Skapa ett Linuxkonto",
|
||||
"noLinux": "Inget Linuxkonto — klicka för att skapa ett",
|
||||
"removeTitle": "Ta bort {{email}}?",
|
||||
"removeBody": "Detta tar bort kontot och allt som tillhör det — passkeys, dashboards, sparade layouter och allt en installerad plugin sparar åt dem. Det kan inte ångras.",
|
||||
"resetTitle": "Återställa lösenordet för {{email}}?",
|
||||
"resetBody": "Ett nytt lösenord genereras och visas för dig en gång — det sparas ingenstans och kan inte hämtas i efterhand. Deras befintliga lösenord slutar fungera omedelbart, och de loggas ut överallt.",
|
||||
"resetLinuxNote": "Deras Linuxkonto ({{osUser}}) påverkas inte: det har inget lösenord, och SSH-nycklarna är oförändrade."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Ny enhet",
|
||||
"create": "Skapa",
|
||||
"none": "Inga enheter än.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Återkalla",
|
||||
"delete": "Ta bort",
|
||||
"copyFailed": "Det gick inte att kopiera — markera och kopiera manuellt",
|
||||
"createFailed": "Det gick inte att skapa applösenordet",
|
||||
"revoked": "Återkallat — den enheten slutar synkronisera",
|
||||
"revokeFailed": "Det gick inte att återkalla",
|
||||
"deleteFailed": "Det gick inte att ta bort",
|
||||
"copyField": "Kopiera {{field}}",
|
||||
"copied": "{{what}} kopierat"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Steg-för-steg-guide",
|
||||
"step1": {
|
||||
"title": "Skapa ett Google Cloud-projekt",
|
||||
"body": "Öppna sidan för att skapa projekt, ge det ett namn (till exempel ”Officer”), och klicka på:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Aktivera API:erna",
|
||||
"body": "Öppna API-biblioteket och sök efter var och en av dessa, klicka sedan på:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Konfigurera OAuth-samtyckesskärmen",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Ange din organisations namn, eller ”Officer”, som:",
|
||||
"supportEmail": "Ange din administratörs e-post som:",
|
||||
"devContact": "Lägg till din administratörs e-post under:",
|
||||
"save": "Klicka sedan på:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Ställ in målgruppen",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Om ditt team använder Google Workspace, välj — ingen verifiering behövs:",
|
||||
"external": "Välj annars, och lägg till ditt teams e-postadresser under Test users (krävs medan appen är overifierad; gräns på 100 testanvändare):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Lägg till scopes",
|
||||
"link": "Data Access",
|
||||
"body": "Öppna dataåtkomst, sök efter och lägg till scopen nedan, klicka sedan på:",
|
||||
"note": "Google klassar kalenderscopet som känsligt och Gmail-scopet som begränsat. Det är okej för Interna appar (Google Workspace) och för Externa appar i testläge; publicering i produktion med begränsade scopes kräver Google-verifiering."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Skapa OAuth-inloggningsuppgifter",
|
||||
"link": "Clients",
|
||||
"body": "Öppna klientsidan och klicka på:",
|
||||
"appType": "Application type:",
|
||||
"nameAnything": "Namn: vad som helst (till exempel ”Officer”)",
|
||||
"redirectUris": "Authorized redirect URIs — lägg till:",
|
||||
"thenClick": "Klicka sedan på:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Kopiera inloggningsuppgifterna",
|
||||
"body": "En dialog visar ditt klient-ID och din klienthemlighet. Kopiera båda och klistra in dem i fälten nedan."
|
||||
},
|
||||
"scopeGmail": "Läs e-post",
|
||||
"scopeCalendar": "Läs kalenderhändelser",
|
||||
"verifying": "Verifierar inloggningsuppgifter…",
|
||||
"valid": "Inloggningsuppgifterna är giltiga",
|
||||
"invalid": "Ogiltiga inloggningsuppgifter",
|
||||
"verifyFailed": "Verifieringsförfrågan misslyckades",
|
||||
"clientId": "Klient-ID",
|
||||
"clientSecret": "Klienthemlighet",
|
||||
"redirectUri": "Omdirigerings-URI",
|
||||
"redirectUriHint": "Lägg till denna URI i din Google OAuth-klients auktoriserade omdirigerings-URI:er",
|
||||
"saved": "Google OAuth-konfigurationen sparad",
|
||||
"saveFailed": "Det gick inte att spara Google OAuth-konfigurationen"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Googlekonto",
|
||||
"hint": "Anslut ditt Googlekonto för Kalender, Kontakter och andra Google-tjänster.",
|
||||
"connected": "Anslutet",
|
||||
"disconnect": "Koppla från",
|
||||
"connect": "Anslut Googlekonto",
|
||||
"connectedToast": "Googlekontot anslutet",
|
||||
"connectFailed": "Det gick inte att ansluta Googlekontot",
|
||||
"disconnectedToast": "Googlekontot frånkopplat",
|
||||
"disconnectFailed": "Det gick inte att koppla från Googlekontot"
|
||||
},
|
||||
"apify": {
|
||||
"token": "API-token",
|
||||
"saved": "Apify API-token sparad",
|
||||
"saveFailed": "Det gick inte att spara Apify API-token"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profil",
|
||||
"systemSettings": "Systeminställningar",
|
||||
"signOut": "Logga ut",
|
||||
"ai": "AI",
|
||||
"integrations": "Integrationer",
|
||||
"userManagement": "Användarhantering"
|
||||
},
|
||||
"tabName": "Flikens namn",
|
||||
"tabNameHint": "Klicka för att döpa om denna flik — rensa den för att gå tillbaka till sidans namn",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigering",
|
||||
"jobsRunning": "Körande jobb",
|
||||
"jobsNothingRunning": "Inget körs",
|
||||
"jobsQueued": "Köade jobb"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hem",
|
||||
"files": "Filer",
|
||||
"chat": "Chatt",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobb",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Webbläsare",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Aktivitet",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Kalender",
|
||||
"contacts": "Kontakter",
|
||||
"appStore": "Appbutik",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Kod-editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ bläddra · ctrl+shift+↑/↓ bläddra i galleri · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "stjärnmärk",
|
||||
"unstar": "avmarkera stjärna",
|
||||
"cycleHint": "ctrl+shift+←/→ bläddra · ctrl+shift+↑/↓ galleri",
|
||||
"everyShortcutHint": "ctrl+? för alla kortkommandon",
|
||||
"favouritesOnly": "Endast favoriter ({{count}})",
|
||||
"favourites": "Favoriter ({{count}})",
|
||||
"noFavourites": "Inga favoriter än — stjärnmärk en med knappen på dess rad.",
|
||||
"starNamed": "Stjärnmärk {{name}}",
|
||||
"unstarNamed": "Avmarkera stjärna för {{name}}",
|
||||
"addToFavourites": "Lägg till i favoriter",
|
||||
"removeFromFavourites": "Ta bort från favoriter",
|
||||
"addToFavouritesKey": "Lägg till i favoriter (f)",
|
||||
"removeFromFavouritesKey": "Ta bort från favoriter (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k eller ↑/↓ flytta · l eller → förhandsgranska · f favoriter · h eller ← återställ · enter behåll · esc avbryt"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gå till…",
|
||||
"noResults": "Inget matchar ”{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Kortkommandon",
|
||||
"hint": "Ctrl+? · Esc för att stänga"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Den här skärmen kunde inte renderas",
|
||||
"description": "Inget gick förlorat — detta är ett visningsfel, inte ett datafel. Agenttranskript, jobb och filer är opåverkade.",
|
||||
"unknownError": "Okänt fel",
|
||||
"tryAgain": "Försök igen",
|
||||
"reload": "Läs om",
|
||||
"goHome": "Gå hem",
|
||||
"resetHint": "Om det misslyckas igen på samma ställe är en sparad panellayout den vanliga orsaken. Återställning sätter tillbaka varje skärms paneler till standardläget.",
|
||||
"dashboardsSafe": "Dina dashboards påverkas inte.",
|
||||
"resetting": "Återställer…",
|
||||
"resetConfirm": "Ja — återställ alla skärmlayouter",
|
||||
"reset": "Återställ skärmlayouter"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Skanna om objekt",
|
||||
"title": "Skanna om officer-items (tillämpa ändringar utan omstart)",
|
||||
"failed": "Det gick inte att skanna om objekten",
|
||||
"done": "Skannade om objekt — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} uppgift",
|
||||
"other": "{{count}} uppgifter"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agent",
|
||||
"other": "{{count}} agenter"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} verktyg",
|
||||
"other": "{{count}} verktyg"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} process",
|
||||
"other": "{{count}} processer"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Gäller nya chattar — en öppen chatt behåller det den startade med.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill saknar beskrivning, så ingen agent erbjuds den.",
|
||||
"other": "{{count}} skills saknar beskrivning, så ingen agent erbjuds dem."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Rapportera ett fel",
|
||||
"title": "Rapportera ett fel",
|
||||
"describePlaceholder": "Beskriv vad som gick fel...",
|
||||
"screenshot": "Skärmbild",
|
||||
"noScreenshot": "Ingen skärmbild togs",
|
||||
"collectedNote": "Konsolloggar, webbläsarinfo och nyliga API-fel samlas in automatiskt.",
|
||||
"submitted": "Felrapport skickad",
|
||||
"failed": "Det gick inte att skicka felrapporten",
|
||||
"submit": "Skicka"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Välkommen tillbaka",
|
||||
"signInSubtitle": "Logga in på ditt konto",
|
||||
"emailOrUsername": "E-post eller användarnamn",
|
||||
"password": "Lösenord",
|
||||
"forgotPassword": "Glömt lösenordet?",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"passwordPlaceholder": "Ditt lösenord",
|
||||
"forgotTitle": "Glömt lösenord",
|
||||
"forgotSubtitle": "Ange din e-post för att få en återställningslänk",
|
||||
"email": "E-post",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"checkEmail": "Kolla din e-post",
|
||||
"checkEmailHint": "Klicka på den för att återställa ditt lösenord.",
|
||||
"resetOk": "Lösenordet återställdes. Logga in.",
|
||||
"verifying": "Verifierar…",
|
||||
"invalidLink": "Ogiltig länk",
|
||||
"invalidLinkHint": "Den här återställningslänken är ogiltig eller har gått ut.",
|
||||
"resetTitle": "Återställ lösenord",
|
||||
"resetSubtitle": "Ange ditt nya lösenord",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"minChars": "Minst 12 tecken",
|
||||
"confirmPlaceholder": "Bekräfta ditt lösenord",
|
||||
"bootstrapTitle": "Välkommen, administratör",
|
||||
"bootstrapSubtitle": "Skapa ditt administratörskonto",
|
||||
"name": "Namn",
|
||||
"username": "Användarnamn",
|
||||
"namePlaceholder": "Ditt namn",
|
||||
"adminEmailPlaceholder": "admin@example.com",
|
||||
"bootstrapOk": "Administratörskontot skapades. Logga in.",
|
||||
"debug": {
|
||||
"controls": "Kontroller:",
|
||||
"camera": "Kamera:",
|
||||
"model": "Modell:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigering",
|
||||
"spotlight": "Spotlight — gå till vilken skärm, plugin eller inställning som helst med namn",
|
||||
"thisList": "Denna lista",
|
||||
"themes": "Teman",
|
||||
"themesNote": "Webbläsaren tillämpar temat medan du flyttar dig, så du ser appen förändras i stället för att läsa namn.",
|
||||
"openBrowser": "Öppna temabläddraren",
|
||||
"prevNextTheme": "Föregående/nästa tema, utan att öppna något",
|
||||
"starCurrent": "Stjärnmärk eller avmarkera det aktuella temat, varifrån som helst",
|
||||
"inBrowser": "I temabläddraren",
|
||||
"moveList": "Flytta genom listan",
|
||||
"applyKeepBrowsing": "Tillämpa det markerade temat och fortsätt bläddra",
|
||||
"restoreStart": "Sätt tillbaka temat du började med",
|
||||
"starFocused": "Stjärnmärk eller avmarkera det markerade temat",
|
||||
"keepLooking": "Behåll det du tittar på",
|
||||
"cancelRestore": "Avbryt och återställ",
|
||||
"inSpotlight": "I Spotlight",
|
||||
"moveResults": "Flytta genom resultaten",
|
||||
"goThere": "Gå dit",
|
||||
"close": "Stäng",
|
||||
"chat": "Chatt",
|
||||
"chatNote": "Återkallning tar bara piltangenten från ett TOMT skrivfält, och varje annan tangent avslutar återkallningen — så ett utkast skrivs aldrig över.",
|
||||
"prevNextPrompt": "Föregående/nästa prompt du skickade, i skalstil",
|
||||
"send": "Skicka · på ett tomt skrivfält med något köat, skicka det nu",
|
||||
"newline": "Ny rad",
|
||||
"stopTurn": "Stoppa turen som genereras"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "Körs och köade",
|
||||
"loading": "Läser in…",
|
||||
"nothingRunning": "Inget körs",
|
||||
"history": "Historik",
|
||||
"clearAll": "Rensa alla",
|
||||
"selectJob": "Välj ett jobb",
|
||||
"notFound": "Jobbet hittades inte.",
|
||||
"backToJobs": "Tillbaka till jobb",
|
||||
"stop": "Stoppa",
|
||||
"testNotification": "Skicka en testnotis till varje konfigurerad kanal",
|
||||
"search": "Sök…",
|
||||
"deleteFinished": "Ta bort alla avslutade jobb",
|
||||
"noChannels": "Inga kanaler konfigurerade",
|
||||
"notifyFailed": "Det gick inte att skicka notisen",
|
||||
"stopRequested": "Stopp begärt",
|
||||
"stopFailed": "Det gick inte att stoppa",
|
||||
"titles": "Titlar",
|
||||
"download": "Ladda ner",
|
||||
"input": "Indata",
|
||||
"output": "Utdata",
|
||||
"steps": "Steg",
|
||||
"selectStep": "Välj ett steg eller en deluppgift för att visa utdata",
|
||||
"noOutput": "Ingen utdata än",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Kostnad:",
|
||||
"status": {
|
||||
"running": "Körs",
|
||||
"queued": "Köad",
|
||||
"completed": "Klar",
|
||||
"failed": "Misslyckades",
|
||||
"stopped": "Stoppad",
|
||||
"interrupted": "Avbruten"
|
||||
},
|
||||
"failedWithCode": "Misslyckades (avslut {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"backgroundTasks": "Bakgrundsuppgifter",
|
||||
"detached": "Frikopplad",
|
||||
"backToList": "Tillbaka till listan",
|
||||
"selectTask": "Välj en uppgift för att följa dess utdata live",
|
||||
"noneRunning": "inget körs"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Ställ in din passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Det gick inte att läsa in den här konversationen: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Inga träffar",
|
||||
"emptyFile": "Tom fil",
|
||||
"fromPlugin": "Följer med plugin-programmet {{plugin}} — skrivskyddad",
|
||||
"createFailed": "Det gick inte att skapa {{kind}}",
|
||||
"deleteFailed": "Det gick inte att ta bort {{kind}}",
|
||||
"searchPlaceholder": "Sök {{kind}}…",
|
||||
"selectOne": "Välj en {{kind}}",
|
||||
"selectToView": "Välj en {{kind}} för att visa dess innehåll",
|
||||
"delete": "Ta bort",
|
||||
"createPrompt": "Hjälp mig skapa innehållet för den här nya {{kind}}-filen",
|
||||
"improvePrompt": "Hjälp mig förstå och förbättra den här {{kind}}-filen"
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||
titleKey: 'shortcuts.navigation',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Shift+Space',
|
||||
keys: 'Ctrl+Space',
|
||||
descriptionKey: 'shortcuts.spotlight',
|
||||
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
|
||||
},
|
||||
@@ -54,7 +54,7 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||
noteKey: 'shortcuts.themesNote',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Ctrl+Shift+Space',
|
||||
keys: 'Ctrl+Shift+↑ / ↓',
|
||||
descriptionKey: 'shortcuts.openBrowser',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
revokeApiKeysByName,
|
||||
deleteApiKey,
|
||||
touchApiKey,
|
||||
type ApiKeyIdentity,
|
||||
|
||||
@@ -14,6 +14,10 @@ export type ApiKeyIdentity = {
|
||||
email: string;
|
||||
username: string | null;
|
||||
lastUsedAt: Date | null;
|
||||
/** Permission keys this credential may reach, or null for "anything not excluded". */
|
||||
permissions: string[] | null;
|
||||
/** Permission kinds it may never reach, resolved against the live registry. */
|
||||
excludedKinds: string[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -31,6 +35,8 @@ export async function findLiveApiKeyByHash(keyHash: string): Promise<ApiKeyIdent
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
permissions: apiKeys.permissions,
|
||||
excludedKinds: apiKeys.excludedKinds,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.innerJoin(users, eq(users.id, apiKeys.userId))
|
||||
@@ -51,6 +57,10 @@ type CreateApiKeyParams = {
|
||||
keyHash: string;
|
||||
prefix: string;
|
||||
expiresAt?: Date | null;
|
||||
/** Omit or pass null for "anything not excluded"; a list narrows the key to those permission keys. */
|
||||
permissions?: string[] | null;
|
||||
/** Permission kinds this key may never reach, whatever the registry grows to hold. */
|
||||
excludedKinds?: string[] | null;
|
||||
};
|
||||
|
||||
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
|
||||
@@ -62,6 +72,8 @@ export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySe
|
||||
keyHash: params.keyHash,
|
||||
prefix: params.prefix,
|
||||
expiresAt: params.expiresAt ?? null,
|
||||
permissions: params.permissions ?? null,
|
||||
excludedKinds: params.excludedKinds ?? null,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
@@ -75,6 +87,8 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
|
||||
userId: apiKeys.userId,
|
||||
name: apiKeys.name,
|
||||
prefix: apiKeys.prefix,
|
||||
permissions: apiKeys.permissions,
|
||||
excludedKinds: apiKeys.excludedKinds,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
@@ -85,6 +99,24 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
|
||||
.orderBy(sql`${apiKeys.createdAt} desc`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke every live key a user holds under one name.
|
||||
*
|
||||
* For credentials the PLATFORM mints on someone's behalf rather than the user minting for themselves — a
|
||||
* commander's key is named `commander:<dirName>`, so deleting that commander revokes exactly its own and
|
||||
* recreating one under a reused directory name cannot leave a live key nothing points at.
|
||||
*
|
||||
* Revokes rather than deletes, the same as the by-id path: the row is what the audit reads afterwards.
|
||||
*/
|
||||
export async function revokeApiKeysByName(userId: number, name: string): Promise<number> {
|
||||
const rows = await db
|
||||
.update(apiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(apiKeys.userId, userId), eq(apiKeys.name, name), isNull(apiKeys.revokedAt)))
|
||||
.returning({ id: apiKeys.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke one of the caller's own keys. Scoped by userId in the same statement as the id, so a caller
|
||||
* naming someone else's key id revokes nothing and is told the same "not found" as for an id that never
|
||||
|
||||
@@ -21,10 +21,27 @@ import { users } from '../auth/schema';
|
||||
//
|
||||
// ── Authority ──
|
||||
//
|
||||
// A key currently carries its owner's full authority — deliberately, as the first cut. That is not a
|
||||
// hole: it is exactly what the password that made it could already do, so a key is never an escalation.
|
||||
// A `scopes` column is the intended next step, and the resolver is written so adding one touches the
|
||||
// resolver and this table, nothing else.
|
||||
// A key carries its owner's authority, NARROWED by two columns when they are set. Both null means
|
||||
// unnarrowed — what every key minted by a human still is, and what the mobile apps hold: exactly what the
|
||||
// password that made it could already do, so a key is never an escalation.
|
||||
//
|
||||
// There are two of them because there are two shapes of "less than everything", and only one of them
|
||||
// survives contact with a platform that keeps growing:
|
||||
//
|
||||
// `permissions` — an ALLOW list. Exact, and a snapshot. Right for a credential with one job.
|
||||
// `excludedKinds` — kinds this key may never reach, whatever exists. Right for a credential whose job
|
||||
// is open-ended, which is every commander: it should reach the notifier, the mailer,
|
||||
// sub-agents and a plugin installed six months from now, and never the plugin
|
||||
// INSTALLER. An allow list cannot say that — it names what exists today, so tomorrow's
|
||||
// plugin is missing from it and fails as a bare 403 with nothing saying why.
|
||||
//
|
||||
// Both narrow and neither widens: the result is still intersected with what the account itself holds. See
|
||||
// `permissions/authorize.ts` → `decideApiRequest`.
|
||||
//
|
||||
// **The owner is the reason this exists.** A Super Admin bypasses every role check, so a credential
|
||||
// carrying the owner's identity is unbounded by construction, and a commander is an agent holding one
|
||||
// unattended for months. The narrowing is applied BEFORE the owner bypass, which makes it the only thing
|
||||
// in the platform that can bound them.
|
||||
export const apiKeys = pgTable(
|
||||
'api_keys',
|
||||
{
|
||||
@@ -38,6 +55,21 @@ export const apiKeys = pgTable(
|
||||
keyHash: text('key_hash').notNull(),
|
||||
/** First characters of the secret, in the clear, for display only. */
|
||||
prefix: text('prefix').notNull(),
|
||||
/**
|
||||
* Permission keys this credential may reach, or null for "anything not excluded below".
|
||||
*
|
||||
* An empty array is NOT the same as null and is not a mistake to guard against: it is a key that can
|
||||
* reach nothing, which is a coherent thing to mint and a safe thing to default to.
|
||||
*/
|
||||
permissions: text('permissions').array(),
|
||||
/**
|
||||
* Permission KINDS this credential may never reach — `admin`, `execution`, `confined`, `app`, `core`.
|
||||
*
|
||||
* Resolved at request time against the live registry rather than expanded into `permissions` at
|
||||
* creation, which is the whole difference: a commander minted today is bounded away from
|
||||
* administration forever, and still reaches a plugin installed next year without being re-issued.
|
||||
*/
|
||||
excludedKinds: text('excluded_kinds').array(),
|
||||
/**
|
||||
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
|
||||
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
|
||||
|
||||
@@ -3,9 +3,13 @@ export {
|
||||
createCommandHqCategory,
|
||||
renameCommandHqCategory,
|
||||
deleteCommandHqCategory,
|
||||
createCommandHqSession,
|
||||
createCommander,
|
||||
setCommanderSessionId,
|
||||
getCommandHqSessionById,
|
||||
listCommandHqSessions,
|
||||
listCommandHqSessionIds,
|
||||
listCommandHqOwnedSessionIds,
|
||||
listAllCommanders,
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
deleteCommandHqSession,
|
||||
@@ -31,6 +35,7 @@ export {
|
||||
export type {
|
||||
CommandHqCategory,
|
||||
CommandHqSessionRow,
|
||||
NewCommander,
|
||||
CommandHqTag,
|
||||
CommandHqWatcherFire,
|
||||
CommandHqTurnCost,
|
||||
|
||||
@@ -54,7 +54,12 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
|
||||
|
||||
export type CommandHqSessionRow = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
/** Claude's transcript uuid — null until the first turn reports one. See the schema file's docstring. */
|
||||
sessionId: string | null;
|
||||
/** `command_hq_sessions.dir_name` — the directory under `getCommandersDir(email)` that IS this commander. */
|
||||
dirName: string;
|
||||
/** What this commander operates on. Seeded with one path at creation; grown in conversation. */
|
||||
referencePaths: string[];
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
@@ -69,6 +74,8 @@ export type CommandHqSessionRow = {
|
||||
const SESSION_ROW_COLUMNS = {
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
dirName: commandHqSessions.dirName,
|
||||
referencePaths: commandHqSessions.referencePaths,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
@@ -78,30 +85,67 @@ const SESSION_ROW_COLUMNS = {
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
};
|
||||
|
||||
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
|
||||
|
||||
/**
|
||||
* File a session as a commander. Upsert on `(user_id, session_id)` — a second call (a retried request, a
|
||||
* client that raced itself) is a no-op rather than a duplicate-key error, and does NOT move an
|
||||
* already-categorised commander back to uncategorised.
|
||||
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
|
||||
* (`api/command-hq/directory.ts`): this file is Postgres only, and `dirName` has to come back from HERE
|
||||
* before anything can be created on disk, since it is derived from the row's own id.
|
||||
*
|
||||
* Two statements in one transaction rather than a single `INSERT ... RETURNING`: `dirName` is derived FROM
|
||||
* the id Postgres assigns on insert, so the row has to exist — with some not-null placeholder — before its
|
||||
* own directory name is knowable. Nothing else here is a candidate: a name can collide (two commanders
|
||||
* called "Backups"), a random string is one more thing to keep unique, and the id already IS unique and
|
||||
* stable the moment it exists.
|
||||
*/
|
||||
export async function createCommandHqSession(
|
||||
userId: number,
|
||||
sessionId: string,
|
||||
categoryId: number | null = null,
|
||||
name: string | null = null,
|
||||
): Promise<CommandHqSessionRow> {
|
||||
export async function createCommander(input: NewCommander): Promise<CommandHqSessionRow> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [inserted] = await tx
|
||||
.insert(commandHqSessions)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
sessionId: null,
|
||||
dirName: '', // fixed up below, once the id it is derived from exists — never visible outside this transaction
|
||||
referencePaths: [input.referencePath],
|
||||
name: input.name,
|
||||
categoryId: input.categoryId ?? null,
|
||||
})
|
||||
.returning({ id: commandHqSessions.id });
|
||||
const [row] = await tx
|
||||
.update(commandHqSessions)
|
||||
.set({ dirName: String(inserted!.id) })
|
||||
.where(eq(commandHqSessions.id, inserted!.id))
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a commander at its current conversation — the transcript id, once the harness reports one. Called
|
||||
* from `fileAsCommander` (`api/chat/websocket.ts`) the moment `session:claude` arrives, correlated by the
|
||||
* commander's own row id (`msg.commanderId`), not by upserting on the transcript id the way this table used
|
||||
* to be filed: the row already exists by the time any turn spawns, per `docs/commander-directory.md`, so
|
||||
* there is nothing left to upsert.
|
||||
*
|
||||
* A plain UPDATE, not gated on the column currently being null: a dead session replaced by a fresh one in
|
||||
* the same directory repoints the same row, which is the whole reason this is a pointer and not an identity.
|
||||
*/
|
||||
export async function setCommanderSessionId(userId: number, id: number, sessionId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({ sessionId })
|
||||
.where(and(eq(commandHqSessions.id, id), eq(commandHqSessions.userId, userId)))
|
||||
.returning({ id: commandHqSessions.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** One commander's row, by its OWN id — for a caller (the composer, mid-creation) that has no transcript yet. */
|
||||
export async function getCommandHqSessionById(userId: number, id: number): Promise<CommandHqSessionRow | null> {
|
||||
const [row] = await db
|
||||
.insert(commandHqSessions)
|
||||
.values({ userId, sessionId, categoryId, name })
|
||||
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
|
||||
// the caller still needs an id back (to attach tags, say) even when this call raced an earlier one.
|
||||
if (row) return row;
|
||||
const [existing] = await db
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
.from(commandHqSessions)
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
|
||||
return existing!;
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.id, id)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,13 +173,66 @@ const isCategoryNull = () => isNull(commandHqSessions.categoryId);
|
||||
* Just the session ids this user has filed — the whole shape `/chat`'s list needs to exclude them, and
|
||||
* `/command-hq`'s grid needs to include only them. One query, no join against transcripts (they are not in
|
||||
* Postgres).
|
||||
*
|
||||
* A commander between `POST /commanders` and its first turn has no transcript yet — `sessionId` is null —
|
||||
* and is filtered out here rather than returned as a literal `null` in the set: there is no transcript id
|
||||
* to exclude from `/chat`'s list, and a null in a `Set<string>` would only ever fail to match anything.
|
||||
*/
|
||||
export async function listCommandHqSessionIds(userId: number): Promise<Set<string>> {
|
||||
const rows = await db
|
||||
.select({ sessionId: commandHqSessions.sessionId })
|
||||
.from(commandHqSessions)
|
||||
.where(eq(commandHqSessions.userId, userId));
|
||||
return new Set(rows.map((r) => r.sessionId));
|
||||
.where(and(eq(commandHqSessions.userId, userId), isNotNullSessionId()));
|
||||
return new Set(rows.map((r) => r.sessionId!));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every transcript that belongs to a commander: the commanders themselves AND the agents they spawned.
|
||||
*
|
||||
* What `/chat`'s list hides. The set above is not enough — it holds the commanders, and a commander that
|
||||
* fires once an hour spawns a child per run, each with its own transcript titled after whatever its first
|
||||
* message happened to be. Thirteen of them under one directory was what made this obvious: twelve reading
|
||||
* `Read /home/.../procedures/classify-t…`, filling the conversation list with a loop's bookkeeping.
|
||||
*
|
||||
* Separate from `listCommandHqSessionIds` rather than replacing it, because a CHILD is not a commander —
|
||||
* `isCommander` on a session must stay false for one, or the composer would offer it the handoff watcher.
|
||||
*/
|
||||
export async function listCommandHqOwnedSessionIds(userId: number): Promise<Set<string>> {
|
||||
const [commanders, children] = await Promise.all([
|
||||
listCommandHqSessionIds(userId),
|
||||
db
|
||||
.select({ id: commandHqSpawned.spawnedSessionId })
|
||||
.from(commandHqSpawned)
|
||||
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
|
||||
.where(eq(commandHqSessions.userId, userId)),
|
||||
]);
|
||||
for (const row of children) commanders.add(row.id);
|
||||
return commanders;
|
||||
}
|
||||
|
||||
// drizzle's `eq` cannot express `IS NOT NULL` either.
|
||||
const isNotNullSessionId = () => sql`${commandHqSessions.sessionId} is not null`;
|
||||
|
||||
/**
|
||||
* Every commander on the machine, across every account — id, owner and directory only.
|
||||
*
|
||||
* The one query here that is not scoped to a user, and it exists for a caller that is not a request:
|
||||
* `servers/api/command-hq/watcher-revive.ts` ticks over all of them looking for a watch whose process
|
||||
* has gone. There is no `userId` to scope by at that point, and the reviver needs the owner's id in
|
||||
* order to resolve their email and their home — so it is returned rather than looked up per row.
|
||||
*
|
||||
* Narrow on purpose. A background sweep has no business reading names, categories or cost, and the next
|
||||
* caller that wants those should say so by widening deliberately.
|
||||
*/
|
||||
export async function listAllCommanders(): Promise<{ userId: number; sessionId: string; dirName: string }[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: commandHqSessions.userId,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
dirName: commandHqSessions.dirName,
|
||||
})
|
||||
.from(commandHqSessions);
|
||||
return rows.filter((row): row is { userId: number; sessionId: string; dirName: string } => row.sessionId !== null);
|
||||
}
|
||||
|
||||
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
|
||||
@@ -352,7 +449,13 @@ export async function deleteCommandHqSession(userId: number, sessionId: string):
|
||||
*/
|
||||
export async function purgeCommandHqSessions(userId: number, liveSessionIds: Set<string>): Promise<number> {
|
||||
const all = await listCommandHqSessions(userId);
|
||||
const dead = all.filter((row) => !liveSessionIds.has(row.sessionId)).map((row) => row.sessionId);
|
||||
// A row with `sessionId: null` has no transcript YET — it is mid-creation, not dead — so it is never a
|
||||
// purge candidate regardless of what `liveSessionIds` holds. Only a row that HAD a transcript and lost it
|
||||
// is "dead" in the sense this sweep exists to clean up.
|
||||
const dead = all
|
||||
.filter((row): row is CommandHqSessionRow & { sessionId: string } => row.sessionId !== null)
|
||||
.filter((row) => !liveSessionIds.has(row.sessionId))
|
||||
.map((row) => row.sessionId);
|
||||
if (!dead.length) return 0;
|
||||
const rows = await db
|
||||
.delete(commandHqSessions)
|
||||
@@ -508,7 +611,11 @@ export async function parentCommanderOf(spawnedSessionId: string): Promise<Spawn
|
||||
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
|
||||
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
// `row.sessionId` is null for a commander mid-creation — no live loop exists to have spawned anything yet
|
||||
// at that point, so a row in this state here would mean the query above found something it should not
|
||||
// have been able to. Treated as "no parent" rather than asserted away, for the same reason every other
|
||||
// null here is: an unexpected fact is not this function's to paper over.
|
||||
if (!row || row.sessionId === null) return null;
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
|
||||
@@ -65,13 +65,17 @@ export const commandHqCategories = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per commander — the mapping from a Claude transcript to Command HQ's own metadata about it.
|
||||
* One row per commander. The ROW is the commander now — `docs/commander-directory.md` — with its own
|
||||
* directory on disk (`dirName`) and its own id, not a mapping keyed on a Claude transcript. `sessionId` is
|
||||
* a nullable POINTER to whichever conversation is current, set once the harness reports one and free to be
|
||||
* repointed if that conversation dies and a fresh one replaces it in the same directory.
|
||||
*
|
||||
* `/chat` and `/command-hq` read the SAME underlying session list (transcripts on disk) and this table is
|
||||
* the filter between them: `/chat` excludes any session with a row here, `/command-hq` includes only
|
||||
* sessions that have one. The table is never itself the list of what exists — a row whose transcript is
|
||||
* gone is simply absent from both lists, on purpose. See `docs/command-hq.md` → "The /chat ↔ /command-hq
|
||||
* relationship".
|
||||
* `/chat` and `/command-hq` still read the SAME underlying session list (transcripts on disk) once a row
|
||||
* has a `sessionId`, and this table is still the filter between them: `/chat` excludes any session with a
|
||||
* row here, `/command-hq` includes rows that have a live transcript. A row whose transcript is gone is
|
||||
* simply absent from both lists, on purpose. See `docs/command-hq.md` → "The /chat ↔ /command-hq
|
||||
* relationship" — written before the identity change, still correct for a row that already has a
|
||||
* transcript.
|
||||
*/
|
||||
export const commandHqSessions = pgTable(
|
||||
'command_hq_sessions',
|
||||
@@ -85,8 +89,38 @@ export const commandHqSessions = pgTable(
|
||||
* for the same reason: everything that joins back to a session on disk (models, events, this table)
|
||||
* uses the id the transcript itself carries, because that is the one Officer's `sessionId` and Claude's
|
||||
* id spaces both agree on only sometimes.
|
||||
*
|
||||
* **Nullable, since 2026-08-24 (`docs/commander-directory.md`).** This used to be the commander's
|
||||
* IDENTITY — the row was created only once a transcript existed, which is what forced a commander to be
|
||||
* created after its own working directory rather than before. The row's own `id`/`dirName` is the
|
||||
* identity now; this is a POINTER to whichever conversation is current, null from the moment the row is
|
||||
* created until the first turn reports a real transcript id, and free to be repointed later if a dead
|
||||
* session is replaced by a fresh one in the same directory.
|
||||
*/
|
||||
sessionId: text('session_id').notNull(),
|
||||
sessionId: text('session_id'),
|
||||
/**
|
||||
* The directory under `getCommandersDir(email)` that IS this commander —
|
||||
* `$DATA_PATH/<email>/commanders/<dirName>/`, holding `CLAUDE.md` and `state/`. See
|
||||
* `docs/commander-directory.md`.
|
||||
*
|
||||
* Derived from this row's own serial `id` (`String(id)`) the moment the row is inserted — nothing else
|
||||
* is guaranteed unique and stable before a name is even chosen. Not the transcript id: that does not
|
||||
* exist yet at creation, which is the entire point of this table gaining its own identity.
|
||||
*/
|
||||
dirName: text('dir_name').notNull(),
|
||||
/**
|
||||
* What the commander operates ON — not where it lives. Seeded with one path at creation (the start
|
||||
* dialog collects exactly one; asking for a list up front gets a worse answer than asking later) and
|
||||
* grown as the commander and the owner agree on more in conversation.
|
||||
*
|
||||
* A Postgres `text[]` rather than `jsonb`: every element is a plain path string with no structure of
|
||||
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
||||
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
||||
*/
|
||||
referencePaths: text('reference_paths')
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
@@ -130,6 +164,14 @@ export const commandHqSessions = pgTable(
|
||||
watcherStoppedAt: timestamp('watcher_stopped_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// `sessionId` is nullable now, and this is left as a PLAIN unique index rather than a partial one
|
||||
// (`.where(isNotNull(...))`) deliberately, not by default: Postgres already treats every NULL as
|
||||
// distinct from every other NULL in a unique index, so any number of a user's commanders can sit with
|
||||
// `session_id: null` at once — which is exactly the window between `POST /commanders` creating the row
|
||||
// and the first turn reporting a real transcript id, for however many commanders are mid-creation at
|
||||
// the same time. The moment `session_id` is set, uniqueness still applies: one commander per transcript,
|
||||
// per user, same guarantee this index always gave. A partial index would enforce the identical rule with
|
||||
// an extra clause to keep in sync — nothing here needs it.
|
||||
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ export {
|
||||
createRole,
|
||||
renameRole,
|
||||
deleteRole,
|
||||
setRoleGrants,
|
||||
type RoleRow,
|
||||
type CreateRoleResult,
|
||||
type RenameRoleResult,
|
||||
type DeleteRoleResult,
|
||||
type SetRoleGrantsResult,
|
||||
} from './queries';
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { eq, like } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { roles } from './schema';
|
||||
import { createRole, getRole, seedRole, setRoleGrants } from './queries';
|
||||
|
||||
/*
|
||||
Phase 3 of docs/roles-and-teams.md: `grantsPostgres`/`grantsDocker` replace `rolePermitsDevTools()`'s
|
||||
hardcoded `role === 'Developer'`. These tests hit the real database, like chat-session-cleanup.test.ts —
|
||||
the thing being pinned is column plumbing (does the value survive a write and a read back), which a fake
|
||||
store would pass regardless of whether the SQL is right.
|
||||
|
||||
Every role name here is prefixed `zz-test-role-`, cleaned up in `afterEach` by prefix rather than by an
|
||||
exact list, so a test that fails before reaching its own cleanup does not leave a row for the next run to
|
||||
trip over.
|
||||
*/
|
||||
|
||||
const PREFIX = 'zz-test-role-';
|
||||
const name = (suffix: string) => `${PREFIX}${suffix}`;
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(roles).where(like(roles.name, `${PREFIX}%`));
|
||||
});
|
||||
|
||||
describe('grants round trip through create and read', () => {
|
||||
test('a role created with both flags true reads back with both true', async () => {
|
||||
const created = await createRole({ name: name('both'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(true);
|
||||
expect(created.role.grantsDocker).toBe(true);
|
||||
|
||||
const read = await getRole(name('both'));
|
||||
expect(read?.grantsPostgres).toBe(true);
|
||||
expect(read?.grantsDocker).toBe(true);
|
||||
});
|
||||
|
||||
test('omitted flags default to false, matching the column default', async () => {
|
||||
const created = await createRole({ name: name('bare') });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(false);
|
||||
expect(created.role.grantsDocker).toBe(false);
|
||||
});
|
||||
|
||||
test('a role with grantsDocker false does not carry it after a Postgres-only grant', async () => {
|
||||
const created = await createRole({ name: name('pg-only'), grantsPostgres: true });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(true);
|
||||
expect(created.role.grantsDocker).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRoleGrants', () => {
|
||||
test('changes only the flags supplied, leaving the other as it was', async () => {
|
||||
const created = await createRole({ name: name('partial'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
const updated = await setRoleGrants(name('partial'), { grantsDocker: false });
|
||||
expect(updated).toEqual({
|
||||
ok: true,
|
||||
role: expect.objectContaining({ grantsPostgres: true, grantsDocker: false }),
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses a system role, so an edit cannot look like it worked and then revert on the next deploy', async () => {
|
||||
// isSystem is not settable through createRole, so insert the row directly — this is standing in for
|
||||
// one of the four seeded rows, which this function must never touch.
|
||||
await db.insert(roles).values({ name: name('system'), isSystem: true, grantsPostgres: false });
|
||||
|
||||
const result = await setRoleGrants(name('system'), { grantsPostgres: true });
|
||||
expect(result).toEqual({ ok: false, reason: 'system' });
|
||||
|
||||
// And the value is genuinely unchanged, not just the result code.
|
||||
expect((await getRole(name('system')))?.grantsPostgres).toBe(false);
|
||||
});
|
||||
|
||||
test('reports not-found for a role that does not exist', async () => {
|
||||
expect(await setRoleGrants(name('missing'), { grantsPostgres: true })).toEqual({
|
||||
ok: false,
|
||||
reason: 'not-found',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedRole', () => {
|
||||
test('is idempotent: seeding the same system role twice does not duplicate or error', async () => {
|
||||
const first = await seedRole({ name: name('seed'), isSystem: true, grantsPostgres: true });
|
||||
const second = await seedRole({ name: name('seed'), isSystem: true, grantsPostgres: true });
|
||||
expect(first).toBe(true); // created
|
||||
expect(second).toBe(false); // already present
|
||||
|
||||
const rows = await db.select().from(roles).where(eq(roles.name, name('seed')));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('corrects a system role whose grants were wrong, without touching a non-system role the same way', async () => {
|
||||
await seedRole({ name: name('correct-sys'), isSystem: true, grantsPostgres: true, grantsDocker: true });
|
||||
// Simulate the migration case: a `db:push` column-add left the row at the boolean default.
|
||||
await db.update(roles).set({ grantsPostgres: false, grantsDocker: false }).where(eq(roles.name, name('correct-sys')));
|
||||
expect(await getRole(name('correct-sys'))).toMatchObject({ grantsPostgres: false, grantsDocker: false });
|
||||
|
||||
// Re-running the same seed call is the fix — no separate migration script.
|
||||
await seedRole({ name: name('correct-sys'), isSystem: true, grantsPostgres: true, grantsDocker: true });
|
||||
expect(await getRole(name('correct-sys'))).toMatchObject({ grantsPostgres: true, grantsDocker: true });
|
||||
});
|
||||
|
||||
test('never rewrites description once a row exists, only grants', async () => {
|
||||
await seedRole({ name: name('desc'), description: 'original', isSystem: true, grantsPostgres: false });
|
||||
// An operator's edit, which the next deploy's seed call must not revert.
|
||||
await db.update(roles).set({ description: 'edited by an operator' }).where(eq(roles.name, name('desc')));
|
||||
|
||||
await seedRole({ name: name('desc'), description: 'original', isSystem: true, grantsPostgres: true });
|
||||
|
||||
const row = await getRole(name('desc'));
|
||||
expect(row?.description).toBe('edited by an operator');
|
||||
expect(row?.grantsPostgres).toBe(true);
|
||||
});
|
||||
|
||||
test('does not touch a non-system role at all, even if seeded once as one', async () => {
|
||||
// A non-system row's grants belong to whoever created it; seedRole's correction pass only applies
|
||||
// when `isSystem` is true in the call.
|
||||
const created = await createRole({ name: name('custom'), grantsPostgres: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
await seedRole({ name: name('custom'), isSystem: false, grantsPostgres: false });
|
||||
|
||||
expect((await getRole(name('custom')))?.grantsPostgres).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,8 @@ export type RoleRow = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
provisioningScript: string | null;
|
||||
grantsPostgres: boolean;
|
||||
grantsDocker: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@@ -24,13 +25,14 @@ export async function listRoles(): Promise<(RoleRow & { userCount: number })[]>
|
||||
name: roles.name,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
provisioningScript: roles.provisioningScript,
|
||||
grantsPostgres: roles.grantsPostgres,
|
||||
grantsDocker: roles.grantsDocker,
|
||||
createdAt: roles.createdAt,
|
||||
userCount: sql<number>`count(${users.id})::int`,
|
||||
})
|
||||
.from(roles)
|
||||
.leftJoin(users, eq(users.role, roles.name))
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.grantsPostgres, roles.grantsDocker, roles.createdAt)
|
||||
.orderBy(asc(roles.name));
|
||||
return rows;
|
||||
}
|
||||
@@ -41,22 +43,48 @@ export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||
* Seed a role if it is not there. Idempotent, but NOT symmetric across columns.
|
||||
*
|
||||
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
|
||||
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
|
||||
* deploy — so the seed establishes a row and never touches it again.
|
||||
* platform's life. `description` is never rewritten once a row exists: an upsert there would mean a
|
||||
* description edited on one server is silently reverted by the next deploy — so that column is set on
|
||||
* insert and never touched again.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` are the opposite, and only for a role seeded with `isSystem: true`: they
|
||||
* ARE re-asserted on every run, because for a system role they are not an operator's data, they are the
|
||||
* platform's own declaration of what the role means — the same standing as its name. Two things need that:
|
||||
*
|
||||
* - A row can predate these columns. `db:push` adds them with `default false`, so an existing `Developer`
|
||||
* row gets `grantsPostgres: false, grantsDocker: false` the moment the column lands — an insert-only
|
||||
* seed would leave it there forever and silently take away dev tooling nobody asked to revoke.
|
||||
* - It is also what makes the guard in `setRoleGrants` (below) honest. That function refuses to touch a
|
||||
* system role's grants specifically so an edit cannot LOOK like it worked and then vanish on the next
|
||||
* deploy; this is the other half of that promise — the seed is the only writer, and it always writes
|
||||
* the same thing.
|
||||
*
|
||||
* A non-system role's grants are never touched here — those belong to whoever created the role, through
|
||||
* `setRoleGrants`, and this function has no opinion about a row it did not seed.
|
||||
*/
|
||||
export async function seedRole(row: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isSystem?: boolean;
|
||||
grantsPostgres?: boolean;
|
||||
grantsDocker?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const isSystem = row.isSystem ?? false;
|
||||
const grantsPostgres = row.grantsPostgres ?? false;
|
||||
const grantsDocker = row.grantsDocker ?? false;
|
||||
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem, grantsPostgres, grantsDocker })
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning({ name: roles.name });
|
||||
|
||||
if (inserted.length === 0 && isSystem) {
|
||||
await db.update(roles).set({ grantsPostgres, grantsDocker }).where(eq(roles.name, row.name));
|
||||
}
|
||||
return inserted.length > 0;
|
||||
}
|
||||
|
||||
@@ -83,14 +111,30 @@ export type CreateRoleResult = { ok: true; role: RoleRow } | { ok: false; reason
|
||||
* Create a role at runtime. `isSystem` is always false here — a role the platform's own code names by
|
||||
* string (Developer, Super Admin, Member; see the schema comment) is one seeded at startup, never one
|
||||
* typed into a form, and nothing reachable through this function may claim otherwise.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` default to false, same as the column defaults — a role created through a
|
||||
* form should not hand out a Postgres login and a Docker daemon because nobody unchecked a box. There is
|
||||
* no `isSystem` guard needed on the write itself: a role created here is never system, so nothing later
|
||||
* pins these two values the way `seedRole` pins Developer's.
|
||||
*/
|
||||
export async function createRole(input: { name: string; description?: string | null }): Promise<CreateRoleResult> {
|
||||
export async function createRole(input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
grantsPostgres?: boolean;
|
||||
grantsDocker?: boolean;
|
||||
}): Promise<CreateRoleResult> {
|
||||
const name = input.name.trim();
|
||||
if (!name) return { ok: false, reason: 'invalid-name' };
|
||||
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name, description: input.description?.trim() || null, isSystem: false })
|
||||
.values({
|
||||
name,
|
||||
description: input.description?.trim() || null,
|
||||
isSystem: false,
|
||||
grantsPostgres: input.grantsPostgres ?? false,
|
||||
grantsDocker: input.grantsDocker ?? false,
|
||||
})
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning();
|
||||
const role = inserted[0];
|
||||
@@ -98,15 +142,52 @@ export async function createRole(input: { name: string; description?: string | n
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type SetRoleGrantsResult = { ok: true; role: RoleRow } | { ok: false; reason: 'not-found' | 'system' };
|
||||
|
||||
/**
|
||||
* Change a role's provisioning flags after it has been created.
|
||||
*
|
||||
* Refuses on `isSystem`, and for a different reason than `renameRole`'s refusal: renaming protects the
|
||||
* NAME code depends on, this protects a PROMISE. `seedRole` re-asserts a system role's grants on every
|
||||
* `postdb:push`, so writing here would look like it worked and then silently revert on the next deploy —
|
||||
* exactly the "says something untrue" trap `manage-users.ts` calls out for the Postgres-follows-role case.
|
||||
* A custom role has no seed pinning it, so this is the only place its grants are ever set after creation.
|
||||
*
|
||||
* Only the two flags supplied are changed; the other keeps its current value, so a caller adjusting just
|
||||
* `grantsDocker` cannot accidentally reset `grantsPostgres` to false.
|
||||
*/
|
||||
export async function setRoleGrants(
|
||||
name: string,
|
||||
grants: { grantsPostgres?: boolean; grantsDocker?: boolean },
|
||||
): Promise<SetRoleGrantsResult> {
|
||||
const existing = await getRole(name);
|
||||
if (!existing) return { ok: false, reason: 'not-found' };
|
||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||
|
||||
// Same re-check as renameRole/deleteRole, same reason: the row must not have become system between the
|
||||
// read above and this write.
|
||||
const [role] = await db
|
||||
.update(roles)
|
||||
.set({
|
||||
grantsPostgres: grants.grantsPostgres ?? existing.grantsPostgres,
|
||||
grantsDocker: grants.grantsDocker ?? existing.grantsDocker,
|
||||
})
|
||||
.where(and(eq(roles.name, name), eq(roles.isSystem, false)))
|
||||
.returning();
|
||||
if (!role) return { ok: false, reason: 'system' };
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type RenameRoleResult =
|
||||
| { ok: true; role: RoleRow }
|
||||
| { ok: false; reason: 'not-found' | 'system' | 'invalid-name' | 'duplicate' };
|
||||
|
||||
/**
|
||||
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `rolePermitsDevTools()`
|
||||
* tests for `'Developer'` by string, `bootstrap.ts` writes `'Super Admin'`, and `users.role` defaults to
|
||||
* `'Member'`. Renaming one of those four is a code change across three call sites that has not happened,
|
||||
* not a data edit — so this refuses rather than quietly breaking them.
|
||||
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `scripts/seed-roles.ts`
|
||||
* matches its four rows by name on every deploy, `bootstrap.ts` writes `'Super Admin'`, and `users.role`
|
||||
* defaults to `'Member'`. Renaming one of those four is a code change across those call sites that has not
|
||||
* happened, not a data edit — a rename would leave the seed re-creating the old name from under it rather
|
||||
* than correcting the row that moved.
|
||||
*
|
||||
* `users.role` and `role_permissions.role` are foreign keys onto this table with `ON UPDATE CASCADE`
|
||||
* (phase 2 part 2), so the `UPDATE` below moves every holder and every grant automatically, in the same
|
||||
|
||||
@@ -13,7 +13,7 @@ import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
* changes.
|
||||
*
|
||||
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
|
||||
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
|
||||
* appears in `scripts/seed-roles.ts`'s seed table, in report text and in whatever a plugin has written down.
|
||||
*
|
||||
* ── What is NOT here ──
|
||||
*
|
||||
@@ -33,20 +33,30 @@ export const roles = pgTable('roles', {
|
||||
/**
|
||||
* A role the platform's own code names, so it cannot be deleted or renamed.
|
||||
*
|
||||
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
|
||||
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
|
||||
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
|
||||
* discovering it as a runtime failure somewhere else.
|
||||
* `scripts/seed-roles.ts` matches `'Developer'`, `'Admin'` and `'Member'` by name on every deploy,
|
||||
* `bootstrap.ts` sets `'Super Admin'`, and the users table defaults to `'Member'`. Deleting or renaming
|
||||
* one of those is not a data change, it is a code change that has not happened yet — so the delete and
|
||||
* rename paths refuse on this flag rather than discovering it as a runtime failure somewhere else.
|
||||
*/
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
/**
|
||||
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
|
||||
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
|
||||
* becomes a row. Null today, and nothing reads it.
|
||||
* Per-role provisioning, as a closed vocabulary rather than a script.
|
||||
*
|
||||
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
|
||||
* column that arrives with the feature that uses it is a second migration for no gain.
|
||||
* D8 in docs/roles-and-teams.md proposed a `provisioningScript` column: a shell script stored on the row
|
||||
* and run when an account is given the role. That was rejected before it was built — it is arbitrary
|
||||
* root-level code living in a database row, and D8's own warning explains why that is worse than it
|
||||
* looks: `usermod -aG docker` is root by a longer route
|
||||
* (`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet
|
||||
* seed). A role editable at runtime must not be a way to grant root by writing a string into a form.
|
||||
*
|
||||
* So the row CHOOSES from options the platform code already knows how to carry out safely, the same
|
||||
* shape as `multiarch` on a plugin's `osDependencies` (`src/servers/plugins/manifest.ts`): a fixed list
|
||||
* instead of a free string, so the manifest picks rather than supplies. `os-user-postgres.ts` and
|
||||
* `os-user-docker.ts` still own how each is provisioned; these columns only say whether a given role's
|
||||
* accounts get it.
|
||||
*/
|
||||
provisioningScript: text('provisioning_script'),
|
||||
grantsPostgres: boolean('grants_postgres').notNull().default(false),
|
||||
/** A rootless Docker daemon. See `grantsPostgres` above and `os-user-docker.ts` for what it costs per member. */
|
||||
grantsDocker: boolean('grants_docker').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
+3
-1
@@ -20,6 +20,7 @@ import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
|
||||
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
|
||||
import { startNudger } from './servers/api/chat/nudger';
|
||||
import { startWatcherRevive } from './servers/api/command-hq/watcher-revive';
|
||||
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
||||
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
||||
import officerWeb from './apps/officer-web/index.gen.html';
|
||||
@@ -226,6 +227,7 @@ registerChatPushNotifications();
|
||||
// Nudger's tick. Here rather than on first use, because a schedule armed before a restart has to survive
|
||||
// one — the row is in Postgres and nothing else would ever look at it again.
|
||||
startNudger();
|
||||
startWatcherRevive();
|
||||
|
||||
// That the working directory is the repo, because every path derives from its parent. First of the three,
|
||||
// since a wrong answer here makes the other two check the wrong files.
|
||||
@@ -391,7 +393,7 @@ async function upgradeWs(req: Request, server: any, provider: UpgradableProvider
|
||||
// whole point: the two doors cannot disagree about what a role holds, because there is only one
|
||||
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
|
||||
// by being `execution` permissions, not by being absent from an array someone has to maintain.
|
||||
if (!(await isWsProviderAllowed(user.id, provider))) {
|
||||
if (!(await isWsProviderAllowed(user.id, provider, user.scope ?? null))) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,17 @@ export const permissionGateMiddleware: MiddlewareHandler = async function (ctx,
|
||||
//
|
||||
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
|
||||
// platform account, so there is nothing here to resolve.
|
||||
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
|
||||
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
|
||||
// A NARROWED credential is checked even for the owner — that narrowing is the only bound a Super Admin
|
||||
// has, so skipping the check for them would make it decorative on the one account it exists for. An
|
||||
// unnarrowed owner still short-circuits, which keeps the common request free of a database read.
|
||||
const credential = payload?.scope ?? null;
|
||||
// Stashed for the routes that pass authority on rather than just using it. Set unconditionally, so a
|
||||
// reader never has to distinguish "unnarrowed" from "the gate did not run".
|
||||
ctx.set('credentialScope', credential);
|
||||
const mustCheck = payload && (!isOwner || credential);
|
||||
|
||||
if (mustCheck && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
|
||||
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path, credential);
|
||||
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ITEMS_DIR } from '../data-path';
|
||||
import { capabilityRoots } from '../capability-sources';
|
||||
|
||||
// What the platform tells an agent about the surface it is speaking through.
|
||||
//
|
||||
@@ -107,6 +108,21 @@ export function compileAffordances(affordances: Affordance[]): string | null {
|
||||
}
|
||||
|
||||
/** Read and compile in one step, for callers that only want the string. */
|
||||
export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null {
|
||||
return compileAffordances(loadAffordances(root));
|
||||
/**
|
||||
* Every affordance in force: the owner's own, plus each installed and enabled plugin's.
|
||||
*
|
||||
* Read in place rather than copied in — see `capability-sources.ts`. Unlike skills these are NOT
|
||||
* de-duplicated by name: an affordance is a paragraph of guidance, several can be true at once, and two
|
||||
* plugins describing the same surface differently is information rather than a collision.
|
||||
*/
|
||||
export function loadAffordancesAcross(roots: string[]): Affordance[] {
|
||||
return roots.flatMap((root) => loadAffordances(root));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and compile in one step. Async because resolving which plugins are enabled reads the database;
|
||||
* called once per session, which is what makes that affordable.
|
||||
*/
|
||||
export async function affordanceBlock(): Promise<string | null> {
|
||||
return compileAffordances(loadAffordancesAcross(await capabilityRoots('affordances')));
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test, afterEach } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { affordanceDirFor, affordanceSourceFor, publishAffordances, unpublishAffordances } from './publish';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// The failure that matters is not a copy going wrong — it is uninstall taking somebody else's files with
|
||||
// it. Every first-party plugin publishes under one `officerdev/` directory, so removing the wrong level
|
||||
// would strip them all and nobody would notice until an agent stopped knowing something.
|
||||
|
||||
const root = join(tmpdir(), `aff-pub-${process.pid}`);
|
||||
|
||||
const plugin = (appName: string, publisher = 'officerdev'): DiscoveredPlugin =>
|
||||
({ appName, dir: join(root, appName), manifest: { publisher } }) as DiscoveredPlugin;
|
||||
|
||||
const withAffordance = (appName: string, publisher = 'officerdev') => {
|
||||
const p = plugin(appName, publisher);
|
||||
mkdirSync(affordanceSourceFor(p), { recursive: true });
|
||||
writeFileSync(join(affordanceSourceFor(p), `${appName}.md`), `---\nname: ${appName}\n---\n\nbody\n`);
|
||||
return p;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
for (const pub of ['officerdev', 'alice']) rmSync(join(AFFORDANCES_DIR, pub), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('publishing', () => {
|
||||
test('lands under <publisher>/<appName>, mirroring mountPrefix', () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(affordanceDirFor(p)).toBe(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea'));
|
||||
});
|
||||
|
||||
test('copies what the plugin ships and counts it', async () => {
|
||||
expect(await publishAffordances(withAffordance('test-gitea'))).toBe(1);
|
||||
});
|
||||
|
||||
test('a plugin shipping none is not a failure — most ship none', async () => {
|
||||
const bare = plugin('test-bare');
|
||||
mkdirSync(bare.dir, { recursive: true });
|
||||
expect(await publishAffordances(bare)).toBe(0);
|
||||
});
|
||||
|
||||
test('re-publishing overwrites rather than erroring, so re-install works', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
});
|
||||
|
||||
test('two publishers with the same plugin name do not collide', async () => {
|
||||
await publishAffordances(withAffordance('test-gitea', 'officerdev'));
|
||||
await publishAffordances(withAffordance('test-gitea', 'alice'));
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'alice', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishing', () => {
|
||||
test('removes the plugin', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
await publishAffordances(p);
|
||||
await unpublishAffordances(p);
|
||||
expect(existsSync(affordanceDirFor(p))).toBe(false);
|
||||
});
|
||||
|
||||
test('leaves the PUBLISHER alone — the thing that would silently strip every sibling', async () => {
|
||||
const a = withAffordance('test-gitea');
|
||||
const b = withAffordance('test-music');
|
||||
await publishAffordances(a);
|
||||
await publishAffordances(b);
|
||||
await unpublishAffordances(a);
|
||||
expect(existsSync(affordanceDirFor(b))).toBe(true);
|
||||
});
|
||||
|
||||
test('is safe when nothing was ever published', async () => {
|
||||
await unpublishAffordances(plugin('test-never'));
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Copying a plugin's affordances into the store the agent reads.
|
||||
//
|
||||
// A plugin is a new SURFACE arriving at runtime, which is the case hand-written prose could never cover:
|
||||
// install gitea and no agent learns that `tea` is authenticated, or that `git` is the better tool for
|
||||
// what git already does. The plugin knows; nothing was carrying that across.
|
||||
//
|
||||
// Same shape as `publishAssets` — a directory in the plugin repository, copied to a platform-managed
|
||||
// place on install, removed on uninstall. Deliberately so: two mechanisms for "the plugin ships some
|
||||
// files" would drift, and this one is already understood.
|
||||
//
|
||||
// ── Why the destination is namespaced ──
|
||||
//
|
||||
// `<publisher>/<appName>/`, mirroring `mountPrefix`. Two plugins called `gitea` from different publishers
|
||||
// must not overwrite each other's affordances, and an affordance in the tree should say whose it is
|
||||
// without opening it. `core` occupies the appName slot for the platform's own.
|
||||
|
||||
/** Where a plugin's affordances land. `officerdev/gitea/`, beside `officer/core/`. */
|
||||
export const affordanceDirFor = (plugin: DiscoveredPlugin): string =>
|
||||
join(AFFORDANCES_DIR, plugin.manifest.publisher, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const affordanceSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'affordances');
|
||||
|
||||
/**
|
||||
* Publish a plugin's affordances. Returns the number of files, or 0 when it ships none — which is the
|
||||
* ordinary case and not a failure.
|
||||
*
|
||||
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT
|
||||
* remove files the plugin has since dropped; that is what uninstall is for, and deleting on every
|
||||
* install would make a half-finished copy destructive.
|
||||
*/
|
||||
export async function publishAffordances(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = affordanceSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
const dest = affordanceDirFor(plugin);
|
||||
await cp(source, dest, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => f.endsWith('.md')).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's affordances.
|
||||
*
|
||||
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
||||
* plugin's, and taking it out because one was uninstalled would silently strip the rest.
|
||||
*
|
||||
* Safe to call when nothing was ever published.
|
||||
*/
|
||||
export async function unpublishAffordances(plugin: DiscoveredPlugin): Promise<void> {
|
||||
await rm(affordanceDirFor(plugin), { recursive: true, force: true });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createRouter } from '../../create-router';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
// Bug reports land on disk and nowhere else.
|
||||
//
|
||||
@@ -42,10 +43,10 @@ bugReportRouter.post('/', async (ctx) => {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
await writeAtomic(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
|
||||
if (screenshot instanceof File) {
|
||||
await Bun.write(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
|
||||
await writeAtomic(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true, id: dirName });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginChatsDir } from '@@/data-path';
|
||||
import type { Context } from 'hono';
|
||||
import { emitToSession, sessionKeyForTranscript } from './websocket';
|
||||
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
@@ -5,10 +6,10 @@ import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import {
|
||||
getUserSettings,
|
||||
getChatSessionModel,
|
||||
getChatSessionModels,
|
||||
listCommandHqSessionIds,
|
||||
listCommandHqOwnedSessionIds,
|
||||
getChatNotifyPreference,
|
||||
getChatNotifyPreferences,
|
||||
setChatNotifyPreference,
|
||||
@@ -45,7 +46,6 @@ import type { ChatIdentity } from './claude-sessions';
|
||||
import { officerModelForApiModel } from './model-aliases';
|
||||
import { readAccountEmail } from './claude-usage';
|
||||
import { listStoredUsage, mergeUsage, snapshotClaudeUsage } from './usage-store';
|
||||
import { readSttConfig } from '../server-settings/stt';
|
||||
|
||||
/**
|
||||
* Whose transcripts a request may read.
|
||||
@@ -81,7 +81,6 @@ 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`.
|
||||
import { transcribeAudio } from '../stt/transcribe';
|
||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
|
||||
export const chatRouter = createRouter();
|
||||
@@ -110,7 +109,24 @@ export const chatRouter = createRouter();
|
||||
// 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.
|
||||
const cwdOf = (ctx: Context, home: string): string => ctx.req.query('cwd')?.trim() || home;
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||
chatRouter.get('/pwds', async (ctx) => {
|
||||
@@ -123,7 +139,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.home);
|
||||
const cwd = cwdOf(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,
|
||||
@@ -159,7 +175,7 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the
|
||||
flag is real, because that route keeps both kinds.
|
||||
*/
|
||||
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
|
||||
const filed = await listCommandHqOwnedSessionIds(user.id).catch(() => new Set<string>());
|
||||
const sessions = merged.filter((s) => !filed.has(s.id));
|
||||
|
||||
/*
|
||||
@@ -207,7 +223,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.home);
|
||||
const cwd = cwdOf(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
|
||||
@@ -420,7 +436,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.home);
|
||||
const cwd = cwdOf(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);
|
||||
@@ -459,7 +475,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.home);
|
||||
const cwd = cwdOf(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);
|
||||
@@ -629,32 +645,6 @@ chatRouter.get('/models', async (ctx: Context) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /chat/stt — proxy an audio clip to the configured Whisper server.
|
||||
chatRouter.post('/stt', async (ctx: Context) => {
|
||||
const sttConfig = await readSttConfig();
|
||||
if (!sttConfig?.url) {
|
||||
return ctx.json({ error: 'Whisper not configured — set it up in Settings → Speech to Text' }, 400);
|
||||
}
|
||||
|
||||
const user = ctx.get('user');
|
||||
const body = await ctx.req.parseBody();
|
||||
const file = body['file'];
|
||||
if (!file || !(file instanceof File)) {
|
||||
return ctx.json({ error: 'file is required' }, 400);
|
||||
}
|
||||
|
||||
const settings = (await getUserSettings(user.id)) as { languages?: { spoken?: string[] } };
|
||||
const spokenLanguages = settings.languages?.spoken ?? [];
|
||||
|
||||
try {
|
||||
const result = await transcribeAudio({ file, whisperUrl: sttConfig.url, spokenLanguages });
|
||||
return ctx.json(result);
|
||||
} catch (err) {
|
||||
logger.error('STT proxy failed', { error: String(err) });
|
||||
return ctx.json({ error: err instanceof Error ? err.message : 'Failed to reach Whisper server' }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// The agent address book — naming a chat panel so other agents on the same dashboard can reach it.
|
||||
// Lives on this router because it is the same authority `chat` already grants: creating and naming
|
||||
// Claude sessions. See servers/api/chat/agent-panels-routes.ts and docs/agent-coordination.md.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { DATA_PATH, getCommandersDir } from '../../data-path';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import {
|
||||
appendTextAs,
|
||||
findPathAs,
|
||||
@@ -120,10 +120,13 @@ export type ClaudeSessionSummary = {
|
||||
* it has to be able to filter commanders out of that gesture itself. A flag only the web knew about
|
||||
* would fix one surface and leave the other wrong.
|
||||
*
|
||||
* Derived from the CWD rather than from a database join. Every commander a user has shares one
|
||||
* directory — `getCommandersDir(email)`, never per-commander and never client-chosen — and this list
|
||||
* already reads each session's `cwd`. So it costs a string comparison, works for a commander whose
|
||||
* `command_hq_sessions` row was lost, and adds no query.
|
||||
* Optional and never set by this file — every commander now has its OWN directory
|
||||
* (`docs/commander-directory.md`), so there is no single path this list could compare a `cwd` against
|
||||
* the way it once did when every commander shared one. The real answer is a `command_hq_sessions` row
|
||||
* lookup, which needs a database call this module does not make; `chat.ts` computes it that way and
|
||||
* spreads it onto the rows it returns — see its own comment on why `isCommander` is deliberately absent
|
||||
* from THIS module's output. The field stays on the type because other callers read it optionally off
|
||||
* whatever `ClaudeSessionSummary` they were handed, undefined being the honest answer when nobody set it.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
};
|
||||
@@ -420,7 +423,17 @@ export type ClaudeChatMessage =
|
||||
// Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation
|
||||
// above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to
|
||||
// "why did it go quiet for two minutes there", which is only useful if it survives a reload.
|
||||
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true };
|
||||
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true }
|
||||
// A background task reporting in — see `taskNotificationFrom`. `startsTurn` is the load-bearing field:
|
||||
// this is the prompt the agent woke up to, so it is where one turn ends and the next begins.
|
||||
| {
|
||||
role: 'task';
|
||||
taskId: string;
|
||||
description: string;
|
||||
status?: 'completed' | 'failed' | 'stopped';
|
||||
summary?: string;
|
||||
startsTurn: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
|
||||
@@ -454,16 +467,59 @@ function blockText(content: unknown): string {
|
||||
* A background task reporting in. The harness delivers these to the agent by writing them as the *user's*
|
||||
* next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally,
|
||||
* the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never
|
||||
* typed. Live it never appears, because a finished task travels as its own `task:notification` event; it
|
||||
* surfaces only when a refresh rebuilds the conversation from Claude's file.
|
||||
* typed. Live it never appears as prose, because a finished task travels as its own `task:notification`
|
||||
* event and lands in the row the task already had; it surfaces only when a refresh rebuilds the
|
||||
* conversation from Claude's file.
|
||||
*
|
||||
* Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real
|
||||
* message — this file's own conversation does exactly that — stays yours.
|
||||
*/
|
||||
const isTaskNotification = (text: string) => text.trimStart().startsWith('<task-notification>');
|
||||
|
||||
const tagOf = (envelope: string, tag: string): string | undefined =>
|
||||
envelope.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1];
|
||||
|
||||
/** `&` last, or `&lt;` would decode twice and become `<`. */
|
||||
const decodeEntities = (text: string) =>
|
||||
text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
|
||||
const TASK_STATUSES = new Set(['completed', 'failed', 'stopped']);
|
||||
|
||||
/**
|
||||
* The envelope, rebuilt into the same row a live `task:notification` produces when the task's announcing
|
||||
* row is gone (`useChat`, `task:notification`). It used to be dropped outright, and dropping it lost more
|
||||
* than a bubble: **turn boundaries are derived from the user's messages**, so deleting one merged the
|
||||
* woken turn into the one before it. Everything the agent had said closing that earlier turn stopped being
|
||||
* the turn's trailing prose and became mid-turn work, which folds — and the merged turn then ended on a
|
||||
* tool call, which printed "Turn finished · no closing message" over a turn that had plainly finished
|
||||
* talking. A commander wakes on these, so its whole life replayed as a single turn with every answer
|
||||
* folded away.
|
||||
*
|
||||
* Malformed envelopes still return null and are still dropped: a row we cannot identify is worse than a
|
||||
* missing boundary.
|
||||
*/
|
||||
function taskNotificationFrom(text: string): ClaudeChatMessage | null {
|
||||
const taskId = tagOf(text, 'task-id')?.trim();
|
||||
if (!taskId) return null;
|
||||
const status = tagOf(text, 'status')?.trim();
|
||||
const summary = decodeEntities((tagOf(text, 'summary') ?? '').trim());
|
||||
return {
|
||||
role: 'task',
|
||||
taskId,
|
||||
description: summary,
|
||||
status: status && TASK_STATUSES.has(status) ? (status as 'completed' | 'failed' | 'stopped') : undefined,
|
||||
summary,
|
||||
startsTurn: true,
|
||||
};
|
||||
}
|
||||
|
||||
function userMessageFrom(text: string): ClaudeChatMessage | null {
|
||||
if (isTaskNotification(text)) return null;
|
||||
if (isTaskNotification(text)) return taskNotificationFrom(text);
|
||||
if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
|
||||
return { role: 'user', text };
|
||||
}
|
||||
@@ -1019,26 +1075,6 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this the default commanders directory?
|
||||
*
|
||||
* **No longer the test for whether something is a commander** — that is the `command_hq_sessions` row, since
|
||||
* a commander now chooses its own working directory. This remains only for the places that legitimately care
|
||||
* about that specific path: creating it for a new OS account, and as the historical location of commanders
|
||||
* created before the cwd was choosable.
|
||||
*
|
||||
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
|
||||
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
|
||||
* whatever the process was actually in.
|
||||
*/
|
||||
export function isCommandersDir(email: string, cwd: string): boolean {
|
||||
try {
|
||||
return resolve(cwd) === resolve(getCommandersDir(email));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-shaped summary, found by id wherever it lives.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { CHAT_DEFAULT_MODEL, DEFAULT_MODEL, isTierlessModel } from './websocket';
|
||||
import { CHAT_DEFAULT_MODEL as CLIENT_CHAT_DEFAULT_MODEL } from 'officerdev';
|
||||
|
||||
/**
|
||||
* The default model, and the two ways it has already been defeated.
|
||||
*
|
||||
* Both failures were silent and both cost real money: every unconfigured surface ran Opus while the code
|
||||
* said Sonnet. Neither was visible in a diff — one was a constant in another package, the other was a
|
||||
* value stored in somebody's settings.
|
||||
*/
|
||||
|
||||
describe('the client and server agree on the default', () => {
|
||||
/*
|
||||
A comment cannot hold this. `apps/Chat/types.ts` mirrors the server's constant because the picker has
|
||||
to SHOW what the server would choose — and if it shows something else it also sends it, at which point
|
||||
the server honours the client and the "default" never applies. That is not hypothetical: the client
|
||||
fell back to the catalogue's first entry, which was `fable`, and did exactly that.
|
||||
*/
|
||||
test('the mirrored constant matches', () => {
|
||||
expect(CLIENT_CHAT_DEFAULT_MODEL).toBe(CHAT_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
test('it names a tier rather than the bare harness', () => {
|
||||
expect(CHAT_DEFAULT_MODEL).toContain('/');
|
||||
expect(isTierlessModel(CHAT_DEFAULT_MODEL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTierlessModel', () => {
|
||||
/*
|
||||
`claude-code` is a harness, and it is a legitimate value — `decideResume` assumes it when neither side
|
||||
knows which sidecar a session belongs to. As an answer to "which model", it means "whatever the CLI
|
||||
picks", which is Opus. Accounts have it stored from before that distinction existed, which is why the
|
||||
default appeared to do nothing on a machine where it was set.
|
||||
*/
|
||||
test('the bare harness is not a model choice', () => {
|
||||
expect(isTierlessModel(DEFAULT_MODEL)).toBe(true);
|
||||
expect(isTierlessModel('claude-code')).toBe(true);
|
||||
});
|
||||
|
||||
test('nothing chosen is not a model choice either', () => {
|
||||
expect(isTierlessModel(null)).toBe(true);
|
||||
expect(isTierlessModel(undefined)).toBe(true);
|
||||
expect(isTierlessModel('')).toBe(true);
|
||||
});
|
||||
|
||||
test('a real tier is', () => {
|
||||
expect(isTierlessModel('claude-code/sonnet')).toBe(false);
|
||||
expect(isTierlessModel('claude-code/opus[1m]')).toBe(false);
|
||||
// OpenCode ids carry a provider prefix of their own, so they are tiered by the same test.
|
||||
expect(isTierlessModel('opencode/some-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { resolve } from 'node:path';
|
||||
import { isCommandersDir } from './claude-sessions';
|
||||
import { getCommandersDir } from '../../data-path';
|
||||
|
||||
const EMAIL = 'pastilhas@officer.dev';
|
||||
|
||||
// The live payload's `isCommander` is derived from a cwd read out of a transcript, so it is whatever the
|
||||
// process was actually in — not a path this code composed. These are the shapes that arrive.
|
||||
describe('isCommandersDir', () => {
|
||||
const dir = getCommandersDir(EMAIL);
|
||||
|
||||
it('recognises the commanders directory', () => {
|
||||
expect(isCommandersDir(EMAIL, dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('survives a trailing slash and a .. hop', () => {
|
||||
expect(isCommandersDir(EMAIL, `${dir}/`)).toBe(true);
|
||||
expect(isCommandersDir(EMAIL, `${dir}/sub/..`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a sibling and a child', () => {
|
||||
expect(isCommandersDir(EMAIL, resolve(dir, '..'))).toBe(false);
|
||||
expect(isCommandersDir(EMAIL, resolve(dir, 'nested'))).toBe(false);
|
||||
});
|
||||
|
||||
// A commander's cwd belongs to the account that owns it. Reading one user's directory as another's
|
||||
// would file their commanders under the wrong person and, in the iPhone swipe order, show them.
|
||||
it('is per account', () => {
|
||||
expect(isCommandersDir('someone-else@officer.dev', dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('never throws on rubbish', () => {
|
||||
expect(isCommandersDir(EMAIL, '')).toBe(false);
|
||||
expect(isCommandersDir(EMAIL, '\0')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { getNotifyServerUrl } from '../notify/router';
|
||||
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||
import { shouldNotify, type NotifyKind } from './notify-decision';
|
||||
|
||||
// The phone half of the bell: the same signal the browser socket listens to, delivered to APNs/FCM.
|
||||
@@ -32,16 +32,17 @@ export function registerChatPushNotifications(): void {
|
||||
if (!(await shouldNotify(userId, sessionKey, kind))) return;
|
||||
|
||||
const base = getNotifyServerUrl();
|
||||
const producer = notifyProducerHeaders();
|
||||
// The sidecar has not reported in — either it is starting, or push is not running on this install.
|
||||
// Not an error: the browser bell still works, and this is the channel that is allowed to be absent.
|
||||
if (!base) return;
|
||||
if (!base || !producer) return;
|
||||
|
||||
// `id` is the session key so the device can deep-link straight to the conversation. It is an
|
||||
// opaque uuid, which is the only reason it may travel: NO title, and no message content, because
|
||||
// this payload is read by Apple and Google. See docs/push-notifications.md.
|
||||
await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId), ...producer },
|
||||
body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { dueNudges, recordNudgeSent, cancelNudge, getUserById } from 'officerdb';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { dueNudges, recordNudgeSent, getUserById } from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { startClaudeTurn } from './websocket';
|
||||
import { startClaudeTurn, CHAT_DEFAULT_MODEL } from './websocket';
|
||||
|
||||
// Nudger: the schedule that keeps a chat moving while nobody is watching it.
|
||||
//
|
||||
@@ -24,64 +23,41 @@ import { startClaudeTurn } from './websocket';
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
|
||||
type Safety = { safe: true } | { safe: false; reason: string; disable: boolean };
|
||||
|
||||
/**
|
||||
* May we speak into this session right now?
|
||||
* There is no safety check any more, and that is the point.
|
||||
*
|
||||
* ── The failure this replaced ──
|
||||
* ── What was here ──
|
||||
*
|
||||
* This used to ask one question, "is it generating", and treat NO as permission. That is exactly
|
||||
* backwards, and on 2026-08-20 it cost a working agent its afternoon: the target session had been
|
||||
* garbage-collected, so it was not generating, so the nudge fired — and `startClaudeTurn` given a session
|
||||
* the sidecar no longer holds does not resume it, it starts a NEW one. A second Claude with bypassed
|
||||
* permissions appeared in a repository another agent was mid-build in. Nothing was lost only because that
|
||||
* agent noticed the collision and stopped rather than committing over the work.
|
||||
* Three refusals: the session is not live, the session is generating, or anything else is live on the
|
||||
* machine. Written on 2026-08-20 after a nudge fired at a garbage-collected session and a second Claude
|
||||
* with bypassed permissions appeared in a repository another agent was mid-build in.
|
||||
*
|
||||
* So there are three questions, and only one answer is permission:
|
||||
* ── Why all three are gone ──
|
||||
*
|
||||
* - **Not live at all** → refuse, and disarm. Nudging it would CREATE an agent, which is not what a
|
||||
* schedule attached to a conversation means and never was.
|
||||
* - **Live but generating** → refuse, keep the schedule. It is working; that is the whole rule.
|
||||
* - **Another session live in the same directory** → refuse, keep the schedule. Two agents with
|
||||
* bypassed permissions in one working tree is not something to arrange on purpose.
|
||||
* **Not live** was fixed elsewhere and the guard outlived it. `fire()` passes `resumeSessionId`, and
|
||||
* `claude-manager` resolves `getClaudeSession(sessionKey) ?? params.resumeSessionId` into the SDK's
|
||||
* `resume` — so a collected session resumes its own transcript rather than starting a new agent. That is
|
||||
* what a schedule attached to a conversation always meant.
|
||||
*
|
||||
* **Anything else live** refused on the presence of any agent anywhere, because the sidecar does not report
|
||||
* working directories and the check could not tell "another agent in this conversation" from "an unrelated
|
||||
* chat". On a machine running commanders that is always true, so no nudge could ever fire — silently: the
|
||||
* row stayed enabled, `sentCount` stayed 0, and whatever scheduled it reported success. It was a ban on the
|
||||
* feature wearing the clothes of a safety check.
|
||||
*
|
||||
* **Generating** was the last one, and it went for a better reason than the others: it was refusing to do
|
||||
* something that already works. Each session is one long-lived `query()` driven by a STREAMING INPUT QUEUE
|
||||
* (`claude-manager.ts` → "Persistent streaming sessions"), and a turn is one message pushed onto it. A
|
||||
* message sent to a busy session is not rejected and not lost — it is queued, and delivered when the
|
||||
* current turn ends. Waiting is fine; disappearing is not, and refusing here meant disappearing.
|
||||
*
|
||||
* ── What this costs, stated plainly ──
|
||||
*
|
||||
* Nudging conversation A while conversation B is live in the same working tree still puts two agents in one
|
||||
* directory. That is real and it is the owner's call, made 2026-08-25: a message that must arrive is worth
|
||||
* more than a guard that could only ever be right by accident, since it never knew the directories either.
|
||||
* Fixing it properly needs the sidecar to report each session's cwd.
|
||||
*/
|
||||
async function checkSafe(userId: number, sessionId: string): Promise<Safety> {
|
||||
try {
|
||||
const live = await sidecar.listLiveClaudeSessions(userId);
|
||||
// Matched on BOTH ids on purpose. `sessionKey` is officer's handle for the conversation;
|
||||
// `claudeSessionId` is the transcript's own filename, and only the agent holds the mapping. A schedule
|
||||
// stored against one would silently never match a live row keyed by the other.
|
||||
const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId);
|
||||
|
||||
if (!mine) {
|
||||
return {
|
||||
safe: false,
|
||||
reason: 'the session is no longer live — nudging it would start a NEW agent, not continue this one',
|
||||
disable: true,
|
||||
};
|
||||
}
|
||||
if (mine.isGenerating) return { safe: false, reason: 'working', disable: false };
|
||||
|
||||
// Anything else alive belongs to another conversation. It may or may not share this one's directory —
|
||||
// the sidecar does not say — so this refuses on the presence of any other live session rather than on
|
||||
// a directory comparison it cannot actually make.
|
||||
const others = live.filter((s) => s !== mine).length;
|
||||
if (others > 0) {
|
||||
return {
|
||||
safe: false,
|
||||
reason: `${others} other live session(s) — refusing to add a second agent`,
|
||||
disable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { safe: true };
|
||||
} catch {
|
||||
// Unknown is not permission. A sidecar that cannot answer might be running a turn, and interrupting one
|
||||
// is the failure this exists to avoid — so silence is the safe answer.
|
||||
return { safe: false, reason: 'sidecar did not answer', disable: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function fire(nudge: { id: number; userId: number; sessionId: string; message: string }): Promise<void> {
|
||||
const user = await getUserById(nudge.userId);
|
||||
@@ -107,7 +83,9 @@ async function fire(nudge: { id: number; userId: number; sessionId: string; mess
|
||||
// they would see a turn started from another tab.
|
||||
ws: null,
|
||||
sessionId: nudge.sessionId,
|
||||
model: 'claude-code/sonnet',
|
||||
// The constant, not a copy of its value — a nudge is an unattended turn and belongs with every
|
||||
// other surface that never had a picker.
|
||||
model: CHAT_DEFAULT_MODEL,
|
||||
msg: { prompt: nudge.message, resumeSessionId: nudge.sessionId },
|
||||
effectivePrompt: nudge.message,
|
||||
onOutcome: (outcome) => {
|
||||
@@ -144,15 +122,7 @@ export function startNudger(): void {
|
||||
running = true;
|
||||
try {
|
||||
for (const nudge of await dueNudges()) {
|
||||
const check = await checkSafe(nudge.userId, nudge.sessionId);
|
||||
if (!check.safe) {
|
||||
if (check.disable) {
|
||||
console.warn(`[nudger] session ${nudge.sessionId} disarmed — ${check.reason}`);
|
||||
await cancelNudge(nudge.userId, nudge.sessionId);
|
||||
}
|
||||
continue; // otherwise reconsidered next tick
|
||||
}
|
||||
console.log(`[nudger] session ${nudge.sessionId} idle — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`);
|
||||
console.log(`[nudger] session ${nudge.sessionId} — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`);
|
||||
await fire(nudge);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadClaudeSession, projectSlug, type ChatIdentity } from './claude-sessions';
|
||||
|
||||
/**
|
||||
* The harness delivers a finished background task to the agent by writing it as the agent's next *user*
|
||||
* message. Replayed literally that is a raw XML envelope in a bubble on your side of the conversation, so
|
||||
* the parser used to drop it — and dropped the turn boundary with it, because `turn-rows` derives turns
|
||||
* from the message list alone. Reported 2026-08-25 against a commander, which wakes on these: its whole
|
||||
* history replayed as one turn with every answer folded away inside the seam.
|
||||
*
|
||||
* Driven through the real loader rather than the parser, because the drop and the boundary live one call
|
||||
* apart and testing them separately is how they came apart in the first place.
|
||||
*/
|
||||
|
||||
const CWD = '/home/someone/commanders/1';
|
||||
const SESSION = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
const home = mkdtempSync(join(tmpdir(), 'officer-transcript-'));
|
||||
afterAll(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const who: ChatIdentity = { email: 'someone@example.com', home, osUser: null, isOwner: true };
|
||||
|
||||
const write = (lines: unknown[]): void => {
|
||||
const dir = join(home, '.claude', 'projects', projectSlug(CWD));
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${SESSION}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
|
||||
};
|
||||
|
||||
const load = () => loadClaudeSession(who, CWD, SESSION)!.messages;
|
||||
|
||||
const NOTIFICATION = [
|
||||
'<task-notification>',
|
||||
'<task-id>b99iu40k1</task-id>',
|
||||
'<tool-use-id>toolu_01JDH</tool-use-id>',
|
||||
'<status>completed</status>',
|
||||
'<summary>Background command "wc -l < ledger.tsv" completed (exit code 0)</summary>',
|
||||
'</task-notification>',
|
||||
].join('\n');
|
||||
|
||||
const say = (text: string) => ({
|
||||
type: 'assistant',
|
||||
cwd: CWD,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
||||
});
|
||||
const prompt = (text: string) => ({ type: 'user', cwd: CWD, message: { role: 'user', content: text } });
|
||||
|
||||
describe('a background task reporting in, replayed from the transcript', () => {
|
||||
test('becomes a task row rather than being dropped or shown as something you typed', () => {
|
||||
write([prompt('watch the folder'), say('armed'), prompt(NOTIFICATION), say('it fired')]);
|
||||
|
||||
const messages = load();
|
||||
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'task', 'assistant']);
|
||||
|
||||
const task = messages[2];
|
||||
expect(task).toMatchObject({ role: 'task', taskId: 'b99iu40k1', status: 'completed', startsTurn: true });
|
||||
});
|
||||
|
||||
test('carries the summary with its entities decoded, not the raw envelope', () => {
|
||||
write([prompt('watch the folder'), prompt(NOTIFICATION)]);
|
||||
|
||||
const task = load()[1] as Extract<ReturnType<typeof load>[number], { role: 'task' }>;
|
||||
expect(task.summary).toBe('Background command "wc -l < ledger.tsv" completed (exit code 0)');
|
||||
expect(task.summary).not.toContain('<');
|
||||
expect(task.summary).not.toContain('<task-notification>');
|
||||
});
|
||||
|
||||
test('an envelope with no task id is still dropped — an unidentifiable row is worse than none', () => {
|
||||
write([
|
||||
prompt('watch the folder'),
|
||||
prompt('<task-notification>\n<status>completed</status>\n</task-notification>'),
|
||||
]);
|
||||
|
||||
expect(load().map((m) => m.role)).toEqual(['user']);
|
||||
});
|
||||
|
||||
test('a message merely QUOTING an envelope stays yours', () => {
|
||||
// This file's own conversation does exactly that, which is why the match is anchored to the start.
|
||||
write([prompt(`what does ${NOTIFICATION} mean?`)]);
|
||||
|
||||
expect(load().map((m) => m.role)).toEqual(['user']);
|
||||
});
|
||||
|
||||
test('an unknown status is dropped rather than passed through to the row', () => {
|
||||
write([prompt('go'), prompt(NOTIFICATION.replace('completed', 'wedged'))]);
|
||||
|
||||
expect(load()[1]).toMatchObject({ role: 'task', status: undefined });
|
||||
});
|
||||
});
|
||||
@@ -77,19 +77,14 @@ export type ClientMessage =
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* The name the owner chose for a COMMANDER before its first message.
|
||||
*
|
||||
* Claude Code will not accept a name for a new session — the harness names its own from the first
|
||||
* prompt — so the platform renames the transcript the moment it exists. Carried on the turn rather
|
||||
* than sent afterwards because it must not depend on a browser still being open: the name was chosen
|
||||
* before anything was said, and losing it to a closed tab would leave a commander called after
|
||||
* whatever it happened to say first.
|
||||
*
|
||||
* Applied once, by `fileAsCommander`, via `renameClaudeSession` — which writes a `summary` record and
|
||||
* therefore an EXPLICIT title, and an explicit title is never overwritten by `ai-title` or by the
|
||||
* first-message fallback.
|
||||
* The COMMANDER row this turn belongs to — `command_hq_sessions.id`. Set by `POST /command-hq/
|
||||
* commanders`, which creates the row (with its name, directory and `CLAUDE.md`) BEFORE this turn ever
|
||||
* starts — see `docs/commander-directory.md`. Carried on the turn so the server can point that row at
|
||||
* the transcript id the moment one exists (`fileAsCommander`, `websocket.ts`) and so `resolveChatCwd`
|
||||
* can resolve this turn's `cwd` to the commander's own directory without trusting a client-chosen
|
||||
* `cwd` for it.
|
||||
*/
|
||||
commanderName?: string;
|
||||
commanderId?: number;
|
||||
/**
|
||||
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
|
||||
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
|
||||
|
||||
+223
-145
@@ -15,7 +15,13 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from 'path';
|
||||
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
|
||||
import {
|
||||
getOwnerHomeDir,
|
||||
getEmailAccountsDir,
|
||||
getPluginChatsDir,
|
||||
findEmailAccountName,
|
||||
getCommanderDir,
|
||||
} from '../../../servers/data-path';
|
||||
import { attachedFilePaths } from './attached-files';
|
||||
import {
|
||||
getUserSettings,
|
||||
@@ -24,25 +30,81 @@ import {
|
||||
getUserById,
|
||||
setChatSessionModel,
|
||||
setChatSessionPlanMode,
|
||||
createCommandHqSession,
|
||||
setCommanderSessionId,
|
||||
getCommandHqSession,
|
||||
getCommandHqSessionById,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
recordCommandHqTurn,
|
||||
} from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
|
||||
import { chatIdentity } from './chat';
|
||||
import { commanderBlock } from '../command-hq/commander-block';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
import { snapshotPlugins } from '@@/plugins/mount';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
import { logger } from './logger';
|
||||
import { isSuperAdmin } from '@@/super-admin';
|
||||
import { snapshotClaudeUsage } from './usage-store';
|
||||
|
||||
// Default model when no user preference is set
|
||||
/**
|
||||
* The bare harness, `claude-code` with no tier. **This is a HARNESS IDENTITY, not a model choice.**
|
||||
*
|
||||
* It is what `decideResume` assumes when neither the server nor the client knows which sidecar a resuming
|
||||
* session belongs to — see that function for the three failures a wrong guess here produced. Keep it bare:
|
||||
* adoption and delivery compare harnesses, and a tier suffix on this value would be comparing something
|
||||
* else. **What a NEW chat runs on is `CHAT_DEFAULT_MODEL` below**, which is a different question that
|
||||
* happened to share a constant.
|
||||
*/
|
||||
export const DEFAULT_MODEL = 'claude-code';
|
||||
|
||||
/**
|
||||
* What a chat runs on when nobody has said otherwise — no model from the client, no account default.
|
||||
*
|
||||
* Sonnet, since 2026-08-25. It used to be the bare harness, which means whatever Claude Code picks for
|
||||
* itself, and that is Opus: every unconfigured surface — a plugin's chat panel, a nudge, a chat opened
|
||||
* from anywhere that does not carry a picker — was quietly running the most expensive model available for
|
||||
* work that rarely needs it. The owner watched a commander spend ~$0.26 of Opus on a single routing
|
||||
* decision on 2026-08-25 and pinned commanders to Sonnet; this is the same reasoning applied to everything
|
||||
* that never had a picker in the first place.
|
||||
*
|
||||
* **It is a floor, not a ceiling.** An account default still wins, and a client that names a model still
|
||||
* wins over both, so nothing that could already choose loses the ability. This only changes what happens
|
||||
* when nothing chose.
|
||||
*
|
||||
* A better answer is wanted — per-surface defaults, or something that reads the work rather than the
|
||||
* caller. This is the honest interim: one constant, one place, easy to move.
|
||||
*/
|
||||
export const CHAT_DEFAULT_MODEL = 'claude-code/sonnet';
|
||||
|
||||
/**
|
||||
* Is this stored preference actually a model, or just a harness?
|
||||
*
|
||||
* `claude-code` with no tier routes fine and is a legitimate value for the resume path — but as an answer
|
||||
* to "which model should this chat use" it means "whatever the CLI picks", which is Opus. Accounts have it
|
||||
* stored from before that distinction existed: on this machine `tasks.defaultModel` and
|
||||
* `chat.defaultProjectModel` were both bare, which is why the picker showed `claude-code` and the change
|
||||
* to `CHAT_DEFAULT_MODEL` appeared to do nothing.
|
||||
*
|
||||
* Rather than rewrite somebody's settings, a bare harness is READ as unset. The row keeps whatever it
|
||||
* says; the meaning of "no tier chosen" is now "use the default", which is what it always meant.
|
||||
*/
|
||||
export const isTierlessModel = (model: string | null | undefined): boolean => !model || !model.includes('/');
|
||||
|
||||
/**
|
||||
* What a commander runs on, regardless of the account's default.
|
||||
*
|
||||
* A commander is long-running and mostly does the same settled thing over and over, so it is the one
|
||||
* surface where the account default — chosen for interactive work — is the wrong answer. Pinned here
|
||||
* rather than left to the picker so a commander created today does not depend on what the owner last
|
||||
* selected for a chat.
|
||||
*
|
||||
* Temporary in the sense that per-surface defaults belong in settings; permanent in the sense that
|
||||
* "commanders default to Sonnet" is the decision, and this is where it lives until there is a better
|
||||
* place for it.
|
||||
*/
|
||||
export const COMMANDER_DEFAULT_MODEL = 'claude-code/sonnet';
|
||||
|
||||
// Harness selection: the `claude-code` provider runs through the Claude sidecar; every other provider
|
||||
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
|
||||
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
|
||||
@@ -143,36 +205,137 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the plugin's own `CLAUDE.md` where its chats will run.
|
||||
*
|
||||
* ── Why a file and not a prompt prefix ──
|
||||
*
|
||||
* A plugin used to say what its chats should know through `promptPrefix`, which rides every message. That
|
||||
* has three costs and the owner hit all of them: it is paid per turn forever, it is INVISIBLE as a design
|
||||
* artefact because it lives in a TypeScript constant, and it is visible in the worst place — the owner
|
||||
* sent three words and watched a paragraph of preamble go out attached to them.
|
||||
*
|
||||
* Claude Code reads `CLAUDE.md` from its working directory at session start. So the same words, written
|
||||
* once into the directory these chats already run in, cost nothing per turn, can be opened and edited by
|
||||
* the person they are about, and are the mechanism `docs/commander-directory.md` already settled on for
|
||||
* exactly this problem.
|
||||
*
|
||||
* ── Copied, not read in place, and why that is not a contradiction ──
|
||||
*
|
||||
* `docs/plugin-capabilities.md` argues at length that a plugin's capabilities are READ where they live and
|
||||
* never copied. That rule is about files the PLATFORM reads, where a copy goes stale and survives an
|
||||
* uninstall. This one is read by Claude Code, from a cwd, so it has to exist there — no amount of
|
||||
* indirection changes where that program looks.
|
||||
*
|
||||
* The staleness the copy rule warns about is answered by rewriting it on every resolve rather than once at
|
||||
* install: the file on disk is always this version of the plugin's, and a `plugins:update` needs no
|
||||
* republish step to take effect. It is a few kilobytes written when a chat starts.
|
||||
*
|
||||
* Best-effort throughout. A plugin with no brief is the ordinary case, and a chat that cannot be given one
|
||||
* still runs — it simply knows less, which is where every chat was before this existed.
|
||||
*/
|
||||
async function installPluginChatBrief(appName: string, dir: string): Promise<void> {
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled);
|
||||
if (!state) return;
|
||||
|
||||
const source = join(state.plugin.dir, 'chat', 'CLAUDE.md');
|
||||
if (!existsSync(source)) return;
|
||||
|
||||
const target = join(dir, 'CLAUDE.md');
|
||||
const next = await Bun.file(source).text();
|
||||
// Skip the write when nothing changed, so a directory's mtimes do not churn on every chat start.
|
||||
if (existsSync(target) && (await Bun.file(target).text()) === next) return;
|
||||
await writeAtomic(target, next);
|
||||
} catch (err) {
|
||||
console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
||||
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
|
||||
async function resolveChatCwd(
|
||||
msg: { context?: string; contextId?: string; cwd?: string },
|
||||
msg: { context?: string; contextId?: string; cwd?: string; resumeSessionId?: string; commanderId?: number },
|
||||
email: string,
|
||||
userId: number,
|
||||
home: string,
|
||||
): Promise<string> {
|
||||
/*
|
||||
A commander runs where the owner chose, and the choice is required.
|
||||
A commander runs in its OWN directory — resolved here, server-side, never trusted from `msg.cwd`.
|
||||
|
||||
This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
|
||||
empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
|
||||
Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
|
||||
works on the platform can live in the platform checkout, where its relative paths mean something and its
|
||||
transcripts sit beside the work they are about.
|
||||
This honoured a client-chosen `msg.cwd` from 2026-08-22 until `docs/commander-directory.md` reversed
|
||||
that call two days later, and the reversal is not just "a crafted message could misdirect a commander" —
|
||||
the CONTAINMENT reasoning that made a client-chosen path safe (`resolveCwd(home, …)`, so "client-chosen"
|
||||
never meant "anywhere") still holds, it just no longer applies, because the client has nothing left to
|
||||
choose. `POST /command-hq/commanders` creates the row, ITS directory and `CLAUDE.md` before any turn
|
||||
spawns, so this only ever has to look the directory back up: by `commanderId` on the turn that just
|
||||
created the row (no transcript id yet to look up by), or by the transcript id (`resumeSessionId`, or the
|
||||
officer session's own resume id on a later turn of the same conversation) once one exists. Either lookup
|
||||
lands on the same row, and `getCommanderDir(email, row.dirName)` is the one true answer either way.
|
||||
|
||||
The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch
|
||||
below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside
|
||||
the account's own tree by a crafted message.
|
||||
|
||||
No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the
|
||||
first message can be sent, and a silent default would be a commander running somewhere nobody picked.
|
||||
Falling back to the account's home is the honest last resort for a caller that predates the requirement.
|
||||
Falls back to the account's home only when NEITHER is given, which should not happen once every path
|
||||
pre-creates the row — the honest last resort for a caller that predates it, not a real branch anybody
|
||||
expects to take.
|
||||
*/
|
||||
if (msg.context === 'command-hq') {
|
||||
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const row =
|
||||
msg.commanderId !== undefined
|
||||
? await getCommandHqSessionById(userId, msg.commanderId)
|
||||
: msg.resumeSessionId
|
||||
? await getCommandHqSession(userId, msg.resumeSessionId)
|
||||
: null;
|
||||
if (!row) {
|
||||
// Loud, because the degradation is otherwise invisible: a commander running in the account's home has
|
||||
// no CLAUDE.md, so it knows nothing about itself and asks the owner what its job is on every turn —
|
||||
// which reads as the agent being forgetful rather than as a lookup that missed.
|
||||
console.warn(
|
||||
`[command-hq] no commander row for ${msg.commanderId !== undefined ? `id ${msg.commanderId}` : `session ${msg.resumeSessionId ?? '(none given)'}`} — falling back to the account home`,
|
||||
);
|
||||
}
|
||||
if (!row) return home;
|
||||
|
||||
/*
|
||||
The directory must already exist. `POST /command-hq/commanders` creates it with its `CLAUDE.md`
|
||||
before any turn spawns, so a missing one means the commander was DELETED — and a late turn from the
|
||||
conversation that outlived it would otherwise recreate it here.
|
||||
|
||||
That happened: an empty `commanders/46` reappeared after a clean delete, from one in-flight turn
|
||||
calling this. An empty directory is worse than none, because it is a commander with no CLAUDE.md
|
||||
that knows nothing about itself, and it shows up in the file browser as something that still exists.
|
||||
|
||||
So: no mkdir. Fall back to the home, and say so — the same degradation the missing-row branch above
|
||||
reports, for the same reason.
|
||||
*/
|
||||
const dir = getCommanderDir(email, row.dirName);
|
||||
if (!existsSync(dir)) {
|
||||
console.warn(`[command-hq] ${row.dirName}: directory is gone — deleted mid-turn? Falling back to the home.`);
|
||||
return home;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
/*
|
||||
A plugin's own chats run in a directory the plugin owns — `$DATA_PATH/<email>/plugins/<appName>/chats`.
|
||||
|
||||
`contextId` is the plugin's appName and is the only thing taken from the client here. The PATH is built
|
||||
server-side from it, never sent: `docs/commander-directory.md` reversed exactly that call for
|
||||
commanders, and the reasoning carries — a client that can name a directory can name any directory, and
|
||||
`getPluginChatsDir` refuses a separator for the same reason `getCommanderDir` does.
|
||||
|
||||
Why a directory rather than more preamble: Claude Code reads a `CLAUDE.md` from its cwd at session
|
||||
start, so a plugin can give its chats context that costs nothing in every other conversation on the
|
||||
machine. It also keeps a plugin's chats off the account's home.
|
||||
|
||||
No `contextId` means no plugin, which falls through to the ordinary answer rather than inventing a
|
||||
directory named after nothing.
|
||||
*/
|
||||
if (msg.context === 'plugin' && msg.contextId) {
|
||||
const dir = getPluginChatsDir(email, msg.contextId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
await installPluginChatBrief(msg.contextId, dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId);
|
||||
if (msg.context === 'chat') {
|
||||
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
|
||||
@@ -358,14 +521,12 @@ function createMessageHandler(
|
||||
planMode?: boolean,
|
||||
commandHq?: boolean,
|
||||
/**
|
||||
* The name the owner chose before the conversation started, for a commander.
|
||||
*
|
||||
* Claude Code will not accept a name for a NEW session — the harness names its own — so the platform
|
||||
* renames it the moment the transcript exists. Done here rather than by the client because it must not
|
||||
* depend on a browser still being open: the name was chosen before the first message, and losing it to a
|
||||
* closed tab would leave a commander called after whatever it happened to say first.
|
||||
* The commander row THIS turn belongs to — `command_hq_sessions.id`, created by `POST /commanders`
|
||||
* before this turn ever started (`docs/commander-directory.md`). Used to point that row at the transcript
|
||||
* id the moment one exists, so a commander's own row and directory (name, `CLAUDE.md`, `dirName`) are
|
||||
* never something this handler creates — only something it finishes wiring up.
|
||||
*/
|
||||
commanderName?: string,
|
||||
commanderId?: number,
|
||||
) {
|
||||
/*
|
||||
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
|
||||
@@ -451,68 +612,33 @@ function createMessageHandler(
|
||||
};
|
||||
|
||||
/*
|
||||
File this session as a commander, once the transcript id is known — the same wait `record` above is
|
||||
built around, and for the same reason: there is nothing to key the row on before the harness announces
|
||||
it. Gated on `commandHq`, which the caller sets only when `msg.context === 'command-hq'`, so an
|
||||
ordinary `/chat` turn never touches this table.
|
||||
Point this commander's row at its transcript id, once the transcript id is known — the same wait
|
||||
`record` above is built around, and for the same reason: there is nothing to key the write on before the
|
||||
harness announces it. Gated on `commandHq && commanderId`, so an ordinary `/chat` turn never touches
|
||||
this table, and a commander turn with no id (should not happen once every caller pre-creates the row —
|
||||
see `docs/commander-directory.md`) is a no-op rather than a guess at creating one.
|
||||
|
||||
Idempotent by construction (`createCommandHqSession` upserts on `(user_id, session_id)`), so calling it
|
||||
again on every later turn of the same commander — which happens, because the client keeps sending
|
||||
`context: 'command-hq'` for `resolveChatCwd` to keep resolving the right directory — costs one no-op
|
||||
upsert rather than a duplicate row. That repetition is also a cheap self-heal: if the row were ever lost,
|
||||
the next turn puts it back.
|
||||
`setCommanderSessionId` is a plain UPDATE, not an upsert: the row, its directory and its name all exist
|
||||
already, written by `POST /commanders` before this turn started. There is nothing left to create here —
|
||||
only a pointer to set, which is why this is so much smaller than the `fileAsCommander` it replaces used
|
||||
to be. Idempotent by construction (an UPDATE by id), so calling it again on every later turn of the same
|
||||
commander costs one no-op write, and is a cheap self-heal if the pointer were ever lost.
|
||||
|
||||
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
|
||||
reason a chat turn fails.
|
||||
*/
|
||||
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
|
||||
// the retry note inside `fileAsCommander`.
|
||||
let commanderNamed = false;
|
||||
|
||||
const fileAsCommander = (): void => {
|
||||
if (!commandHq || !transcriptId) return;
|
||||
if (!commandHq || !transcriptId || commanderId === undefined) return;
|
||||
const userId = sessionManager.getSession(sessionId)?.userId;
|
||||
if (!userId) return;
|
||||
const filedId = transcriptId;
|
||||
// The name goes in with the row, not only to the transcript: the list reads it from here and is
|
||||
// right immediately, rather than waiting on a summary record the harness cannot write until the
|
||||
// first turn has finished.
|
||||
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
|
||||
.then(async () => {
|
||||
/*
|
||||
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
|
||||
|
||||
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
|
||||
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
|
||||
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
|
||||
kept the harness's own `ai-title` — which is exactly what the first live test showed.
|
||||
|
||||
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
|
||||
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
|
||||
|
||||
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
|
||||
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
|
||||
*/
|
||||
if (!commanderName?.trim() || commanderNamed) return;
|
||||
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
|
||||
// decides the home and the OS user, and a rename writes a file as that user.
|
||||
const user = await getUserById(userId).catch(() => null);
|
||||
if (!user?.email) return;
|
||||
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
|
||||
if (!who) return;
|
||||
// `who.home` as the hint, not the old shared commanders directory: a commander chooses its own
|
||||
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
|
||||
// and locates the transcript wherever it actually is — so this works either way, and passing a
|
||||
// path we know is probably wrong would be the kind of thing that reads as intent later.
|
||||
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.warn('Could not file this session as a commander', {
|
||||
sessionId,
|
||||
transcriptId: filedId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
void setCommanderSessionId(userId, commanderId, transcriptId).catch((err: unknown) => {
|
||||
logger.warn('Could not point this commander at its transcript', {
|
||||
sessionId,
|
||||
transcriptId,
|
||||
commanderId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (msg: TurnMessage, seq?: number): void => {
|
||||
@@ -573,9 +699,6 @@ function createMessageHandler(
|
||||
record();
|
||||
fileAsCommander();
|
||||
}
|
||||
// And retried on every result until the name lands. The first attempt runs before the transcript file
|
||||
// exists, so the end of the first turn is the earliest a rename can succeed.
|
||||
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
|
||||
|
||||
/*
|
||||
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
|
||||
@@ -791,11 +914,6 @@ async function handleChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
@@ -838,8 +956,11 @@ async function handleChat(
|
||||
.join('\n')}`
|
||||
: withSummary;
|
||||
|
||||
// Use provided model, or fall back to user default, or the system default.
|
||||
const requested = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
|
||||
// Provided model, then the account default, then Sonnet. NOT `DEFAULT_MODEL` — that is the bare harness
|
||||
// and means "whatever Claude Code picks", which is Opus. See `CHAT_DEFAULT_MODEL`.
|
||||
const accountDefault = await getUserDefaultModel(userId);
|
||||
const chosen = msg.model || accountDefault;
|
||||
const requested = isTierlessModel(chosen) ? CHAT_DEFAULT_MODEL : chosen!;
|
||||
|
||||
/*
|
||||
A conversation may change TIER mid-flight; it may not change HARNESS.
|
||||
@@ -931,14 +1052,6 @@ export function sessionKeyForTranscript(transcriptId: string): string | undefine
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions already told what they are and where to record it.
|
||||
*
|
||||
* In memory, keyed on officer's session id: a restart re-briefs, which costs a few hundred tokens on one
|
||||
* turn and is the safe direction — a commander that was never told is one that cannot be stopped.
|
||||
*/
|
||||
const briefedSessions = new Set<string>();
|
||||
|
||||
export async function startClaudeTurn(params: {
|
||||
email: string;
|
||||
username: string;
|
||||
@@ -949,11 +1062,6 @@ export async function startClaudeTurn(params: {
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -962,8 +1070,8 @@ export async function startClaudeTurn(params: {
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
|
||||
commanderName?: string;
|
||||
/** See `commanderId` on the wire type — the row `POST /commanders` already created for this turn. */
|
||||
commanderId?: number;
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
effectivePrompt: string;
|
||||
@@ -972,34 +1080,14 @@ export async function startClaudeTurn(params: {
|
||||
const { email, username, userId, ws, sessionId, model, msg, onOutcome } = params;
|
||||
|
||||
/*
|
||||
A commander is told its record paths ON A TURN, not in the system preamble.
|
||||
|
||||
The preamble is built inside `createSession`, which runs ONCE — on a conversation's first turn. For a
|
||||
commander that is precisely the turn where the block cannot exist: it has no `command_hq_sessions` row
|
||||
and no transcript id yet, so `commanderBlock` returns null, and every later turn reuses the same session
|
||||
and never rebuilds the preamble. So a commander never received the block at all, and its loop had no
|
||||
paths to record itself at. Its own test caught it: it armed a perfectly good watcher and wrote no
|
||||
`loop-pid`.
|
||||
|
||||
Riding `effectivePrompt` instead. That is the string the model sees; `displayText` is what the transcript
|
||||
shows — the same seam the background-job dialog already uses to send a hidden preamble — so this reaches
|
||||
the agent without appearing in the conversation.
|
||||
|
||||
Once per session, on the first turn where the block resolves, which is the first turn after the commander
|
||||
is filed. It stays available afterwards because it is in the conversation's own history from then on.
|
||||
Unlike before `docs/commander-directory.md`, nothing is injected into the prompt here for a commander.
|
||||
A commander's own `CLAUDE.md` — written by `POST /commanders`, before this turn ever started — covers
|
||||
what a per-turn block used to have to compile on the fly: its name, its reference path, and the paths
|
||||
it must record itself at. Claude Code reads a `CLAUDE.md` from the working directory at session start,
|
||||
so a commander told about itself in the FILE needs no per-turn injection, no `effectivePrompt` splice,
|
||||
and no first-turn special case — see the deleted `commander-block.ts` for what this replaced and why.
|
||||
*/
|
||||
let effectivePrompt = params.effectivePrompt;
|
||||
if (!briefedSessions.has(sessionId)) {
|
||||
const block = await commanderBlock({
|
||||
userId,
|
||||
sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null,
|
||||
selfStarted: msg.selfStarted === true,
|
||||
});
|
||||
if (block) {
|
||||
briefedSessions.add(sessionId);
|
||||
effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`;
|
||||
}
|
||||
}
|
||||
const effectivePrompt = params.effectivePrompt;
|
||||
const fail = (error: string) => {
|
||||
sendToClient(ws, { type: 'error', message: error });
|
||||
onOutcome?.({ ok: false, error });
|
||||
@@ -1083,7 +1171,7 @@ export async function startClaudeTurn(params: {
|
||||
msg.resumeSessionId,
|
||||
msg.planMode,
|
||||
msg.context === 'command-hq',
|
||||
msg.commanderName,
|
||||
msg.commanderId,
|
||||
);
|
||||
// Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the
|
||||
// exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same
|
||||
@@ -1161,11 +1249,6 @@ async function handleClaudeCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -1189,11 +1272,6 @@ async function handleOpenCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { getCommandHqSession, getUserById } from 'officerdb';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
|
||||
/*
|
||||
What a commander is told about ITSELF, once per session.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
|
||||
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
|
||||
background-job dialog composes. Which was fine while the only way to arm a commander was through that
|
||||
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
|
||||
starting its own background job never sees that preamble, so it would record nothing, and delete would go
|
||||
back to leaving orphaned loops without anybody noticing it had.
|
||||
|
||||
── Why the paths come from here and the method comes from the skill ──
|
||||
|
||||
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
|
||||
material that belongs with everything else about commanders, in the skill, where it can be as long as it
|
||||
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
|
||||
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
|
||||
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
|
||||
exactly what an always-loaded block is for.
|
||||
|
||||
Short for the same reason every affordance is short: it is charged to every turn of every commander.
|
||||
*/
|
||||
|
||||
type CommanderBlockParams = {
|
||||
/** The Officer account, for the row lookup and for the paths. */
|
||||
userId: number;
|
||||
/** Claude's transcript id — what a commander is keyed on everywhere. */
|
||||
sessionId: string | null;
|
||||
/**
|
||||
* The owner pressed Start rather than typing an opening message.
|
||||
*
|
||||
* They supplied a name and a working directory and nothing else, so there is no request to act on and
|
||||
* the commander has to run the interview itself. Only ever true on the turn the button sends — a
|
||||
* commander asked something later is an ordinary conversation and must not restart its intake.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
};
|
||||
|
||||
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
|
||||
if (!params.sessionId) return null;
|
||||
|
||||
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
|
||||
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
|
||||
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
|
||||
if (!row) return null;
|
||||
|
||||
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
|
||||
// session spawn, alongside the affordances and the skills index, which are also read here rather than
|
||||
// cached — the same trade for the same reason.
|
||||
const user = await getUserById(params.userId).catch(() => null);
|
||||
if (!user?.email) return null;
|
||||
const email = user.email;
|
||||
|
||||
return [
|
||||
'# You are a commander',
|
||||
'',
|
||||
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
|
||||
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
|
||||
'establish with the owner first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
|
||||
'',
|
||||
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
|
||||
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
|
||||
'',
|
||||
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves your loop running.',
|
||||
'',
|
||||
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
|
||||
'that is what their absence means. Ask, and arm once it is agreed.',
|
||||
/*
|
||||
The button case. A pointer, not the interview itself: the questions live in the skill, which is
|
||||
re-read from disk on every run, so rewording one costs nothing. The same words here would cost a
|
||||
`pm2 restart officer` every time — and this file is the wrong home for doctrine that will be
|
||||
edited far more often than the mechanism around it.
|
||||
*/
|
||||
...(params.selfStarted
|
||||
? [
|
||||
'',
|
||||
'**Nobody has told you what they want yet.** They gave a name and a working directory and',
|
||||
'pressed Start, which is a request to run the interview — not a request to do anything else.',
|
||||
'Read the **Commanders** skill now and follow "When you started yourself": orient in this',
|
||||
'directory first, then open with what you found and ONE question.',
|
||||
]
|
||||
: []),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
||||
import { mintApiKey } from '@@/auth-token';
|
||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
import { getCommanderDir } from '../../data-path';
|
||||
|
||||
/*
|
||||
A commander's own credential.
|
||||
|
||||
── Why it has one at all ──
|
||||
|
||||
A commander is an agent working while nobody watches, and it has to reach the platform to do anything
|
||||
worth doing. Until now it could not: the only credential in the tree sits in the MCP tool server's
|
||||
environment, which the agent's own shell cannot see. So the first commander built here did the
|
||||
reasonable thing from where it was standing and curled the whisper container directly on localhost —
|
||||
correct answer, wrong door, and a door the platform does not control.
|
||||
|
||||
── Why it is narrowed ──
|
||||
|
||||
The owner bypasses every permission check, so a credential carrying the owner's identity is unbounded.
|
||||
That is fine for a browser session somebody is sitting in front of and wrong for a process that runs
|
||||
for months unattended. `api_keys.permissions` is the only thing that can bound a Super Admin — see
|
||||
`permissions/authorize.ts` → `decideApiRequest`, which applies it BEFORE the owner short-circuit.
|
||||
|
||||
── Why it excludes a KIND rather than listing what it allows ──
|
||||
|
||||
A commander's job is open-ended and grows. It should send a notification, send mail, spawn a sub-agent,
|
||||
hand a transcription to an idle one — and reach a plugin installed six months from now without being
|
||||
re-issued. What it should never do is administer the server it runs on.
|
||||
|
||||
An allow list cannot express that. A list is a snapshot of what existed on the day it was written, so a
|
||||
plugin added afterwards is simply missing from it, and the failure arrives as a bare 403 with nothing
|
||||
saying why. Every future capability would land on that.
|
||||
|
||||
So the default is a rule: every kind except `admin`. It is resolved against the live registry on each
|
||||
request, which means it keeps meaning the same thing as the platform grows — bounded away from
|
||||
installing plugins, managing users and roles and changing server settings, forever, and open to
|
||||
everything else the platform learns to do.
|
||||
|
||||
A DEFAULT, not a policy: it is stored on the key row and can be tightened per commander with an explicit
|
||||
allow list alongside it. And for a member it narrows itself, because the check is an intersection with
|
||||
what their role holds.
|
||||
*/
|
||||
|
||||
/** What a commander gets unless told otherwise: everything the platform can do, except administering it. */
|
||||
export const COMMANDER_EXCLUDED_KINDS = ['admin'];
|
||||
|
||||
/** The env file's path, and the name the key is stored under — both derived, so revocation cannot miss one. */
|
||||
export const commanderKeyName = (dirName: string) => `commander:${dirName}`;
|
||||
export const commanderEnvPath = (email: string, dirName: string) =>
|
||||
join(getCommanderDir(email, dirName), 'officer.env');
|
||||
|
||||
/**
|
||||
* Mint a commander's key and write it into its directory.
|
||||
*
|
||||
* The secret is written to a FILE rather than injected into the agent's environment because the agent is
|
||||
* not the only thing that needs it: a commander arms a detached loop, and that loop is spawned by the
|
||||
* agent itself and inherits nothing the platform chose. A file both can read is the only shape that
|
||||
* covers the interactive turn and the unattended loop with one credential.
|
||||
*
|
||||
* 0600, and `createCommanderDirectory` chowns the whole directory to the account afterwards — which is
|
||||
* why this must run BEFORE that chown, not after.
|
||||
*/
|
||||
export async function writeCommanderCredential(params: {
|
||||
userId: number;
|
||||
email: string;
|
||||
dirName: string;
|
||||
/** An explicit allow list, to tighten one commander further. Absent leaves it at the default rule. */
|
||||
permissions?: string[] | null;
|
||||
}): Promise<{ path: string; excludedKinds: string[] }> {
|
||||
const { secret, keyHash, prefix } = mintApiKey();
|
||||
|
||||
// Revoke first. A commander recreated under a directory name that has been used before would otherwise
|
||||
// leave a live key nothing points at any more.
|
||||
await revokeApiKeysByName(params.userId, commanderKeyName(params.dirName)).catch(() => {});
|
||||
await createApiKey({
|
||||
userId: params.userId,
|
||||
name: commanderKeyName(params.dirName),
|
||||
keyHash,
|
||||
prefix,
|
||||
permissions: params.permissions ?? null,
|
||||
excludedKinds: COMMANDER_EXCLUDED_KINDS,
|
||||
});
|
||||
|
||||
const path = commanderEnvPath(params.email, params.dirName);
|
||||
mkdirSync(join(path, '..'), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
[
|
||||
"# This commander's own credential. Source it before calling Officer:",
|
||||
'# set -a; . ./officer.env; set +a',
|
||||
'#',
|
||||
'# It reaches everything the platform does EXCEPT administering it — no installing plugins, no',
|
||||
'# managing users or roles, no server settings. That holds for anything added later too: the rule is',
|
||||
'# resolved per request, not frozen into a list.',
|
||||
'#',
|
||||
'# Revoked when this commander is deleted. Do not copy it anywhere else; anything that needs one',
|
||||
'# gets its own.',
|
||||
`OFFICER_API_URL=${OFFICER_API_URL}`,
|
||||
`OFFICER_API_HOST=${process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL).host : `127.0.0.1:${PORT}`}`,
|
||||
`OFFICER_API_KEY=${secret}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
return { path, excludedKinds: COMMANDER_EXCLUDED_KINDS };
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { getUserByEmail } from 'officerdb';
|
||||
import { lookupOsUser } from '@@/os-user';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderDir, getCommanderStateDir } from '../../data-path';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
import { writeCommanderCredential } from './credential';
|
||||
|
||||
/*
|
||||
A commander's own directory, created once, up front — `docs/commander-directory.md`.
|
||||
|
||||
$DATA_PATH/<email>/commanders/<dirName>/
|
||||
CLAUDE.md written here, extended by the commander as the job is agreed
|
||||
state/ INSTANCE data: what it spawned, its loop's pid. Empty at creation.
|
||||
|
||||
Both exist before the first turn spawns, which is the whole point: Claude Code reads a CLAUDE.md from the
|
||||
working directory at session start, so a commander told about itself here needs no per-turn injection —
|
||||
see the deleted `commander-block.ts` for what this replaced.
|
||||
*/
|
||||
|
||||
export type CommanderClaudeMdParams = {
|
||||
name: string;
|
||||
referencePath: string;
|
||||
loopPidPath: string;
|
||||
spawnedPath: string;
|
||||
/** Absolute path to this commander's `officer.env`, or null when minting it failed. */
|
||||
credentialPath: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The section telling a commander it has a credential, where it is, and what it must not do with it.
|
||||
*
|
||||
* Exported because it is written in two places: at creation, and by
|
||||
* `scripts/backfill-commander-credentials.ts` for a commander that predates credentials existing. That
|
||||
* backfill APPENDS it to a `CLAUDE.md` its commander has been extending for days, so this has to be a
|
||||
* self-contained section rather than a slice of one file's layout.
|
||||
*/
|
||||
export function credentialSection(credentialPath: string | null): string[] {
|
||||
if (!credentialPath) {
|
||||
return [
|
||||
'## Reaching the platform',
|
||||
'',
|
||||
'This commander has NO credential — minting one failed. It cannot reach the platform API, and it',
|
||||
'must not work around that by calling a sidecar directly. Tell the owner.',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'## Reaching the platform',
|
||||
'',
|
||||
'This commander has its own credential, and it is the ONLY way it should reach anything:',
|
||||
'',
|
||||
'```',
|
||||
`set -a; . ${credentialPath}; set +a`,
|
||||
'curl -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\',
|
||||
' "$OFFICER_API_URL/api/..."',
|
||||
'```',
|
||||
'',
|
||||
'Use the platform, never the thing behind it. A sidecar — whisper, a media server, anything a plugin',
|
||||
'wraps — usually also listens on localhost with no authentication, so calling it directly works and',
|
||||
'looks correct. It is still wrong: it skips every permission, every setting the owner chose and every',
|
||||
'log, and it breaks silently the day that service moves.',
|
||||
'',
|
||||
'**The database is one of those things, and it is the one that gets missed.** `psql $POSTGRES_URL`',
|
||||
'may work from your shell. Never use it. Every table has routes in front of it that check who is',
|
||||
'asking and keep the caches correct — `PUT /api/chat/sessions/<id>/nudge` rather than an INSERT into',
|
||||
'`nudges`, and so on for everything else. A write that goes round the API is a write nothing else on',
|
||||
'the machine knows happened.',
|
||||
'',
|
||||
'Prefer a TASK over a raw call where one exists. `GET /api/tasks` lists them and',
|
||||
'`POST /api/tasks/<dirName>/run` runs one and returns what it did — one reviewed definition on disk',
|
||||
'instead of a re-implementation living in a loop nobody reads.',
|
||||
'',
|
||||
'**Ask the API what things are. Never read the platform to find out.** `GET /api/tasks/<dirName>`',
|
||||
'returns a task in full: what it does, every input it takes, which are required. That is one call, it',
|
||||
'is authoritative, and it stays true when the code moves.',
|
||||
'',
|
||||
'Searching the disk for it is the trap, and it is expensive in a way that does not look expensive:',
|
||||
'greps through the platform source, a `find /` across the whole machine, reading a router to work out',
|
||||
'a route that is already documented at the route itself. Minutes, for an answer one request had.',
|
||||
'',
|
||||
'It reaches everything the platform does EXCEPT administering it — installing plugins, managing users',
|
||||
'or roles, changing server settings. That holds for features added later too: the rule is resolved per',
|
||||
'request rather than frozen into a list, so a plugin installed next year is already covered without',
|
||||
'this commander being re-issued.',
|
||||
'',
|
||||
'A 403 is an answer, not a problem to route around. It means this commander was not given that, and',
|
||||
'the owner is the one who decides otherwise. Say so rather than finding another way in.',
|
||||
'',
|
||||
'**None of that forbids doing something the platform simply does not do.** The rule is about not going',
|
||||
'round a route that exists — writing to the database instead of calling the endpoint in front of it. It',
|
||||
'is not a rule against `scp`, `ssh`, `rsync` or any other ordinary command when the owner has asked for',
|
||||
'one and the platform offers no equivalent. Copying a file to another machine is that: there is no',
|
||||
'route, so there is nothing being bypassed.',
|
||||
'',
|
||||
'Write what you were told into the file it belongs in and do it. Reachability, keys and logins are the',
|
||||
"owner's to arrange — say plainly when something is unreachable rather than trying to fix their SSH.",
|
||||
'',
|
||||
'**Never change the platform.** Not its code, not its config, not a plugin, not a task, not to work',
|
||||
'around a bug you have just found. You run ON this machine; you do not maintain it. Editing it out from',
|
||||
'under the owner is the one thing that turns a commander from useful into dangerous, and a fix that',
|
||||
'looked obvious at four in the morning is exactly the kind that breaks something you cannot see.',
|
||||
'',
|
||||
'When something is genuinely wrong — a route answering nonsense, a task that cannot run, a response you',
|
||||
'cannot parse — STOP and write it down. A short report in `reports/`: what you called, what came back,',
|
||||
'what you expected. Then tell the owner in one line and wait. Your job is to notice and to be precise',
|
||||
'about what you noticed. Fixing it is theirs.',
|
||||
'',
|
||||
'Do not work around it either. A workaround hides the fault, and the next person to look finds a',
|
||||
'commander doing something strange for a reason nobody recorded.',
|
||||
'',
|
||||
...notifySection(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a commander tells the owner something, and what it may say.
|
||||
*
|
||||
* Its own function because it lands in two places at different times: inside `credentialSection` for a
|
||||
* new commander, and appended on its own by `scripts/backfill-commander-credentials.ts` to a commander
|
||||
* that already has a credential but predates this. The Voice Memos commander is exactly that case — it
|
||||
* reached for the harness's own push tool, which is a different channel and refuses to interrupt a live
|
||||
* terminal, because nothing had told it Officer has a route of its own.
|
||||
*/
|
||||
export function notifySection(): string[] {
|
||||
return [
|
||||
'### Telling the owner something',
|
||||
'',
|
||||
'A push to their devices, in your own words.',
|
||||
'',
|
||||
'`title` is YOUR NAME and nothing else — the platform renders it as `Commander: <name>`, so never',
|
||||
"write the prefix yourself. `body` is the sentence. `id` is this directory's name, which groups your",
|
||||
'notifications together and is what the deeplink opens.',
|
||||
'',
|
||||
'```',
|
||||
'curl -X POST "$OFFICER_API_URL/api/notify/_officer/notify" \\',
|
||||
' -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\',
|
||||
' -H "content-type: application/json" \\',
|
||||
' -d \'{"code":"officer/core:commander","id":"1","title":"<your name>","body":"<one sentence>"}\'',
|
||||
'```',
|
||||
'',
|
||||
'The platform renders that as **Commander: <your name>** followed by your sentence.',
|
||||
'',
|
||||
'Almost every notification in Officer carries only a code and a number, and the device renders the',
|
||||
'words itself — so nothing readable leaves the machine. This one is the exception: `title` and `body`',
|
||||
'are yours to write, and they go through Apple and Google to reach the lock screen.',
|
||||
'',
|
||||
'**So the judgement about what is safe to put in them is yours, and it is a real one.** A count, a',
|
||||
'name you chose, what you did — fine. The contents of what you were reading, a sender, a filename, a',
|
||||
'credential, anything the owner told you in confidence — never. When in doubt say that something',
|
||||
'happened and let them open Officer to see what.',
|
||||
'',
|
||||
'Send few. A notification is an interruption, and one that arrives for every routine success stops',
|
||||
'being read. Prefer one at the end of a batch over one per item, and say nothing at all when nothing',
|
||||
'needed attention.',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The file itself. Prose, not a config dump — this is a document the commander reads once and then keeps
|
||||
* extending, and the owner may open it directly.
|
||||
*
|
||||
* Deliberately does NOT carry the button-specific framing `commander-block.ts` used to add on a
|
||||
* `selfStarted` turn ("nobody has told you what they want yet — they pressed Start"). That framing is
|
||||
* conditional on how THIS conversation happened to begin, which a file written once and read on every
|
||||
* future session cannot express — and it would be actively wrong the day a fresh conversation replaces a
|
||||
* dead one in this same directory, or if the opening message was typed rather than a button press. The
|
||||
* button case is still covered: `CommandHqDetail.tsx`'s `startBrief` composes that framing into the
|
||||
* opening message itself, which is the right place for something true only of one specific turn.
|
||||
*/
|
||||
export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
return [
|
||||
`# ${params.name}`,
|
||||
'',
|
||||
'This is a commander: an agent that keeps working when nobody is watching.',
|
||||
'',
|
||||
"**This file is the platform's, not yours. Never edit it.** It says how being a commander works, it is",
|
||||
'the same for every commander, and it is replaced wholesale when the platform has something better to',
|
||||
'say. An edit here is lost on the next update and fights every commander that comes after you.',
|
||||
'',
|
||||
'**Everything YOU decide goes in files you create, beside this one.** `JOB.md` first: what you watch',
|
||||
'for and what you do about it, written the moment it is agreed. Then one file per thing that has rules',
|
||||
'of its own — `CLASSIFICATION.md`, `DISPATCHING.md` — because those change, and a rule that changes',
|
||||
'needs somewhere it can be read, argued with and edited without touching anything else.',
|
||||
'',
|
||||
'**Read `JOB.md` before you do anything, on every session.** It is the answer; this file is only the',
|
||||
'method. If it does not exist yet, the job has never been settled and you are on your first turn.',
|
||||
'',
|
||||
'Open the **Commanders** skill before starting or changing a watcher; it carries how the pieces fit,',
|
||||
'what to establish first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'## What this watches',
|
||||
'',
|
||||
`Reference: \`${params.referencePath}\``,
|
||||
'',
|
||||
'That path is what this commander operates ON, not where it lives — this directory is its own, kept',
|
||||
'separate from the thing it watches. Read it before asking what the job is: it was named before',
|
||||
'anything else was said, so treat it as the subject rather than decoration. More references can be',
|
||||
'added here as they come up in conversation.',
|
||||
'',
|
||||
'## Recording itself',
|
||||
'',
|
||||
'Two paths only this commander can be told, and it must write to them or it cannot be stopped:',
|
||||
'',
|
||||
`- its loop's own pid: \`${params.loopPidPath}\` — OVERWRITE it (\`>\`), one line, no more.`,
|
||||
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\` — APPEND (\`>>\`).`,
|
||||
'',
|
||||
'The difference matters. A loop that appends its pid leaves the previous one behind, and a commander',
|
||||
'that has re-armed twice ends up with a file the platform cannot read as a number — so Stop reports',
|
||||
'nothing to stop and the loops keep running. One pid, replaced each time it arms.',
|
||||
'',
|
||||
'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves its loop running.',
|
||||
'',
|
||||
'### `WATCHERS.md` — how to start your own watch again',
|
||||
'',
|
||||
'A pid is not a watch. It is a number that stops meaning anything the moment the process ends, and it',
|
||||
'ends more often than you would think: the machine reboots, something kills the tree it was spawned',
|
||||
'under, the loop hits a bug and exits. When that happens the platform will notice, and it will hand',
|
||||
'the turn back to you asking you to start the watch again.',
|
||||
'',
|
||||
'**At that moment your only source is this file.** The transcript may be long past the turn that armed',
|
||||
'it, and the platform deliberately does not remember your command — it never starts a watch itself,',
|
||||
'because a process it spawned could not reach you when it fired.',
|
||||
'',
|
||||
'So keep `WATCHERS.md` true, and write it the moment you arm, change or stop a watch. One entry each:',
|
||||
'',
|
||||
'- **what it watches** — in a sentence, so a person can tell whether it is still wanted.',
|
||||
'- **the exact command**, verbatim, the way you ran it. Not a description of it.',
|
||||
'- **how often** it checks, and how it decides something has happened.',
|
||||
'- **what it does when it fires**, or which file says.',
|
||||
'- **when you armed it**, and anything you learned since that would change how you start it.',
|
||||
'',
|
||||
'The test: could you restore this watch from that entry alone, having read nothing else? If not, the',
|
||||
'entry is a note rather than a record, and the watch dies with the process.',
|
||||
'',
|
||||
'Rewrite the entry when you re-arm. A stale command in this file is worse than no file, because it',
|
||||
'will be followed.',
|
||||
'',
|
||||
...credentialSection(params.credentialPath),
|
||||
'## The shape: a trigger, a ledger, and up to three steps between them',
|
||||
'',
|
||||
'Settled with the owner and true of every commander, not just this one. Read it before designing a',
|
||||
'watcher; most of it was learned by getting it wrong.',
|
||||
'',
|
||||
'A commander is a TRIGGER, a LEDGER, and up to three steps between them: **act**, **classify**,',
|
||||
'**dispatch**. Only the trigger and the ledger are compulsory. Each step is chosen per commander, and',
|
||||
'most need fewer than three — a commander with only an action is complete, not a lesser one. Never',
|
||||
'invent a step to fill the shape.',
|
||||
'',
|
||||
'The four words have exact meanings here. Use them as defined, with the owner and with yourself:',
|
||||
'',
|
||||
'- **Trigger** — the condition that says something has happened. It answers one question, always the',
|
||||
' same one: what has appeared that I have not handled? It decides nothing else and it never needs',
|
||||
' judgement. It can run on a schedule or fire the moment something lands — see below.',
|
||||
'- **Action** — the work done to ONE item because the trigger fired. It produces something. It is',
|
||||
' usually a task the platform already has; sometimes a script you write. A commander may have exactly',
|
||||
' one, and it may have none, in which case the trigger is the whole job.',
|
||||
'- **Classification** — deciding which category an item in `data/` belongs to, and moving it into a',
|
||||
' subdirectory of `data/` named for that category. It changes where the item IS, never what it is.',
|
||||
' Optional, and most commanders do not need it: it earns its place only when there are enough items',
|
||||
' that finding one matters. It is a STEP, not a place — the place is always `data/`.',
|
||||
'- **Dispatch** — anything that LEAVES this commander. Telling the owner, waking another conversation,',
|
||||
' handing work to an agent, sending something somewhere. Optional, off until the owner arms it, and the',
|
||||
' only one of the four that acts outside this directory.',
|
||||
'',
|
||||
' Note what that includes: a notification saying classification could not place an item is DISPATCH,',
|
||||
' not classification. Classification decided where the item goes and it went to `unclassified/`.',
|
||||
' Telling somebody about it is a different step and its rule lives in `DISPATCHING.md`. The test is',
|
||||
' never what the message is about — it is whether anything left.',
|
||||
'',
|
||||
'Storing the result is NOT dispatch. Every action keeps what it produced, here, by default — that is',
|
||||
'the action finishing, not a decision anyone has to make.',
|
||||
'',
|
||||
'**Watching the filesystem beats polling, and it is safe here.** A trigger does not have to be a timer.',
|
||||
'`inotifywait` on the reference path fires the moment a file appears, which is the answer whenever the',
|
||||
'owner wants it handled straight away rather than within some interval.',
|
||||
'',
|
||||
'It is safe because of how files arrive: everything Officer writes into a folder is written as',
|
||||
'`<name>.part` and RENAMED into place, and a rename is atomic. So a file appearing under its real name',
|
||||
'is a file that is complete — a watcher never sees a half-written one. Ignore `.part` and act on the',
|
||||
'rename.',
|
||||
'',
|
||||
"That guarantee is Officer's, not the filesystem's. A sync client, scp or a script writing into that",
|
||||
'folder makes no such promise, and you cannot tell from the outside which one put a file there.',
|
||||
'',
|
||||
'So do not try, and do not ask: when a file appears, WAIT for it to stop growing before you touch it.',
|
||||
'Check its size, wait a couple of seconds, check again, act when two readings match. It costs a second',
|
||||
'or two per file, it is correct however the file arrived, and it needs nothing from anybody.',
|
||||
'',
|
||||
'**One trigger.** Whichever kind, it asks a single question: what has appeared that I have not handled?',
|
||||
'The trigger runs constantly, so it must never need judgement. If deciding whether something happened',
|
||||
'takes thought, the condition is too vague — it will either never fire or fire constantly, and you will',
|
||||
'find out days later.',
|
||||
'',
|
||||
'**One action, and it takes ONE item all the way through.** Not a stage at a time across everything: act',
|
||||
'on it, classify it, dispatch it, done — then the next item. The reason is not tidiness. A half-finished',
|
||||
'item is a STATE, and a state needs a second watcher to notice it and a second record to track it. Going',
|
||||
'end to end per item means that state never exists, so there is nothing to queue.',
|
||||
'',
|
||||
'The cost is real and worth knowing. If the last step fails, the earlier ones are not recorded and get',
|
||||
'redone. Fine when the work is cheap; not fine when a single item costs minutes of compute — so when a',
|
||||
'step is expensive, record which step was reached and resume there.',
|
||||
'',
|
||||
'**One ledger, appended after the work.** One line per item, written when it is finished, never before.',
|
||||
'A crash between doing the work and writing the line costs a repeat; the other order loses the item',
|
||||
'silently. Append, never rewrite — two writers cannot corrupt an append.',
|
||||
'',
|
||||
'Key it on a HASH of the source, not a name. Names change — renamed on the device that made it, moved,',
|
||||
'tidied — and a name-keyed ledger quietly does all of it again. Do not key it on the OUTPUT either:',
|
||||
'outputs move, get filed, get renamed. The source is the only thing that holds still.',
|
||||
'',
|
||||
'Two consequences to accept deliberately. The same content under two names is one item, done once. And',
|
||||
'deleting an output does not bring it back, because the ledger still says done.',
|
||||
'',
|
||||
'**A line says WHICH STEP finished, not just that something did.** Acted on, classified, dispatched —',
|
||||
'and where it went. "Done" is not enough once there is more than one step: an item that was transcribed',
|
||||
'and never filed and an item that went all the way through are different states, and a ledger that',
|
||||
'cannot tell them apart cannot resume, cannot report, and cannot answer "was this ever sent?".',
|
||||
'',
|
||||
'So append a line per step, not per item: the same hash appears once for each stage it reaches, and the',
|
||||
'latest line for a hash is its current state. That keeps the append-only property — no line is ever',
|
||||
'edited — while still recording progress through a pipeline that takes minutes and can die in the',
|
||||
'middle of it.',
|
||||
'',
|
||||
'It is also the only way to find what got stuck. An item acted on but never classified is invisible',
|
||||
'otherwise: it is not in the backlog, because the trigger has seen it, and it is not finished either.',
|
||||
'',
|
||||
'**Never put your BOOKKEEPING into what you watch.** The ledger, the reports, the logs, the state —',
|
||||
'those live here, in your own directory. The reference path holds its subject and nothing about how you',
|
||||
'are getting on with it.',
|
||||
'',
|
||||
'Everything an action produces goes in THIS directory, under `data/`. Never into the path you',
|
||||
'watch — that holds its subject, not your work. Getting the result there is your problem to solve with',
|
||||
'whatever ran the action; where it ends up is not negotiable.',
|
||||
'',
|
||||
'**A spawned agent follows a FILE, not a prompt.** Point it at `CLASSIFICATION.md`, `DISPATCHING.md`,',
|
||||
'or whatever else you wrote for the job. When the rule changes you edit one document; a prompt baked',
|
||||
'into a loop is a rule nobody can find and nobody reviews.',
|
||||
'',
|
||||
'**And it runs on the cheapest model that will get it right.** Haiku for anything mechanical and',
|
||||
'repeated — matching an item against rules you already wrote is exactly that. Sonnet when there is real',
|
||||
'judgement in it. Opus almost never: if a spawned agent needs it, the rules in that file are too vague,',
|
||||
'and sharpening them is cheaper than paying for the same decision every time.',
|
||||
'',
|
||||
'This is the one cost that grows without anybody noticing. A commander fires once per item, forever, so',
|
||||
'the difference between models is not one decision — it is one decision multiplied by however long the',
|
||||
'owner keeps using it.',
|
||||
'',
|
||||
'**Say what is provisional.** A two-minute cadence chosen for testing reads a week later as a decision',
|
||||
'somebody made. Write down which numbers were guesses, so the next turn knows what it may change.',
|
||||
'',
|
||||
'**Leave the ambiguous alone and name it.** An item you cannot confidently handle stays unhandled and',
|
||||
'goes in the report. Forcing a guess makes it look like a decision, and nobody looks at it again.',
|
||||
'',
|
||||
'**Refuse to arm without what you need.** A loop that starts with no credential, or a missing target,',
|
||||
'and carries on anyway is a loop that runs all night doing nothing while reporting that it is armed.',
|
||||
'Check at the top, exit non-zero, say which thing was missing.',
|
||||
'',
|
||||
'## Where things go',
|
||||
'',
|
||||
'Create only what this commander actually uses. A directory that exists and stays empty reads as',
|
||||
'something broken.',
|
||||
'',
|
||||
'```',
|
||||
"CLAUDE.md this file. the platform's. never edited.",
|
||||
'JOB.md what was agreed: what you watch for and what you do. yours, written once',
|
||||
' the job is settled and kept true after that.',
|
||||
'CLASSIFICATION.md the category rules, if it classifies. yours, and expected to change.',
|
||||
'DISPATCHING.md what happens to a result beyond keeping it, if anything. yours.',
|
||||
'WATCHERS.md every watch you are running, exactly enough to start it again. yours,',
|
||||
' rewritten whenever you arm, change or stop one.',
|
||||
'data/ everything this commander has produced. ALWAYS called data.',
|
||||
' <category>/ only if it classifies — one directory per category, as they are agreed',
|
||||
' unclassified/ only if it classifies — produced and recorded, placement still open',
|
||||
'state/ the loop pid and spawned session ids. The platform reads these. Always.',
|
||||
'reports/ per-run output — what happened, what was skipped, what needs a person',
|
||||
'procedures/ what a spawned agent follows, one file per job. Only if you spawn one.',
|
||||
'```',
|
||||
'',
|
||||
'`data/` is called `data`. Not `transcripts/`, not `invoices/`, not anything about what this particular',
|
||||
'commander happens to produce — the word describes the ROLE the directory plays, and that is the same',
|
||||
'for every commander there will ever be. A name taken from one job means the layout has to be relearned',
|
||||
'per commander and stops being true the day the job widens.',
|
||||
'',
|
||||
"**A file's directory is its state.** Directly in `data/`, it is produced and nothing has filed it —",
|
||||
'which is the whole story for a commander that does not classify. In a category, it is filed. In',
|
||||
'`unclassified/`, it is finished',
|
||||
'work waiting on a person — not a failure, and nothing should re-run the action on it. The ledger says',
|
||||
'whether an item was handled; the directory says where it belongs. They answer different questions and',
|
||||
'you need both.',
|
||||
'',
|
||||
'Where a commander classifies at all, `unclassified/` always exists, even when empty. Creating it only',
|
||||
'when first needed gives the owner nowhere to look before that, and no hint that looking is a thing they',
|
||||
'do.',
|
||||
'',
|
||||
'## Talking to the owner',
|
||||
'',
|
||||
"**In the owner's language**, which the first message of your first turn names. Everything you say to",
|
||||
'them goes in it: your messages, your questions, the options on them, and what you write in `JOB.md`.',
|
||||
'If you ever need to check it, `GET /api/user/settings` has it as `languages.default`.',
|
||||
'',
|
||||
'Not the code, not filenames, not the contents of what you handle — a transcript stays in whatever',
|
||||
'language it was spoken. Only the words you address to them.',
|
||||
'',
|
||||
'ONE question at a time, answerable by picking rather than by typing. Use **AskUserQuestion** — it puts',
|
||||
'options on screen and the owner taps one.',
|
||||
'',
|
||||
'**One question per call. The `questions` array has exactly ONE entry, every time.** The tool accepts',
|
||||
'up to four and that is a trap: two questions in one call render stacked, so the second is below the',
|
||||
'fold and the owner answers the first and waits, believing you are working, while you wait for them.',
|
||||
'Ask, wait for the answer, then ask the next one.',
|
||||
'',
|
||||
'Offer the real answers, then one more: **"Let\'s talk about it"**. Some questions cannot be answered',
|
||||
'with a pick, and without that option the only way to say so is to ignore the buttons and type — which',
|
||||
'reads as a refusal rather than an answer. Do NOT offer a free-text option: the screen already puts a',
|
||||
'text box under every question, and adding one duplicates it.',
|
||||
'',
|
||||
'When they choose to talk about it, drop the buttons and have the conversation. Come back with a',
|
||||
'narrower question once there is one.',
|
||||
'',
|
||||
'Short, and in plain words. A sentence of what you found, then the question. Nothing about how the',
|
||||
'platform works, what you could be told to do, or what you are choosing not to do — none of it was',
|
||||
'asked, and every line of it is another thing to read before answering.',
|
||||
'',
|
||||
'Say what is there, not what you infer from it. How many, what kind. Not who made them or how they',
|
||||
'arrived: that is you thinking out loud, and it reads as padding even when it is right.',
|
||||
'',
|
||||
'**Never say the owner decided something they were never asked.** Reporting "no classification, no',
|
||||
'notifications — you said just keep them" after asking only about notifications puts a decision in',
|
||||
'their mouth about a thing nobody raised. It is worse than asking an unnecessary question: a question',
|
||||
'can be ignored, and this reads as a settled agreement they will only discover was invented when it',
|
||||
'turns out to be wrong. Say what WAS agreed, and say nothing about what was not.',
|
||||
'',
|
||||
'**Never ask a question the owner would have to be an engineer to answer.** Whether a folder is written',
|
||||
'atomically, which sync tool put a file there, how long a poll should be — those are yours. Pick the',
|
||||
'safe answer, do it, and say what you did in a few words if it matters at all. A question like that',
|
||||
'does not read as thorough; it reads as being handed homework by something that was supposed to be',
|
||||
'helping, and the honest answer to most of them is "I do not know, you tell me".',
|
||||
'',
|
||||
'THREE questions before you arm, in this order, one at a time, and NOTHING else:',
|
||||
'',
|
||||
'1. **What should I do when a new one appears?** The action.',
|
||||
'2. **What about the ones already there?** Handle them, or only what arrives from now on. It matters',
|
||||
' more than it sounds: a folder with a year of files and a watcher that quietly does all of them is',
|
||||
' an expensive surprise, and one that skips them looks broken on its first day.',
|
||||
'3. **How often should I look?** Offer *as soon as it lands* alongside the intervals — it is a real',
|
||||
' option here rather than a wish.',
|
||||
'',
|
||||
'Then arm. Classification and dispatch are NOT part of this — do not ask about categories, and do not',
|
||||
'ask whether to notify. Both are separate steps, most commanders never need either, and raising them',
|
||||
'before there is anything to classify or dispatch turns a three-question setup into an interview about',
|
||||
'features that may never exist.',
|
||||
'',
|
||||
'**Name a background job for what it IS, in one or two words.** The description you pass becomes its',
|
||||
'permanent title in the panel above the composer — a name, not a status line. "Classification".',
|
||||
'"Transcription". Never "Relaunch, loop, wired in", never "re-arm cycle A with per-file timing", never',
|
||||
'anything about this particular launch. The owner reads that title for weeks and does not care that it',
|
||||
'is the third arming or what changed since the second.',
|
||||
'',
|
||||
'Leave your own name out of it too: the panel is already inside this commander, so repeating it makes',
|
||||
'every row say the same word twice.',
|
||||
'',
|
||||
'**When it is armed, say so in two sentences.** What is done, and what it now does. Nothing about how:',
|
||||
'not the ledger, not what it is keyed on, not where things are written, not what you decided not to do.',
|
||||
'Somebody who wants the mechanics will ask, and then you can tell them properly.',
|
||||
'',
|
||||
'Then ask what next:',
|
||||
'',
|
||||
'**What next?** — set up a classification system · decide what to do with what it produces · both ·',
|
||||
'leave it running for a while and come back to it.',
|
||||
'',
|
||||
'Leaving it running is a complete answer and often the right one. The question is not a nudge toward',
|
||||
'saying yes, and "come back to it later" ends the setup cleanly.',
|
||||
'',
|
||||
'Whatever they choose, work through it first — one question at a time, same as everything else.',
|
||||
'',
|
||||
'**Never end on a report.** Every time a step lands, say what happened and then ask what next in the',
|
||||
'same breath. Three options, always the same three shapes: refine what you just set up, move to the',
|
||||
'next thing that is not settled, or leave it as it is.',
|
||||
'',
|
||||
'**Anything still unsettled stays on the menu.** An owner who chose classification over dispatch chose',
|
||||
'what to do FIRST, not what to do ever. Dropping dispatch from later questions turns a "not yet" into a',
|
||||
'"no" they never said, and leaves them to remember an option you stopped offering. Keep offering it',
|
||||
'until it is settled or they say to stop.',
|
||||
'',
|
||||
'Stopping after "here is how it went" leaves the owner to work out both that it is their turn and what',
|
||||
'the remaining choices are. They have neither the map nor the reason to hold it — you do.',
|
||||
'',
|
||||
'**Once there are categories, ask what happens to each of them.** Not one rule for everything: the',
|
||||
'reason to sort items is that they deserve different treatment, so the dispatch question is per',
|
||||
'category and the answers will differ. Some categories will have nothing at all, and that is an answer.',
|
||||
'',
|
||||
"Write each one into `DISPATCHING.md` as you agree it, in the owner's words as much as your own. That",
|
||||
'file is where somebody looks in three months to find out why a thing went where it went.',
|
||||
'',
|
||||
'**Say what the choices ARE.** Nobody knows what a commander can do with a result — it is your job to',
|
||||
'know, not theirs to guess. What is available:',
|
||||
'',
|
||||
'- **Nothing.** It stays in `data/`, filed, and the owner reads it when they want to. A complete answer.',
|
||||
'- **Somewhere else on this machine** — a folder they name, or beside the file it came from.',
|
||||
'- **Tell them** — a notification, on the item or on a batch.',
|
||||
'- **Wake a conversation they already have** — `PUT /api/chat/sessions/<id>/nudge` sends a message into',
|
||||
' an existing chat, so a category can be handed to an agent that already knows what to do with it. It',
|
||||
' arrives within the minute rather than instantly.',
|
||||
'',
|
||||
' **ASK for the id. Never offer a list of live sessions.** Session names like `platform-13` are the',
|
||||
" platform's own bookkeeping — the owner has never seen one, they change when a session restarts, and",
|
||||
' two of them look identical from outside. Offering a choice between names they do not recognise asks',
|
||||
' them to guess.',
|
||||
'',
|
||||
' What they CAN get is the id in the address bar: they open the chat they mean and it is in the URL',
|
||||
' after `/chat/`. Ask for that, in those words, and take it as free text.',
|
||||
'- **Start an agent on it** — the same way you spawn one to classify.',
|
||||
'- **Run another platform task on it** — whatever `GET /api/tasks` offers.',
|
||||
'- **Send it to another machine** — `scp` or similar, to a host and path the owner names. They arrange',
|
||||
' the access; you say plainly when it is unreachable.',
|
||||
'',
|
||||
'Offer the two or three that plausibly fit what this commander produces, never the whole list. A menu',
|
||||
'of seven is the same as no menu.',
|
||||
'',
|
||||
'**Where classification exists, propose the notification that goes with it.** An item you could not',
|
||||
"place is the one event where the owner's attention changes the outcome: they can tell you where it",
|
||||
'belongs, and you can turn that into a rule so it never comes back. Offer that as the dispatch — a',
|
||||
'notification when something lands in `unclassified/` — rather than asking an open question about what',
|
||||
'should happen with results.',
|
||||
'',
|
||||
'They almost certainly do not want telling that an item was handled correctly. They started this; they',
|
||||
'assume it works. Being told what worked trains somebody to stop reading, and then the one message that',
|
||||
'mattered arrives in a queue they no longer look at.',
|
||||
'',
|
||||
'That reasoning is not about classification. Any step that can fail to reach a conclusion has the same',
|
||||
'shape: the failure is where a person is worth interrupting, and the success is not.',
|
||||
'',
|
||||
'**Where you could draft it yourself, offer that as the first option.** Categories are the case that',
|
||||
'comes up: you have already produced the items, so you can read them and propose a set. "Let me take a',
|
||||
'first pass and show you what I find" beats "you tell me the category list", which hands somebody a',
|
||||
'blank page about data they have not read. Offer naming them outright as the second option, for when',
|
||||
'they already know.',
|
||||
'',
|
||||
'This is general. Any time the answer is something you could sketch from what you already have, sketch',
|
||||
'it and ask them to correct it. Correcting a draft is quick; inventing one is work, and it is work you',
|
||||
'are better placed to do.',
|
||||
'',
|
||||
'**Then write down the RULE you applied, not what came out of it.** "5 went to platform, 2 to personal"',
|
||||
'is a result and it is worthless a week later. What belongs in `CLASSIFICATION.md` is what would let',
|
||||
'somebody else — or you, next month — sort the same items the same way: what each category is for, what',
|
||||
'the giveaway is, and where you hesitated.',
|
||||
'',
|
||||
"Write the borderline calls down especially. Those are the rule's real edges, they are where it will be",
|
||||
'wrong first, and they are the only part the owner can usefully argue with. A scheme with no recorded',
|
||||
'doubt looks decided, and then nobody revisits it.',
|
||||
'',
|
||||
"Same for `DISPATCHING.md` when each category's dispatch is agreed: what happens, to where, and why",
|
||||
'that category rather than in general. Write it as you agree it, not afterwards from memory.',
|
||||
'',
|
||||
'Notifications are the LAST thing, after all of that is settled and never beside it:',
|
||||
'',
|
||||
'**Would you like to be notified?** — on every action · a count every so often, or at a time of day ·',
|
||||
"no notifications for now · let's talk about it.",
|
||||
'',
|
||||
'Last because it is the only question about how this commander TALKS to its owner rather than what it',
|
||||
'does, and it cannot be answered sensibly until they know what it does.',
|
||||
'',
|
||||
'**Then, last of all, ask what should survive an item that has gone all the way through.** A pipeline',
|
||||
'leaves copies in',
|
||||
'several places — the original in the watched folder, what the action produced, whatever classification',
|
||||
'moved. Nobody decided that; it is just what happens, and it accumulates quietly.',
|
||||
'',
|
||||
'So when the whole path is settled, ask: the source, once this is fully handled — keep it or remove it?',
|
||||
'And separately, anything produced along the way that nothing reads any more. Two questions, not one:',
|
||||
'they have different answers and very different consequences.',
|
||||
'',
|
||||
'**Default to keeping, and never infer permission for either.** Everything you make can be made again',
|
||||
'from the source. The source cannot be made again from anything. Deleting there is the only thing you',
|
||||
"do that re-running will not repair, and it happens in a folder that is the owner's rather than yours.",
|
||||
'',
|
||||
'Ask it plainly, once the rest works and they can see what it produces. Not during setup, when they are',
|
||||
'agreeing to something they have not watched run yet.',
|
||||
'',
|
||||
'AskUserQuestion takes at most FOUR options. Where "let\'s talk about it" belongs, it is one of the',
|
||||
'four — not a fifth.',
|
||||
'',
|
||||
'**Never arm a watcher before the owner has agreed what it does.** Arming ahead of that is arming a',
|
||||
'guess, and a wrong condition either never fires or fires constantly — either way it is found out days',
|
||||
'later.',
|
||||
'',
|
||||
'**Prove the pipeline on ONE item, then arm. Do not process the backlog first.** Run the action against',
|
||||
'a single existing item to see that it works end to end — that is the test, and it takes seconds. Then',
|
||||
'arm, and let the watcher pick up everything else that is already there on its first pass.',
|
||||
'',
|
||||
'Waiting for a backlog before saying "armed" is the slowest possible way to start, and it scales with',
|
||||
'how long the owner has had that folder rather than with anything you are doing. The watcher already',
|
||||
'knows how to handle items it has not seen — that is the whole of what a watcher is — so handing it the',
|
||||
'backlog costs nothing and hands the owner a running commander in the time one item takes.',
|
||||
'',
|
||||
'**Then keep talking. Do not wait for the backlog to finish.** You proved the pipeline on one item, so',
|
||||
'the rest working is expected rather than news — say it is armed and move straight to the next',
|
||||
'question. No sleeping, no polling a log, no "I will confirm once it is done". The owner does not need a',
|
||||
'progress report on work they already know succeeds.',
|
||||
'',
|
||||
'If something DOES go wrong in the backlog, that is worth saying — in the conversation if it is still',
|
||||
'open, or as a notification if it is not. A failure is news. A success is not.',
|
||||
'',
|
||||
'Once the job is settled, write it into `JOB.md` — what you watch for, what you do about it, and which',
|
||||
'numbers were guesses. Not into this file. The next session reads `JOB.md` for the answer and this one',
|
||||
'for the method.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a commander's directory and write its `CLAUDE.md`. Called once, by `POST /command-hq/commanders`,
|
||||
* before any turn spawns — that ordering is the entire fix `docs/commander-directory.md` describes.
|
||||
*
|
||||
* `state/` is created empty rather than left to be created lazily on first write (as `getCommanderStateDir`
|
||||
* callers already do defensively): creating it here means the directory listing a commander's first turn
|
||||
* sees is complete from the start, definition and state both present, rather than state/ appearing later
|
||||
* as an unexplained addition.
|
||||
*/
|
||||
/**
|
||||
* Give the new directory to the account that will work in it.
|
||||
*
|
||||
* `confineUserTree` chowns `commanders/` to the member ONCE, at provisioning. Anything created after that
|
||||
* belongs to the service user — so a member's commander directory would be one their own `claude` cannot
|
||||
* write into, and the failure surfaces as an agent that dies on its first command in a directory it can
|
||||
* see but not use. Exactly the `general_chat_sessions` mistake `resolveChatCwd` already tells the story
|
||||
* about, one level deeper.
|
||||
*
|
||||
* A no-op for the owner, whose account has no `osUser` and who already owns everything under `DATA_PATH`
|
||||
* — which is why this was invisible: it works perfectly for the person most likely to test it.
|
||||
*
|
||||
* Best effort. A commander whose directory could not be handed over still exists and still works for the
|
||||
* owner; refusing to create it would be the worse trade. The failure is returned so the caller can say so.
|
||||
*/
|
||||
async function giveToAccount(email: string, dir: string): Promise<string | null> {
|
||||
const user = await getUserByEmail(email).catch(() => null);
|
||||
if (!user?.osUser) return null;
|
||||
|
||||
const ids = await lookupOsUser(user.osUser);
|
||||
if (!ids) return `no Linux account named ${user.osUser}`;
|
||||
|
||||
const proc = Bun.spawn(['sudo', '-n', 'chown', '-R', `${ids.uid}:${ids.gid}`, dir], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const err = await new Response(proc.stderr).text();
|
||||
return (await proc.exited) === 0 ? null : err.trim() || 'chown failed';
|
||||
}
|
||||
|
||||
export async function createCommanderDirectory(
|
||||
email: string,
|
||||
dirName: string,
|
||||
params: { name: string; referencePath: string; userId: number },
|
||||
): Promise<{ dir: string; ownershipError: string | null; credentialError: string | null }> {
|
||||
const dir = getCommanderDir(email, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirSync(getCommanderStateDir(email, dirName), { recursive: true });
|
||||
|
||||
// Before CLAUDE.md, because CLAUDE.md tells the commander where its credential is and has to say
|
||||
// truthfully whether there is one. Best effort for the same reason the chown is: a commander with no
|
||||
// credential is degraded, and refusing to create it would be the worse trade — but it must be TOLD,
|
||||
// or it will find its own way to the machine, which is how this started.
|
||||
let credentialPath: string | null = null;
|
||||
let credentialError: string | null = null;
|
||||
try {
|
||||
({ path: credentialPath } = await writeCommanderCredential({ userId: params.userId, email, dirName }));
|
||||
} catch (err) {
|
||||
credentialError = err instanceof Error ? err.message : 'could not mint a credential';
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(dir, 'CLAUDE.md'),
|
||||
commanderClaudeMd({
|
||||
name: params.name,
|
||||
referencePath: params.referencePath,
|
||||
loopPidPath: loopPidPath(email, dirName),
|
||||
spawnedPath: spawnedRecordPath(email, dirName),
|
||||
credentialPath,
|
||||
}),
|
||||
);
|
||||
|
||||
// Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself.
|
||||
const ownershipError = await giveToAccount(email, dir);
|
||||
return { dir, ownershipError, credentialError };
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import type { Context } from 'hono';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren, sessionKeyForTranscriptId } from 'officerdb';
|
||||
import { toShellUsername, getCommanderDir } from '@@/data-path';
|
||||
import {
|
||||
forgetChatSessions,
|
||||
getChatEventsSince,
|
||||
ingestSpawnedChildren,
|
||||
revokeApiKeysByName,
|
||||
sessionKeyForTranscriptId,
|
||||
} from 'officerdb';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||
import {
|
||||
clearSpawnedRecord,
|
||||
listSpawnedSessions,
|
||||
loopPidPath,
|
||||
spawnedRecordPath,
|
||||
stopCommanderProcesses,
|
||||
liveLoopPid,
|
||||
} from './spawned';
|
||||
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
|
||||
import { createCommanderDirectory } from './directory';
|
||||
import { commanderKeyName } from './credential';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
loadClaudeSessionById,
|
||||
loadClaudeSessionSummaryById,
|
||||
renameClaudeSession,
|
||||
@@ -27,13 +28,14 @@ import {
|
||||
type ChatIdentity,
|
||||
type ClaudeSessionSummary,
|
||||
} from '../chat/claude-sessions';
|
||||
import { collectRunningTasks, startClaudeTurn, getUserDefaultModel, DEFAULT_MODEL } from '../chat/websocket';
|
||||
import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
||||
import {
|
||||
listCommandHqCategories,
|
||||
createCommandHqCategory,
|
||||
renameCommandHqCategory,
|
||||
deleteCommandHqCategory,
|
||||
createCommandHqSession,
|
||||
createCommander,
|
||||
setCommanderSessionId,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
@@ -67,17 +69,26 @@ import {
|
||||
*
|
||||
* ── Where commanders live ──
|
||||
*
|
||||
* **Wherever the owner chose.** Each commander names its own working directory when it is created, and that
|
||||
* directory is where its relative paths resolve and where its transcripts are grouped.
|
||||
* **Its own directory, created by this router before any turn spawns.** `POST /commanders` creates the row
|
||||
* AND `$DATA_PATH/<email>/commanders/<dirName>/` together, writes `CLAUDE.md`, and only then does the
|
||||
* caller start a turn with `cwd` set to that directory — see `docs/commander-directory.md` for why the
|
||||
* ordering is the entire fix.
|
||||
*
|
||||
* It was one shared directory per user until 2026-08-22, because the directory WAS the identity — the only
|
||||
* way to tell a commander from an ordinary chat. Identity is the `command_hq_sessions` row now, so the
|
||||
* directory became free to be useful, and a commander that works on the platform can live in the platform.
|
||||
* Two earlier designs are worth knowing so this one is not mistaken for a third try at the same idea. One
|
||||
* shared directory per user, until 2026-08-22, because the directory WAS the identity — the only way to
|
||||
* tell a commander from an ordinary chat, which is also what made this router's own listing impossible to
|
||||
* do any other way than a directory scan. Then, briefly, a commander chose an arbitrary directory anywhere
|
||||
* in its owner's tree, once identity moved to the `command_hq_sessions` row — free to be useful, but free
|
||||
* of a directory a commander could depend on existing, or write anything durable into before its first
|
||||
* turn even started. This router owning creation is what fixes both: identity is still the row, and the
|
||||
* directory is still guaranteed to exist and be this commander's own before anything runs in it.
|
||||
*
|
||||
* The consequence for this router is that listing is no longer a directory scan. It reads its rows and loads
|
||||
* each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk. A row
|
||||
* whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in the old scan
|
||||
* was, and `POST /purge` is what tidies those.
|
||||
* The consequence for this router is that listing is not a directory scan either way. It reads its rows and
|
||||
* loads each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk.
|
||||
* A row whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in a scan
|
||||
* would have been, and `POST /purge` is what tidies those. A row with no transcript YET (freshly created,
|
||||
* before its first turn) is treated the same way — absent from the grid until it has one — which is fine:
|
||||
* the composer that just created it already has everything it needs to keep going without a grid entry.
|
||||
*/
|
||||
|
||||
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
@@ -213,29 +224,29 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory.
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
||||
directory holds `CLAUDE.md` and `state/`, never a transcript, so there is nothing to scan there any more
|
||||
either. One `locateTranscript` per row instead. Commanders are few — this is a handful of stats, not a
|
||||
directory walk per row — and a row whose transcript cannot be found anywhere is treated as absent,
|
||||
which is what `purgeCommandHqSessions` exists to tidy.
|
||||
|
||||
It used to be `listClaudeSessions(who, getCommandersDir(...))` — every commander lived in one place, so
|
||||
one scan found them all and a row without a match there was a row whose transcript had been deleted. A
|
||||
commander chooses its own working directory now, so that scan finds only the ones that happen to still
|
||||
live in the old shared folder and would silently hide every other.
|
||||
|
||||
One `locateTranscript` per row instead of one scan for all of them. Commanders are few — this is a
|
||||
handful of stats, not a directory walk per row — and a row whose transcript cannot be found anywhere is
|
||||
still treated exactly as before: absent, which is what `purgeCommandHqSessions` exists to tidy.
|
||||
A row with `sessionId: null` — created but never yet turned — is absent from this grid for the same
|
||||
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
||||
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const summaries = rows
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId))
|
||||
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||
const summaries = withTranscript
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
||||
.filter((s): s is NonNullable<typeof s> => !!s);
|
||||
|
||||
const liveById = new Map(summaries.map((s) => [s.id, s]));
|
||||
const filed = rows.filter((row) => liveById.has(row.sessionId));
|
||||
const filed = withTranscript.filter((row) => liveById.has(row.sessionId!));
|
||||
|
||||
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
|
||||
listCommandHqSessionTags(filed.map((row) => row.id)),
|
||||
getLastFiredAtBySessionRowIds(filed.map((row) => row.id)),
|
||||
Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)).then(
|
||||
Promise.all(filed.map(async (row) => [row.sessionId!, await isRunning(row.sessionId!)] as const)).then(
|
||||
(pairs) => new Map(pairs),
|
||||
),
|
||||
// One aggregate query per commander, the same cost class `isRunning` above already accepts for this
|
||||
@@ -248,18 +259,27 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is filed, while the
|
||||
// summary record cannot exist until the first turn ends — so until then the transcript still reports
|
||||
// the opening brief, and the row is the only source that is right from the start.
|
||||
...liveById.get(row.sessionId!)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is CREATED, before any
|
||||
// turn runs, while the transcript's own summary record cannot exist until the first turn ends — so
|
||||
// until then the transcript still reports the opening brief, and the row is the only source that is
|
||||
// right from the start.
|
||||
...(row.name ? { title: row.name } : {}),
|
||||
// Its directory name, which is also its id. Shown beside the title so the owner can tell which
|
||||
// `commanders/<n>/` on disk a card is, without opening it and reading a path out of a tool call.
|
||||
dirName: row.dirName,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
running: runningById.get(row.sessionId!) ?? false,
|
||||
// The pid file is the FACT; `watcher_armed` is a claim only one path sets. A commander that started
|
||||
// its loop as a plain background job recorded its pid correctly and still reported unarmed, so the
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible.
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||
// beside the transcript.
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
|
||||
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -271,26 +291,31 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders/new { prompt: string, displayText?: string, categoryId?: number | null }
|
||||
* POST /command-hq/commanders/new { prompt: string, name: string, referencePath: string, displayText?:
|
||||
* string, categoryId?: number | null }
|
||||
*
|
||||
* Creates a commander for a client that cannot open a websocket with the right parameters — mobile,
|
||||
* notably. The web composer never calls this: it sends an ordinary `/chat` turn with `context:
|
||||
* 'command-hq'`, and the row is written the moment the harness reports a transcript id (see
|
||||
* `websocket.ts` → `fileAsCommander`). This route drives the SAME path — `startClaudeTurn`, the function
|
||||
* that logic was extracted into — with no live socket, and waits for that same id before answering.
|
||||
* notably. The web composer never calls this: it starts a chat turn directly, in the directory
|
||||
* `POST /commanders` (below) already created for it. This route drives the identical two-step order for a
|
||||
* caller with no live socket — create the row and its directory FIRST, via the same `createCommander` +
|
||||
* `createCommanderDirectory` this router's bare `POST /commanders` uses, THEN start the turn with `cwd` set
|
||||
* to that directory — rather than a second copy that could drift from it. See
|
||||
* `docs/commander-directory.md` for why the ordering is the fix and not an implementation detail.
|
||||
*
|
||||
* **This changed shape 2026-08-24.** `name` and `referencePath` are now required, and `cwd` is no longer
|
||||
* accepted as the actual working directory — a commander runs in its OWN directory now, never one a caller
|
||||
* names. Neither client had been exercised against a running server yet when this changed (`docs/command-
|
||||
* hq.md`), so there is no live caller to keep compatible with the old shape.
|
||||
*
|
||||
* `prompt` is required, deliberately. A commander begins as an ordinary conversation — sometimes a real
|
||||
* back-and-forth before a background job is ever armed, sometimes just "you know what to do" once that
|
||||
* has already happened elsewhere — but it always begins with something, the same as any `/chat`
|
||||
* conversation does. There is no "empty commander" to create and later fill in.
|
||||
*
|
||||
* `displayText`, separately, is what TITLES the commander — found missing here by the mobile side after
|
||||
* this route first shipped without it. A commander created to become a background job sends the full
|
||||
* `buildBackgroundJobPrompt` mechanics as `prompt` (a page of preamble) with the owner's actual sentence
|
||||
* only at the end, and `command_hq_sessions` has no name column — the title comes from
|
||||
* `session.meta.title = (displayText ?? prompt).slice(0, 100)`, same precedence the websocket path
|
||||
* already uses. Without this field every commander created this way titles itself with the mechanics
|
||||
* text, and the grid becomes a column of identical, unreadable cards.
|
||||
* `displayText`, separately, is what the TRANSCRIPT shows for the opening message — the row's own `name`
|
||||
* already titles the commander everywhere Command HQ reads it, but the harness still names its own summary
|
||||
* record from the first prompt, and `prompt` here is often the full `buildBackgroundJobPrompt` mechanics
|
||||
* rather than something a person would want to read back.
|
||||
*
|
||||
* The response's `sessionId` is Claude's own transcript id — not a placeholder, not Officer's — so the
|
||||
* client has something it can immediately resume, attach to, or list among its commanders. Getting it
|
||||
@@ -301,13 +326,31 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
*/
|
||||
commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }>()
|
||||
.json<{
|
||||
prompt?: string;
|
||||
displayText?: string;
|
||||
categoryId?: number | null;
|
||||
name?: string;
|
||||
referencePath?: string;
|
||||
}>()
|
||||
.catch(
|
||||
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
|
||||
() =>
|
||||
({}) as {
|
||||
prompt?: string;
|
||||
displayText?: string;
|
||||
categoryId?: number | null;
|
||||
name?: string;
|
||||
referencePath?: string;
|
||||
},
|
||||
);
|
||||
const prompt = body.prompt?.trim();
|
||||
const name = body.name?.trim();
|
||||
const referencePath = body.referencePath?.trim();
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
// ── Room to run, before starting work nobody is watching ──
|
||||
//
|
||||
@@ -320,8 +363,24 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const budget = await checkBudget();
|
||||
if (!budget.ok) throw errors.TOO_MANY_REQUESTS(budget.reason ?? 'not enough usage headroom to start an agent');
|
||||
|
||||
// Row and directory first — see `POST /commanders` below for the same two calls with the full reasoning.
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
// AWAITED. It was not, and that is the ordering this whole redesign exists to get right: Claude Code
|
||||
// reads CLAUDE.md from the working directory at session start, so a turn spawned before the file is
|
||||
// written is a commander that never learns what it is. The un-awaited call also made the `cwd` returned
|
||||
// below a Promise, which serialised as `{}`.
|
||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||
name,
|
||||
referencePath,
|
||||
userId: user.id,
|
||||
});
|
||||
if (ownershipError)
|
||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL;
|
||||
// Not the account default: a commander runs on Sonnet — see COMMANDER_DEFAULT_MODEL.
|
||||
const model = COMMANDER_DEFAULT_MODEL;
|
||||
|
||||
const outcome = await new Promise<{ ok: true; transcriptId: string } | { ok: false; error: string }>((resolve) => {
|
||||
// 30s covers a slow spawn (member login checks, a cold sidecar) without holding an HTTP connection
|
||||
@@ -345,12 +404,10 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
prompt,
|
||||
displayText: body.displayText?.trim() || undefined,
|
||||
context: 'command-hq',
|
||||
// Where this commander runs, and its name. Both are chosen before the first message — the UI makes
|
||||
// them mandatory — so this programmatic path takes them too rather than inventing a default. An
|
||||
// absent cwd falls back to the account's home in `resolveChatCwd`, which is the honest last resort
|
||||
// for a caller that predates the requirement.
|
||||
...(body.cwd?.trim() ? { cwd: body.cwd.trim() } : {}),
|
||||
...(body.name?.trim() ? { commanderName: body.name.trim() } : {}),
|
||||
// No `cwd` here: `resolveChatCwd` resolves the commander's directory itself from `commanderId`
|
||||
// (`docs/commander-directory.md`), the same as the web composer's turn does. `dir`, computed above,
|
||||
// is used only for the JSON response below.
|
||||
commanderId: row.id,
|
||||
},
|
||||
effectivePrompt: prompt,
|
||||
onOutcome: settle,
|
||||
@@ -360,20 +417,15 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
if (!outcome.ok) throw errors.INTERNAL_SERVER_ERROR(outcome.error);
|
||||
|
||||
/*
|
||||
The row already exists — `fileAsCommander` wrote it, off the same `session:claude` event this promise
|
||||
resolved on. What might not have landed yet is the CATEGORY: that write is fire-and-forget and always
|
||||
files with none, so a race between it and this request is possible in either order. `createCommandHqSession`
|
||||
is an upsert-on-conflict-do-nothing (safe either way), and the `setCommandHqSessionCategory` UPDATE below
|
||||
is unconditional and runs after both, which is what makes the outcome deterministic regardless of which
|
||||
of the two insert attempts actually won: whichever category this request asked for is the one that is
|
||||
left standing.
|
||||
Belt and braces: `fileAsCommander` (`websocket.ts`) already points this row at the transcript id off the
|
||||
same `session:claude` event this promise resolved on, but that write is fire-and-forget so it is not
|
||||
guaranteed to have landed by the time this handler resumes. `setCommanderSessionId` is a plain,
|
||||
idempotent UPDATE — calling it again here costs nothing and guarantees the row is correct before this
|
||||
response goes out, rather than trusting a race this handler has no way to observe.
|
||||
*/
|
||||
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommanderSessionId(user.id, row.id, outcome.transcriptId);
|
||||
|
||||
// The cwd it ACTUALLY ran in, from the spawn outcome — not a guess at the old shared directory, which is
|
||||
// no longer where a commander necessarily lives.
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: body.cwd?.trim() ?? null });
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: dir, id: row.id, dirName: row.dirName });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -402,10 +454,16 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
// The commander's OWN directory name, so a client can open a file browser on it. Its directory sits
|
||||
// under `user-data` (`$DATA_PATH/<email>/commanders/<dirName>`), which the `home` root the file
|
||||
// browser normally serves cannot reach at all — see `file-browser/router.ts` → `getRootDir`.
|
||||
dirName: row.dirName,
|
||||
referencePaths: row.referencePaths,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -446,36 +504,52 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
|
||||
* POST /command-hq/commanders { name: string, referencePath: string, categoryId?: number | null }
|
||||
*
|
||||
* Files an EXISTING session as a commander. Idempotent — filing an already-filed session is a no-op, and
|
||||
* does not move it to a different category (use PATCH for that).
|
||||
* Creates a commander. **This is the creation path now, and it changed shape 2026-08-24** — it used to file
|
||||
* an EXISTING session (`{ sessionId, categoryId? }`), which is the wrong order per
|
||||
* `docs/commander-directory.md`: a chat session existed first and was filed as a commander second, so the
|
||||
* working directory was fixed before any commander id existed and a commander could never start in a
|
||||
* directory named after itself. There is no live caller of the old shape to keep compatible with — see the
|
||||
* design doc's "Order of work".
|
||||
*
|
||||
* This is a low-level primitive, not the normal creation path. The normal path is starting a new commander
|
||||
* through Command HQ's own composer, which writes this row itself the moment the harness reports a real
|
||||
* transcript id — see `docs/command-hq.md` → "How a commander is created". This route exists for that
|
||||
* wiring to call, and for anything else (a purge-and-refile flow, a script) that needs to file a session
|
||||
* without going through a live chat turn.
|
||||
*
|
||||
* The session must actually exist in the commanders directory, or this refuses — filing a `/chat` session
|
||||
* makes no sense given commanders and `/chat` never share a working directory.
|
||||
* Creates, in order: the row (`createCommander`, no transcript — `sessionId` stays null until a turn
|
||||
* reports one), THEN the directory and its `CLAUDE.md` (`createCommanderDirectory`) — the row has to exist
|
||||
* first because `dirName` is derived from its own id. Returns the row, including `cwd`: the caller (the
|
||||
* web composer, `CommandHqDetail.tsx`) starts its chat turn with `cwd` set to exactly this path, and
|
||||
* threads `commanderId: row.id` on that turn so `fileAsCommander` (`websocket.ts`) can point this same row
|
||||
* at the transcript id the moment one exists — the same server-driven, don't-depend-on-a-live-browser
|
||||
* pattern this router already used for the commander's name, now doing the same job for its `sessionId`.
|
||||
* No separate `PATCH … { sessionId }` route exists for this: it would only reintroduce the thing this
|
||||
* design removes — a client that has to remember to follow through, on a value the server already knows
|
||||
* first.
|
||||
*/
|
||||
commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ sessionId?: string; categoryId?: number }>()
|
||||
.catch(() => ({}) as { sessionId?: string; categoryId?: number });
|
||||
const sessionId = body.sessionId?.trim();
|
||||
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
|
||||
.json<{ name?: string; referencePath?: string; categoryId?: number | null }>()
|
||||
.catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null });
|
||||
const name = body.name?.trim();
|
||||
const referencePath = body.referencePath?.trim();
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
// Located by id wherever it lives, not required to be in the old shared directory. A commander chooses
|
||||
// its own working directory, so "is it in the commanders folder" stopped being a meaningful check — and
|
||||
// as a GUARD it only ever established that the transcript exists, which this still does.
|
||||
if (!loadClaudeSessionSummaryById(who, sessionId)) throw errors.NOT_FOUND('no such conversation');
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||
name,
|
||||
referencePath,
|
||||
userId: user.id,
|
||||
});
|
||||
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
|
||||
if (ownershipError) {
|
||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||
}
|
||||
|
||||
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
|
||||
return ctx.json(row);
|
||||
// `ownershipError` is returned as well as logged. A member whose commander directory could not be handed
|
||||
// over gets an agent that dies on its first command, and "the platform knew and only wrote it to a log"
|
||||
// is the worst version of that.
|
||||
return ctx.json({ ...row, cwd: dir, ...(ownershipError ? { ownershipError } : {}) });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -592,13 +666,13 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
is exactly how a deleted commander came back as an untitled ordinary chat.
|
||||
*/
|
||||
const sessionKilled = sessionKey ? await sidecar.killClaudeAndWait(sessionKey, user.id) : false;
|
||||
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||
const stopped = stopCommanderProcesses(who.email, row.dirName);
|
||||
|
||||
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
||||
// not stop the rest from going, and a child is often gone before its parent.
|
||||
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
|
||||
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, row.dirName))).map(
|
||||
(c) => c.spawnedSessionId,
|
||||
);
|
||||
let childrenDeleted = 0;
|
||||
@@ -623,7 +697,28 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||
}
|
||||
}
|
||||
clearSpawnedRecord(who.email, sessionId);
|
||||
/*
|
||||
The commander's OWN directory — `CLAUDE.md`, `state/`, and anything else it wrote for itself. Not just
|
||||
`state/` (which `clearSpawnedRecord` alone would remove): per `docs/commander-directory.md`, deleting a
|
||||
commander is deleting its directory, whole, which is also the only way to be sure nothing is left
|
||||
behind. Best-effort, same posture as the children above — a directory already gone is not a failure.
|
||||
*/
|
||||
try {
|
||||
rmSync(getCommanderDir(who.email, row.dirName), { recursive: true, force: true });
|
||||
} catch {
|
||||
// Already gone, or never fully created. The row is still deleted below regardless.
|
||||
}
|
||||
|
||||
/*
|
||||
And its credential, which the directory removal does NOT cover — the secret file goes with the
|
||||
directory, but the row that makes it work lives in Postgres. A commander whose files are gone and whose
|
||||
key still authenticates is the worst of both: nothing on disk to find it by, and a live credential.
|
||||
|
||||
Revoked rather than deleted, so what it was and when it was last used survive the commander.
|
||||
*/
|
||||
await revokeApiKeysByName(user.id, commanderKeyName(row.dirName)).catch((err) => {
|
||||
console.warn(`[command-hq] ${row.dirName}: credential not revoked — ${err}`);
|
||||
});
|
||||
|
||||
// 3. The commander itself.
|
||||
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
|
||||
@@ -675,10 +770,16 @@ commandHqRouter.post('/purge', async (ctx) => {
|
||||
|
||||
Scanning one directory would have deleted every commander that chose a different one — a housekeeping
|
||||
route quietly unfiling live commanders is the worst possible version of this bug, because the rows are
|
||||
what identity depends on now.
|
||||
what identity depends on now. A row with no transcript yet (`sessionId: null`, mid-creation) is never a
|
||||
purge candidate at all — see `purgeCommandHqSessions`'s own guard — so it needs no entry in this set.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id);
|
||||
const stillThere = new Set(rows.map((row) => row.sessionId).filter((id) => !!loadClaudeSessionSummaryById(who, id)));
|
||||
const stillThere = new Set(
|
||||
rows
|
||||
.map((row) => row.sessionId)
|
||||
.filter((id): id is string => id !== null)
|
||||
.filter((id) => !!loadClaudeSessionSummaryById(who, id)),
|
||||
);
|
||||
const removed = await purgeCommandHqSessions(user.id, stillThere);
|
||||
return ctx.json({ removed });
|
||||
});
|
||||
@@ -716,7 +817,7 @@ commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, row.dirName));
|
||||
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
|
||||
// must not display a confident total that happens to be too low. That understatement is the whole reason
|
||||
// this exists.
|
||||
@@ -731,23 +832,25 @@ commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/record-paths
|
||||
*
|
||||
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||
* Where this commander's loop must write its pid and the session ids it spawns — the same two paths
|
||||
* `CLAUDE.md` already states at creation (`docs/commander-directory.md`). This route still exists for
|
||||
* `BackgroundJobDialog`, which needs them again at ARM time, well after creation, to build the watch loop's
|
||||
* own prompt (`background-job-prompt.ts`) — a live turn's `CLAUDE.md` is not something this route re-reads.
|
||||
*
|
||||
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
||||
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
||||
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
||||
* job, where an authenticated POST would need a credential the loop has no business holding.
|
||||
*
|
||||
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
||||
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
||||
* Rooted in `state/` inside this commander's own directory, keyed by `dirName` — which means it now needs
|
||||
* the ROW (to learn `dirName`), unlike before, when the path was a pure function of the transcript id and
|
||||
* nothing else. A commander always has a row by the time any turn exists, so this is not a new restriction
|
||||
* in practice — it only makes explicit what used to be implicit.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
return ctx.json({
|
||||
loopPidPath: loopPidPath(who.email, sessionId),
|
||||
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||
loopPidPath: loopPidPath(who.email, row.dirName),
|
||||
spawnedPath: spawnedRecordPath(who.email, row.dirName),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseLivePids } from './spawned';
|
||||
|
||||
/*
|
||||
A commander records its loop's pid so the platform can stop it. This used to be `Number(text.trim())`,
|
||||
which is NaN the moment the file has two lines — and the first real commander wrote its pid with `>>`,
|
||||
so after one re-arm it had two.
|
||||
|
||||
NaN read as "no loop": Stop reported zero processes signalled and left both loops running. Silent, and
|
||||
exactly backwards — the more times a commander was re-armed, the less stoppable it became.
|
||||
*/
|
||||
|
||||
const alive = (live: number[]) => (pid: number) => live.includes(pid);
|
||||
|
||||
describe('reading a commander loop pid file', () => {
|
||||
test('one pid, the ordinary case', () => {
|
||||
expect(parseLivePids('4242\n', alive([4242]))).toEqual([4242]);
|
||||
});
|
||||
|
||||
// The bug, pinned. Two lines used to produce NaN and therefore nothing.
|
||||
test('an appended file yields every live pid, not NaN', () => {
|
||||
expect(parseLivePids('3675353\n3675487\n', alive([3675353, 3675487]))).toEqual([3675353, 3675487]);
|
||||
});
|
||||
|
||||
test('a pid whose process has gone is dropped', () => {
|
||||
expect(parseLivePids('111\n222\n', alive([222]))).toEqual([222]);
|
||||
});
|
||||
|
||||
// 0 and 1 are never a loop, and a negative would be a process GROUP — signalling one of those from a
|
||||
// typo'd file would reach far more than this commander.
|
||||
test('junk, blanks and unsignalable ids are ignored', () => {
|
||||
expect(parseLivePids('\n\nnot-a-pid\n0\n1\n-9\n77\n', alive([77, 0, 1, -9]))).toEqual([77]);
|
||||
});
|
||||
|
||||
test('an empty file is no loop rather than an error', () => {
|
||||
expect(parseLivePids('', alive([]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,12 @@ import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
So the loop records it, and the platform reads it. One newline-delimited file per commander:
|
||||
|
||||
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
|
||||
<DATA_PATH>/<email>/commanders/<dirName>/state/spawned
|
||||
|
||||
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
|
||||
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
|
||||
every link is required.
|
||||
path is written into `CLAUDE.md` at creation (`docs/commander-directory.md`) and also carried into the
|
||||
background-job prompt by `background-job-prompt.ts` for the dialog-driven case. That is the whole chain,
|
||||
and every link is required.
|
||||
*/
|
||||
|
||||
const FILE = 'spawned';
|
||||
@@ -36,8 +37,8 @@ const PID_FILE = 'loop-pid';
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
|
||||
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), FILE);
|
||||
export function spawnedRecordPath(email: string, dirName: string): string {
|
||||
return join(getCommanderStateDir(email, dirName), FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +48,8 @@ export function spawnedRecordPath(email: string, commanderSessionId: string): st
|
||||
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
|
||||
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
|
||||
*/
|
||||
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
|
||||
const path = spawnedRecordPath(email, commanderSessionId);
|
||||
export function listSpawnedSessions(email: string, dirName: string): string[] {
|
||||
const path = spawnedRecordPath(email, dirName);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const seen = new Set<string>();
|
||||
@@ -68,18 +69,26 @@ export function listSpawnedSessions(email: string, commanderSessionId: string):
|
||||
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
|
||||
* that arrives it must write the same file rather than a second record.
|
||||
*/
|
||||
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
|
||||
export function recordSpawnedSession(email: string, dirName: string, spawnedId: string): void {
|
||||
if (!SESSION_ID.test(spawnedId)) return;
|
||||
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||
if (listSpawnedSessions(email, dirName).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
|
||||
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
|
||||
/**
|
||||
* Drop `state/` alone, leaving `CLAUDE.md` and anything else the commander wrote in place.
|
||||
*
|
||||
* Not called from the delete route any more — deleting a commander now removes its WHOLE directory
|
||||
* (`docs/commander-directory.md`: `state/` is one part of it, not the only thing that needs to go), which
|
||||
* subsumes this. Kept as the narrower primitive for the shape of operation it names: clearing what a
|
||||
* commander has HANDLED without deleting the commander itself, which nothing calls today but is exactly
|
||||
* the kind of reset a commander asked to "start over" might need.
|
||||
*/
|
||||
export function clearSpawnedRecord(email: string, dirName: string): void {
|
||||
try {
|
||||
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
|
||||
rmSync(getCommanderStateDir(email, dirName), { recursive: true, force: true });
|
||||
} catch {
|
||||
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
|
||||
// without a commander to read it for.
|
||||
@@ -103,35 +112,72 @@ export function clearSpawnedRecord(email: string, commanderSessionId: string): v
|
||||
*/
|
||||
|
||||
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
|
||||
export function loopPidPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
|
||||
export function loopPidPath(email: string, dirName: string): string {
|
||||
return join(getCommanderStateDir(email, dirName), PID_FILE);
|
||||
}
|
||||
|
||||
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
|
||||
const path = loopPidPath(email, commanderSessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
/**
|
||||
* Every pid recorded in the file whose process is still alive, in the order they were written.
|
||||
*
|
||||
* PLURAL, because the file can legitimately hold more than one. A commander re-arming its loop appends,
|
||||
* and this is written by an agent improvising a shell script — being strict about its formatting buys
|
||||
* nothing except a commander nobody can stop.
|
||||
*
|
||||
* That is not hypothetical. This used to be `Number(text.trim())`, which is NaN the moment there are two
|
||||
* lines, and NaN meant "no loop": the Stop button reported zero processes signalled and left both loops
|
||||
* running. Silent, and exactly backwards — the more times a commander was re-armed, the less stoppable it
|
||||
* became.
|
||||
*/
|
||||
export function liveLoopPids(email: string, dirName: string): number[] {
|
||||
const path = loopPidPath(email, dirName);
|
||||
if (!existsSync(path)) return [];
|
||||
return parseLivePids(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsing and liveness rule, split out so it can be tested — the caller resolves a real path under
|
||||
* DATA_PATH, and a test of that would be a test of this machine.
|
||||
*
|
||||
* `isAlive` defaults to signal 0, which tests for existence without touching the process. A recorded pid
|
||||
* whose process has gone is not an error — loops end — and reusing a dead pid's number would kill
|
||||
* something unrelated.
|
||||
*/
|
||||
export function parseLivePids(text: string, isAlive: (pid: number) => boolean = defaultIsAlive): number[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n > 1)
|
||||
.filter(isAlive);
|
||||
}
|
||||
|
||||
function defaultIsAlive(pid: number): boolean {
|
||||
try {
|
||||
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
|
||||
// error — loops end — and reusing a dead pid's number would kill something unrelated.
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is this commander's loop running? The newest live pid, or null. */
|
||||
export function liveLoopPid(email: string, dirName: string): number | null {
|
||||
const pids = liveLoopPids(email, dirName);
|
||||
return pids.length ? pids[pids.length - 1]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the loop and everything under it. Returns how many processes were signalled.
|
||||
*
|
||||
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||
*/
|
||||
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
|
||||
const pid = liveLoopPid(email, commanderSessionId);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
export function stopCommanderProcesses(email: string, dirName: string): number {
|
||||
// EVERY live loop, not the newest. A commander that re-armed without stopping the previous loop has two
|
||||
// running, and stopping one of them leaves the folder still being watched by the other — which reads as
|
||||
// "Stop did nothing", because from outside it is indistinguishable.
|
||||
const pids = liveLoopPids(email, dirName);
|
||||
if (!pids.length) return 0;
|
||||
const tree = [...new Set(pids.flatMap((pid) => [...descendantPids(pid), pid]))];
|
||||
let signalled = 0;
|
||||
for (const target of tree) {
|
||||
try {
|
||||
@@ -141,5 +187,26 @@ export function stopCommanderProcesses(email: string, commanderSessionId: string
|
||||
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
|
||||
// The record goes with the processes, and this is load-bearing rather than tidiness.
|
||||
//
|
||||
// `watcher-revive.ts` treats "a pid file whose pids are all dead" as a watch that died and needs the
|
||||
// commander woken. A Stop that killed the loop and left the file behind is indistinguishable from a
|
||||
// crash, so the reviver would hand the turn back and the commander would dutifully re-arm the thing
|
||||
// the owner just stopped. Removing the file is what makes deliberate and accidental different.
|
||||
clearLoopPid(email, dirName);
|
||||
return signalled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the recorded pid. "No file" means no watch, on purpose — which is exactly what a fresh
|
||||
* commander looks like, and what a stopped one should look like too.
|
||||
*/
|
||||
export function clearLoopPid(email: string, dirName: string): void {
|
||||
try {
|
||||
rmSync(loopPidPath(email, dirName), { force: true });
|
||||
} catch {
|
||||
// A file we cannot remove is not worth failing a Stop over. The reviver's own guard covers it: it
|
||||
// wakes a commander once and clears the file itself, so the worst case is one wasted turn.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { listAllCommanders, getUserById } from 'officerdb';
|
||||
import { getCommanderStateDir } from '@@/data-path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { clearLoopPid, liveLoopPids } from './spawned';
|
||||
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||
|
||||
// Waking a commander whose watch has died.
|
||||
//
|
||||
// ── The failure this exists for ──
|
||||
//
|
||||
// A watch is a background process the commander started, and nothing on this machine supervises it. So a
|
||||
// reboot ends every one of them at once, and nothing says so: the card simply stops saying "watching",
|
||||
// which looks identical to a commander that was never armed.
|
||||
//
|
||||
// That is not theoretical. On 2026-08-24 this machine rebooted at 12:40 and took the deploy commander's
|
||||
// loop with it. A release was tagged the next day and sat on the remote with nobody looking at it, and
|
||||
// the only evidence was a word missing from a card. `todo/agent-coordination/` carries the full account.
|
||||
//
|
||||
// ── Why the platform wakes the agent instead of restarting the process ──
|
||||
//
|
||||
// The obvious fix is for the platform to respawn the loop itself, and it is WRONG — this was settled on
|
||||
// 2026-08-18 and is written down in `docs/command-hq.md` under "The watcher loop". The agent is only ever
|
||||
// notified about a process it started and is itself tracking. A process the platform spawned sits outside
|
||||
// that entirely, so it would watch correctly, fire, and reach nobody.
|
||||
//
|
||||
// A respawn would therefore trade a visible failure for an invisible one, which is the worse of the two:
|
||||
// today the commander is plainly not watching, and that version would be watching and unable to tell
|
||||
// anyone. So the platform's whole job here is to NOTICE and hand the turn back. The agent re-arms itself,
|
||||
// exactly as it does after every ordinary fire, and the process it starts is tracked.
|
||||
//
|
||||
// The owner's two conditions, 2026-08-25, both kept by construction: the commander is still the only
|
||||
// thing that starts a watch, and the indicator still reads the pid file rather than any stored claim.
|
||||
//
|
||||
// ── Why the pid file, and not `watcherArmed` ──
|
||||
//
|
||||
// `watcherArmed` is a claim only one path sets, and most commanders never take that path — they arm by
|
||||
// backgrounding a process, which records a pid and nothing else. Both commanders on this machine read
|
||||
// `watcher_armed = false` while one of them was demonstrably watching. `router.ts` already resolves this
|
||||
// the same way for its cards: **the pid file is the fact.**
|
||||
//
|
||||
// So the signal is: a pid file EXISTS and nothing in it is alive. That means this commander had a watch
|
||||
// and no longer does. A commander that never armed has no file, and a commander the owner deliberately
|
||||
// stopped no longer has one either — `stopCommanderProcesses` removes it, which is what makes stopped and
|
||||
// crashed distinguishable at all.
|
||||
|
||||
const TICK_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The first tick is delayed rather than immediate.
|
||||
*
|
||||
* A reboot is the case this is for, and at boot the platform comes up before much else — waking a
|
||||
* commander into a machine still starting its own services would ask it to re-arm a watch against
|
||||
* half-present dependencies. Two minutes is not a considered number; it is enough for the rest of PM2.
|
||||
*/
|
||||
const FIRST_TICK_MS = 120_000;
|
||||
|
||||
const PID_FILE = 'loop-pid';
|
||||
const ATTEMPTS_FILE = 'revive-attempts';
|
||||
|
||||
/**
|
||||
* How many times this will wake one commander, and over what window.
|
||||
*
|
||||
* **This bound is the backstop, not the correctness.** The guard that matters is `isGenerating` below;
|
||||
* this one exists for the case that guard cannot see — a commander that genuinely cannot re-arm, waking,
|
||||
* failing, and being woken again every two minutes, each wake costing a real agent turn, all night.
|
||||
*
|
||||
* That is the exact failure `prompts/deploy-commander.md` records for the first deploy watcher: a careful
|
||||
* loop with an unbounded retry inside it. A commander that cannot re-arm itself is not a thing to keep
|
||||
* asking; it is a thing to tell a person about once.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const ATTEMPT_WINDOW_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/** Wake timestamps inside the window. Anything older is dropped rather than kept — this is a rate, not a history. */
|
||||
function recentAttempts(email: string, dirName: string): number[] {
|
||||
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
|
||||
if (!existsSync(path)) return [];
|
||||
const cutoff = Date.now() - ATTEMPT_WINDOW_MS;
|
||||
try {
|
||||
return readFileSync(path, 'utf-8')
|
||||
.split('\n')
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n > cutoff);
|
||||
} catch {
|
||||
// Unreadable counts as "we have tried" rather than "we have not". Failing towards fewer wakes is the
|
||||
// safe direction: the cost of one missed revival is a watch that stays off and is reported; the cost
|
||||
// of miscounting the other way is a turn every two minutes.
|
||||
return new Array(MAX_ATTEMPTS).fill(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
function recordAttempt(email: string, dirName: string): void {
|
||||
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
|
||||
try {
|
||||
writeFileSync(path, [...recentAttempts(email, dirName), Date.now()].join('\n'));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[watcher-revive] could not record an attempt for ${dirName}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A commander that re-armed successfully starts from zero, so a crash months later is not refused. */
|
||||
function clearAttempts(email: string, dirName: string): void {
|
||||
try {
|
||||
writeFileSync(join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE), '');
|
||||
} catch {
|
||||
// Not worth failing over — the window expires on its own.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A watch that was running and is not.
|
||||
*
|
||||
* **A dead pid is NOT the same as a dead watch, and this is the trap.** A watcher is built to EXIT when it
|
||||
* fires — that is the whole mechanism, because a background process reaches the model only by ending. So
|
||||
* the normal, healthy path goes: poll, find something, exit, hand back to the agent, agent acts, agent
|
||||
* re-arms. For the whole of that middle stretch the pid file names a process that is gone.
|
||||
*
|
||||
* The exit-on-fire model is `docs/command-hq.md` → "The watcher loop", settled 2026-08-18, and it is the
|
||||
* reason this guard exists rather than any incident. There WAS a near miss on 2026-08-25 — commander 40
|
||||
* was woken, rebuilt its watch, and two seconds after it recorded a pid that pid read as dead — but the
|
||||
* process turned out to be alive and mid-claim, so the honest account is that a pid file is a poor instant
|
||||
* answer to "is this watching", not that a fire was observed being misread. Either way the window is real:
|
||||
* between a watcher exiting and its agent re-arming, the file names a process that is gone.
|
||||
*
|
||||
* Hence the generating check: if a TURN is running, the exit is being handled and the re-arm is the last
|
||||
* thing that turn will do. Waking then is not just wasteful, it is wrong — it would arrive mid-work
|
||||
* carrying an instruction the agent has no reason to obey.
|
||||
*
|
||||
* ── "generating" is not "has an outstanding background task" ──
|
||||
*
|
||||
* This asked `collectRunningTasks` until 2026-08-26, and that was wrong in a way that disabled the whole
|
||||
* feature. Those are BACKGROUND TASKS, and **a commander's watcher is itself a background task** — one that
|
||||
* stays outstanding until it reports a terminal state, which a killed process never does. So every
|
||||
* commander with an armed watcher had one outstanding by definition, the check returned "busy" forever, and
|
||||
* the reviver could never wake the one thing it exists for.
|
||||
*
|
||||
* Found on the deploy commander: its loop was dead, its pid file said so, and the task
|
||||
* "Platform Deployer watcher — polls origin for deploy/* tags" was still marked running from the revival
|
||||
* six hours earlier. The guard was permanently true.
|
||||
*
|
||||
* The right question is whether the AGENT is mid-turn, which the sidecar answers directly.
|
||||
*/
|
||||
async function watchDied(email: string, dirName: string, sessionId: string, userId: number): Promise<boolean> {
|
||||
if (!existsSync(join(getCommanderStateDir(email, dirName), PID_FILE))) return false;
|
||||
if (liveLoopPids(email, dirName).length > 0) return false;
|
||||
|
||||
try {
|
||||
const live = await sidecar.listLiveClaudeSessions(userId);
|
||||
const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId);
|
||||
// Absent means idle or collected, and both are fine — `fire` resumes by transcript id.
|
||||
if (mine?.isGenerating) return false;
|
||||
} catch {
|
||||
// Cannot tell whether it is working, so assume it is. A missed revival is reported next tick; a wake
|
||||
// delivered into a live turn is not recoverable by waiting.
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the commander is told. Deliberately short, and it points at the file rather than describing the
|
||||
* watch — the platform does not know what was being watched and must not guess.
|
||||
*
|
||||
* `WATCHERS.md` is the commander's own record, required by its `CLAUDE.md`: what each watch is, the exact
|
||||
* command, how often, and what happens when it fires. This message is the moment that file earns its keep.
|
||||
*
|
||||
* It also has to work for a commander that predates the file, which every commander on this machine does
|
||||
* today — so reconstructing from `JOB.md`, a script in its own directory, or the turn that armed it is
|
||||
* explicitly allowed. That is reading its own records, not guessing. Inventing a command it cannot find
|
||||
* is the thing forbidden, because a wrong watch looks exactly like a working one.
|
||||
*/
|
||||
const WAKE_PROMPT = [
|
||||
'Your watch is not running. The platform found a recorded pid with no live process behind it —',
|
||||
'usually the machine rebooted, or something killed the tree it was started under.',
|
||||
'',
|
||||
'Read `WATCHERS.md` and start it again, exactly as it is written there: same command, same interval,',
|
||||
'same behaviour on firing. Then record the new pid as your `CLAUDE.md` describes, and update the entry',
|
||||
'if anything about it changed.',
|
||||
'',
|
||||
'If `WATCHERS.md` does not exist, you may rebuild the watch from your own records — `JOB.md`, a script',
|
||||
'in your directory, the turn in this conversation that armed it. That is reading, not guessing, and',
|
||||
'write `WATCHERS.md` as you go so this is the last time it has to be reconstructed.',
|
||||
'',
|
||||
'But if none of those say what the command actually was, do NOT invent one. Say what is missing and',
|
||||
'stop. A wrong watch is worse than none, because it looks like one that works.',
|
||||
].join('\n');
|
||||
|
||||
async function wake(userId: number, sessionId: string, dirName: string, email: string): Promise<void> {
|
||||
// Cleared BEFORE the turn, not after. The file is the trigger, so leaving it in place while a turn runs
|
||||
// means the next tick sees the same dead pid and wakes the commander a second time. Clearing first also
|
||||
// gives the right outcome when the wake fails: one attempt, one notification, and silence — rather than
|
||||
// a commander woken every two minutes for as long as the machine is up.
|
||||
clearLoopPid(email, dirName);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const done = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(done, 60_000);
|
||||
|
||||
void startClaudeTurn({
|
||||
email,
|
||||
username: email,
|
||||
userId,
|
||||
// No socket, the same way the nudger drives a turn. Whoever has the commander open sees it arrive
|
||||
// through their own connection.
|
||||
ws: null,
|
||||
sessionId,
|
||||
model: COMMANDER_DEFAULT_MODEL,
|
||||
msg: { prompt: WAKE_PROMPT, resumeSessionId: sessionId },
|
||||
effectivePrompt: WAKE_PROMPT,
|
||||
onOutcome: (outcome) => {
|
||||
clearTimeout(timer);
|
||||
if (!outcome.ok) console.error(`[watcher-revive] ${dirName}: ${outcome.error}`);
|
||||
done();
|
||||
},
|
||||
}).catch((err) => {
|
||||
clearTimeout(timer);
|
||||
console.error(`[watcher-revive] ${dirName} threw:`, err instanceof Error ? err.message : err);
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the owner, separately from waking the commander.
|
||||
*
|
||||
* These are two different audiences and only one of them can act when the wake does not work. A
|
||||
* commander that cannot find its command says so into a transcript nobody is reading at 4am; the
|
||||
* notification is what makes "your watch has been off since the reboot" reach a person.
|
||||
*
|
||||
* Best-effort in both directions: the wake happens whether or not this lands, and a failure here is
|
||||
* logged rather than raised. Null headers mean the notify sidecar has not registered yet, and sending
|
||||
* unauthenticated is not the fallback — see `notifyProducerHeaders`.
|
||||
*/
|
||||
async function tellTheOwner(userId: number, dirName: string): Promise<void> {
|
||||
const base = getNotifyServerUrl();
|
||||
const producer = notifyProducerHeaders();
|
||||
if (!base || !producer) return;
|
||||
|
||||
try {
|
||||
await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...producer },
|
||||
body: JSON.stringify({
|
||||
code: 'officer/core:message',
|
||||
userId,
|
||||
title: 'A commander stopped watching',
|
||||
// Names the commander by its directory and says nothing about what it watches. The subject of a
|
||||
// watch is the owner's business and this text goes through Apple and Google to a lock screen.
|
||||
body: `Commander ${dirName}'s watch was not running. It has been asked to start it again.`,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[watcher-revive] could not notify about ${dirName}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start the sweep. Called once from `server.tsx`.
|
||||
*
|
||||
* `running` guards a slow tick against the next one, and `unref` keeps it from holding the process open.
|
||||
* Both copied from the nudger, which has the same shape and the same failure to avoid.
|
||||
*/
|
||||
export function startWatcherRevive(): void {
|
||||
if (timer) return;
|
||||
let running = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
for (const commander of await listAllCommanders()) {
|
||||
const user = await getUserById(commander.userId);
|
||||
if (!user) continue;
|
||||
if (!(await watchDied(user.email, commander.dirName, commander.sessionId, commander.userId))) {
|
||||
// Healthy, stopped, or mid-turn. A live loop means the last wake worked, so the count resets.
|
||||
if (liveLoopPids(user.email, commander.dirName).length > 0) clearAttempts(user.email, commander.dirName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attempts = recentAttempts(user.email, commander.dirName);
|
||||
if (attempts.length >= MAX_ATTEMPTS) {
|
||||
console.warn(
|
||||
`[watcher-revive] commander ${commander.dirName}: ${attempts.length} failed wakes — leaving it alone`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.warn(`[watcher-revive] commander ${commander.dirName}: watch is dead — waking it`);
|
||||
recordAttempt(user.email, commander.dirName);
|
||||
await tellTheOwner(commander.userId, commander.dirName);
|
||||
await wake(commander.userId, commander.sessionId, commander.dirName, user.email);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[watcher-revive] tick failed:', err instanceof Error ? err.message : err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => void tick(), FIRST_TICK_MS).unref?.();
|
||||
timer = setInterval(() => void tick(), TICK_MS);
|
||||
timer.unref?.();
|
||||
console.log('[watcher-revive] armed — checking every 120s for watches that have died');
|
||||
}
|
||||
@@ -4,11 +4,10 @@ import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises
|
||||
import { existsSync } from 'node:fs';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
import { transcribeAudio } from '@@/api/stt/transcribe';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
@@ -255,7 +254,7 @@ router.post('/write', async (ctx) => {
|
||||
|
||||
const absPath = resolveUserPath(rootDir, path);
|
||||
await mkdir(dirname(absPath), { recursive: true });
|
||||
await Bun.write(absPath, content);
|
||||
await writeAtomic(absPath, content);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -284,12 +283,31 @@ router.post('/upload', async (ctx) => {
|
||||
const raw = body['file'];
|
||||
const files = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
|
||||
/*
|
||||
WRITE ASIDE, THEN RENAME. Never straight to the final path.
|
||||
|
||||
`Bun.write(filePath, file)` streams to the name the file will end up with, so for as long as the
|
||||
upload takes, that name exists holding a partial file — and anything watching the folder cannot
|
||||
tell. The failure is silent rather than loud: a truncated m4a decodes fine, it is simply short. A
|
||||
watcher transcribing voice memos as they land would produce a clean transcript of the first few
|
||||
seconds of a two-minute recording and report success.
|
||||
|
||||
A rename within one directory is atomic, so afterwards the final name either does not exist or is
|
||||
a complete file. Every consumer gets that for free — the phone's own `uploadVerified`, a watcher,
|
||||
an agent reading the folder — instead of each inventing a heuristic like "the size stopped
|
||||
changing", which cannot tell a finished upload from a stalled one.
|
||||
|
||||
`.part` rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was
|
||||
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
|
||||
create is a worse trade than a fragment somebody can see.
|
||||
*/
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
const filePath = resolve(targetDir, file.name);
|
||||
if (!isInside(rootDir, filePath)) continue;
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, file);
|
||||
|
||||
await writeAtomic(filePath, file);
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
@@ -789,7 +807,7 @@ router.post('/tts', async (ctx) => {
|
||||
const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
|
||||
const siblingAbs = resolveUserPath(rootDir, siblingRel);
|
||||
const cached = await readFile(cacheAbs);
|
||||
await Bun.write(siblingAbs, cached);
|
||||
await writeAtomic(siblingAbs, cached);
|
||||
return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true });
|
||||
}
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
@@ -819,12 +837,12 @@ router.post('/tts', async (ctx) => {
|
||||
|
||||
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||
const buffer = await res.arrayBuffer();
|
||||
await Bun.write(cacheAbs, buffer);
|
||||
await writeAtomic(cacheAbs, buffer);
|
||||
|
||||
if (saveNextTo) {
|
||||
const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
|
||||
const siblingAbs = resolveUserPath(rootDir, siblingRel);
|
||||
await Bun.write(siblingAbs, buffer);
|
||||
await writeAtomic(siblingAbs, buffer);
|
||||
return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: false });
|
||||
}
|
||||
|
||||
@@ -874,7 +892,7 @@ router.post('/tts-text', async (ctx) => {
|
||||
|
||||
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||
const buffer = await res.arrayBuffer();
|
||||
await Bun.write(cacheAbs, buffer);
|
||||
await writeAtomic(cacheAbs, buffer);
|
||||
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
});
|
||||
@@ -946,7 +964,7 @@ router.post('/ocr', async (ctx) => {
|
||||
if (saveNextTo) {
|
||||
const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`;
|
||||
const siblingAbs = resolveUserPath(rootDir, siblingRel);
|
||||
await Bun.write(siblingAbs, text);
|
||||
await writeAtomic(siblingAbs, text);
|
||||
return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: false });
|
||||
}
|
||||
|
||||
@@ -1076,46 +1094,6 @@ router.post('/extract', async (ctx) => {
|
||||
return ctx.json({ extractedPath });
|
||||
});
|
||||
|
||||
// Transcribe audio via Whisper with caching
|
||||
// Workflow: detect language → check user's spoken languages → translate if needed → transcribe
|
||||
router.post('/transcribe', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean };
|
||||
if (!filePath) throw errors.BAD_REQUEST('path is required');
|
||||
|
||||
const rootDir = getRootDir(user, root);
|
||||
const absPath = resolveUserPath(rootDir, filePath);
|
||||
const s = await stat(absPath);
|
||||
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory');
|
||||
|
||||
// No caching: a transcription is always produced fresh and overwrites any previous one.
|
||||
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||
|
||||
const sttConfig = await readSttConfig();
|
||||
const whisperUrl = sttConfig?.url;
|
||||
if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured — set it up in Settings → Speech to Text');
|
||||
|
||||
const settings = await getUserSettings(user.id);
|
||||
const spokenLanguages = (settings.languages as { spoken?: string[] })?.spoken ?? [];
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const result = await transcribeAudio({ file: Bun.file(absPath), whisperUrl, spokenLanguages });
|
||||
text = result.text;
|
||||
} catch (err) {
|
||||
throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Transcription failed');
|
||||
}
|
||||
|
||||
if (saveNextTo) {
|
||||
const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`;
|
||||
const siblingAbs = resolveUserPath(rootDir, siblingRel);
|
||||
await Bun.write(siblingAbs, text);
|
||||
return ctx.json({ transcriptionPath: `/${siblingRel}`, transcriptionRoot: root ?? 'home', cached: false });
|
||||
}
|
||||
|
||||
return ctx.json({ text, cached: false });
|
||||
});
|
||||
|
||||
// Search files by name
|
||||
router.get('/search', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter } from '../create-router';
|
||||
import { readdir, mkdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { itemsDir, type ItemType } from '../data-path';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
// Shared router for the flat, file-based item types (skills, tools, processes). Each item is a
|
||||
// directory under $OFFICER_ITEMS_DIR/<type>/<dirName>/ holding a single markdown file (SKILL.md /
|
||||
@@ -114,8 +115,8 @@ export function createItemRouter({ type, fileName, label }: ItemRouterConfig) {
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
await mkdir(chatDir, { recursive: true });
|
||||
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
|
||||
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
|
||||
await writeAtomic(join(chatDir, 'messages.json'), JSON.stringify(messages));
|
||||
if (sessionId) await writeAtomic(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -140,7 +141,7 @@ export function createItemRouter({ type, fileName, label }: ItemRouterConfig) {
|
||||
if (await Bun.file(filePath).exists()) return ctx.text(`${label} already exists`, 409);
|
||||
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
await writeAtomic(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
import { getUsers } from 'officerdb';
|
||||
import * as errors from '../../custom-errors';
|
||||
|
||||
// /api/notify/* — auth, then forward to officer-notify. The apps register their push tokens through here
|
||||
// and nothing else uses it; producers inside the tailnet POST to the sidecar over loopback directly.
|
||||
@@ -7,7 +9,68 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' });
|
||||
|
||||
/*
|
||||
POST /api/notify/broadcast — one message to every account, owner only.
|
||||
|
||||
It exists because the alternative was what actually happened on 2026-08-24: to reach four accounts at
|
||||
once, somebody found the sidecar's ephemeral port and POSTed to it directly, once per user, with no
|
||||
authentication at all. That worked, which was the problem — the same door was open to anything else on
|
||||
the machine, including an agent.
|
||||
|
||||
Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only.
|
||||
*/
|
||||
proxy.router.post('/broadcast', async (ctx) => {
|
||||
type BroadcastBody = { title?: string; body?: string; code?: string };
|
||||
const body: BroadcastBody = await ctx.req.json<BroadcastBody>().catch(() => ({}) as BroadcastBody);
|
||||
const title = body.title?.trim();
|
||||
const message = body.body?.trim();
|
||||
if (!title || !message) throw errors.BAD_REQUEST('title and body are required');
|
||||
|
||||
const base = proxy.getHttpUrl();
|
||||
const producer = notifyProducerHeaders();
|
||||
if (!base || !producer) throw errors.createError(503, 'notify sidecar not available');
|
||||
|
||||
// Sequential, and deliberately so: this is a handful of accounts on a personal server, and one slow
|
||||
// APNs call must not turn into a hundred concurrent ones against the same host.
|
||||
const results: { userId: number; ok: boolean }[] = [];
|
||||
for (const user of await getUsers()) {
|
||||
try {
|
||||
const res = await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...producer },
|
||||
// `userId` in the BODY, with no `X-Officer-User`: this is the platform speaking about someone
|
||||
// else, which is exactly the distinction `sidecar/notify/resolve-user.ts` draws.
|
||||
body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId: user.id, title, body: message }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
results.push({ userId: user.id, ok: res.ok });
|
||||
} catch {
|
||||
// One account's failure must not stop the rest — a broadcast that gives up halfway is worse than
|
||||
// one that reports which half landed.
|
||||
results.push({ userId: user.id, ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results });
|
||||
});
|
||||
|
||||
export const notifyRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getNotifyServerUrl = proxy.getHttpUrl;
|
||||
|
||||
/**
|
||||
* The header an in-process producer must send when it calls the sidecar directly.
|
||||
*
|
||||
* Producers inside the platform — sendoff, chat — bypass this proxy and POST over loopback, because they
|
||||
* are notifying somebody other than the caller and the proxy pins the target to whoever is signed in.
|
||||
* That path used to accept anyone who could reach the port, which on a machine running commanders is
|
||||
* anyone with a shell.
|
||||
*
|
||||
* Null before the sidecar registers, in which case the producer is right to give up rather than send
|
||||
* unauthenticated.
|
||||
*/
|
||||
export function notifyProducerHeaders(): Record<string, string> | null {
|
||||
const secret = proxy.getProducerSecret();
|
||||
return secret ? { 'X-Officer-Producer': secret } : null;
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ pluginsRouter.get('/', async (ctx) => {
|
||||
version: plugin.manifest.version,
|
||||
platform: plugin.manifest.platform,
|
||||
permissions: plugin.manifest.permissions,
|
||||
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
|
||||
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
|
||||
settings: plugin.manifest.settings ?? [],
|
||||
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
|
||||
has: {
|
||||
api: !!plugin.api,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { listRoles, createRole, renameRole, deleteRole } from 'officerdb';
|
||||
import { listRoles, createRole, renameRole, deleteRole, setRoleGrants } from 'officerdb';
|
||||
import { seedGrantsForNewRole } from '@@/plugins/grant-on-install';
|
||||
|
||||
/*
|
||||
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md).
|
||||
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md), plus grants (phase 3).
|
||||
|
||||
This is the roles TABLE — a name, a description, the `isSystem` flag. It is not the GRANTS a role
|
||||
holds; that stays `role_permissions`, administered at `/api/users/permissions` by
|
||||
`permissionAdminRouter`. The two are separate screens for a reason: this router answers "which roles
|
||||
exist", that one answers "what may each reach" — and deleting a role here has to know the second
|
||||
question's table too, because the guard below is "does anyone hold it", not "what does it grant".
|
||||
This is the roles TABLE — a name, a description, the `isSystem` flag, and since phase 3 the two
|
||||
provisioning flags (`grantsPostgres`, `grantsDocker`). It is not the PERMISSION grants a role holds;
|
||||
those stay `role_permissions`, administered at `/api/users/permissions` by `permissionAdminRouter`. The
|
||||
two are separate screens for a reason: this router answers "which roles exist, and what does holding one
|
||||
provision on the machine", that one answers "what may each reach in the app" — and deleting a role here
|
||||
has to know the second question's table too, because the guard below is "does anyone hold it", not "what
|
||||
does it grant".
|
||||
|
||||
── Owner only, explicitly ──
|
||||
|
||||
@@ -37,19 +40,51 @@ rolesRouter.use(async (ctx, next) => {
|
||||
/** GET /api/roles — every role, with how many accounts hold each. */
|
||||
rolesRouter.get('/', async (ctx) => ctx.json({ roles: await listRoles() }));
|
||||
|
||||
/** POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`. */
|
||||
/**
|
||||
* POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` default to false when omitted, matching the column defaults — creating
|
||||
* a role must not hand out a Postgres login and a Docker daemon because a form field was left out of the
|
||||
* request body.
|
||||
*/
|
||||
rolesRouter.post('/', async (ctx) => {
|
||||
const body = ctx.get('body') as { name?: unknown; description?: unknown };
|
||||
const body = ctx.get('body') as {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
grantsPostgres?: unknown;
|
||||
grantsDocker?: unknown;
|
||||
};
|
||||
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
|
||||
const description = typeof body.description === 'string' ? body.description : null;
|
||||
if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsPostgres must be a boolean');
|
||||
}
|
||||
if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsDocker must be a boolean');
|
||||
}
|
||||
const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined;
|
||||
const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined;
|
||||
|
||||
const result = await createRole({ name: body.name, description });
|
||||
const result = await createRole({ name: body.name, description, grantsPostgres, grantsDocker });
|
||||
if (!result.ok) {
|
||||
throw result.reason === 'invalid-name'
|
||||
? errors.BAD_REQUEST('a role name cannot be empty')
|
||||
: errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
/*
|
||||
A new role starts where Member does: the confined baseline plus every installed plugin.
|
||||
|
||||
Not doing this is how the policy ends up half-applied — granted to roles that existed when a plugin was
|
||||
installed, and not to any created afterwards. It also fixes something older: DEFAULT_ROLE_PERMISSIONS
|
||||
was only ever applied at bootstrap, so a role created here used to begin with nothing at all, not even
|
||||
chat or files, and nothing said why.
|
||||
|
||||
Not fatal. The role exists either way and its grants can be set on the permissions screen; failing the
|
||||
creation over a grant would be the worse trade.
|
||||
*/
|
||||
const seeded = await seedGrantsForNewRole(result.role.name).catch(() => 0);
|
||||
|
||||
return ctx.json({ role: result.role, seededGrants: seeded });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -80,6 +115,41 @@ rolesRouter.patch('/:name', async (ctx) => {
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roles/:name/grants — set `grantsPostgres`/`grantsDocker`. Refuses a system role, for a
|
||||
* different reason than the rename route above: `scripts/seed-roles.ts` re-asserts a system role's grants
|
||||
* on every `postdb:push`, so a write here would look like it worked and then silently revert on the next
|
||||
* deploy. A custom role has no such seed pinning it, so this is where its grants actually get set once it
|
||||
* exists — `createRole` only takes an initial value.
|
||||
*
|
||||
* Either field may be omitted; only the ones present are changed (see `setRoleGrants`).
|
||||
*/
|
||||
rolesRouter.patch('/:name/grants', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const body = ctx.get('body') as { grantsPostgres?: unknown; grantsDocker?: unknown };
|
||||
if (body?.grantsPostgres === undefined && body?.grantsDocker === undefined) {
|
||||
throw errors.BAD_REQUEST('grantsPostgres and/or grantsDocker is required');
|
||||
}
|
||||
if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsPostgres must be a boolean');
|
||||
}
|
||||
if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsDocker must be a boolean');
|
||||
}
|
||||
const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined;
|
||||
const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined;
|
||||
|
||||
const result = await setRoleGrants(name, { grantsPostgres, grantsDocker });
|
||||
if (!result.ok) {
|
||||
throw result.reason === 'not-found'
|
||||
? errors.NOT_FOUND(`no such role '${name}'`)
|
||||
: errors.FORBIDDEN(
|
||||
`'${name}' is a system role — its grants are declared in scripts/seed-roles.ts, not edited here`,
|
||||
);
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/** DELETE /api/roles/:name — refuses a system role, and refuses one any account still holds. */
|
||||
rolesRouter.delete('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
const MAX_CONTENT_LENGTH = 100_000;
|
||||
|
||||
@@ -148,7 +149,7 @@ scrapeRouter.post('/', async (ctx) => {
|
||||
}
|
||||
|
||||
await mkdir(saveDir, { recursive: true });
|
||||
await Bun.write(join(saveDir, attachmentId), html);
|
||||
await writeAtomic(join(saveDir, attachmentId), html);
|
||||
|
||||
return ctx.json({ url, title, content, attachmentId });
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
revokeSender,
|
||||
upsertSendoffDevice,
|
||||
} from 'officerdb';
|
||||
import { getNotifyServerUrl } from '../notify/router';
|
||||
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths';
|
||||
|
||||
/*
|
||||
@@ -78,10 +79,13 @@ function freePath(dir: string, filename: string): string {
|
||||
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
|
||||
try {
|
||||
const base = getNotifyServerUrl();
|
||||
if (!base) return;
|
||||
const producer = notifyProducerHeaders();
|
||||
// No secret means the sidecar has not registered with THIS process yet. Giving up is right: the
|
||||
// alternative is sending unauthenticated, which the sidecar now refuses anyway.
|
||||
if (!base || !producer) return;
|
||||
await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) },
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId), ...producer },
|
||||
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
@@ -149,9 +153,11 @@ sendoffRouter.post('/send', async (ctx) => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const written: { name: string; size: number }[] = [];
|
||||
// `writeAtomic` matters more here than anywhere: this is a folder things ARRIVE in unattended.
|
||||
// Neither a recipient watching it nor the notification below may point at a name still being written.
|
||||
for (const file of files) {
|
||||
const path = freePath(dir, safeFilename(file.name));
|
||||
await Bun.write(path, await file.arrayBuffer());
|
||||
await writeAtomic(path, await file.arrayBuffer());
|
||||
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AGENT_CONFIG_DIR } from '../../data-path';
|
||||
import { invalidateModelCache } from '../chat/list-models';
|
||||
import { logger } from '../chat/logger';
|
||||
import { readConfigValue, writeConfigValue } from 'officerdb';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
export const chatProvidersRouter = createRouter();
|
||||
|
||||
@@ -69,7 +70,7 @@ async function readModelsConfig(): Promise<ModelConfig> {
|
||||
}
|
||||
|
||||
async function writeModelsConfig(config: ModelConfig): Promise<void> {
|
||||
await Bun.write(MODELS_FILE, JSON.stringify(config, null, 2));
|
||||
await writeAtomic(MODELS_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,7 +343,7 @@ async function readAuthJson(): Promise<AuthJson> {
|
||||
}
|
||||
|
||||
async function writeAuthJson(auth: AuthJson): Promise<void> {
|
||||
await Bun.write(AUTH_FILE, JSON.stringify(auth, null, 2));
|
||||
await writeAtomic(AUTH_FILE, JSON.stringify(auth, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,6 @@ import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||
import { chatProvidersRouter } from './chat-providers';
|
||||
import { smtpRouter } from './smtp';
|
||||
import { ttsRouter } from './tts';
|
||||
import { sttRouter } from './stt';
|
||||
import { ocrRouter } from './ocr';
|
||||
|
||||
export const serverSettingsRouter = createRouter();
|
||||
@@ -17,7 +16,6 @@ export const serverSettingsRouter = createRouter();
|
||||
serverSettingsRouter.route('/chat-providers', chatProvidersRouter);
|
||||
serverSettingsRouter.route('/smtp', smtpRouter);
|
||||
serverSettingsRouter.route('/tts', ttsRouter);
|
||||
serverSettingsRouter.route('/stt', sttRouter);
|
||||
serverSettingsRouter.route('/ocr', ocrRouter);
|
||||
|
||||
export { readServerSettings as readSettings };
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||
|
||||
type SttConfig = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function readSttConfig(): Promise<SttConfig | undefined> {
|
||||
const settings = await readServerSettings();
|
||||
return settings.stt as SttConfig | undefined;
|
||||
}
|
||||
|
||||
export const sttRouter = createRouter();
|
||||
|
||||
sttRouter.get('/', async (ctx) => {
|
||||
const stt = await readSttConfig();
|
||||
if (!stt) return ctx.json(null);
|
||||
return ctx.json(stt);
|
||||
});
|
||||
|
||||
sttRouter.put('/', async (ctx) => {
|
||||
const body = await ctx.req.json<SttConfig>();
|
||||
const settings = await readServerSettings();
|
||||
settings.stt = body;
|
||||
await writeServerSettings(settings);
|
||||
return ctx.json({ success: true });
|
||||
});
|
||||
|
||||
sttRouter.post('/test', async (ctx) => {
|
||||
const body = await ctx.req.json<{ url: string }>();
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/inference`, {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
// Whisper will return an error for empty form, but a response means it's reachable
|
||||
return ctx.json({ success: true, status: res.status });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { toParagraphs } from './transcribe';
|
||||
import type { WhisperSegment } from './transcribe';
|
||||
|
||||
// Whisper's own shape: leading space on every segment, times in seconds.
|
||||
const seg = (text: string, start: number, end: number, extra: Partial<WhisperSegment> = {}): WhisperSegment => ({
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('toParagraphs', () => {
|
||||
it('joins segments spoken without a real pause into one paragraph', () => {
|
||||
const out = toParagraphs([seg(' I went to the shop.', 0, 2), seg(' It was closed.', 2.2, 4)]);
|
||||
expect(out).toBe('I went to the shop. It was closed.');
|
||||
});
|
||||
|
||||
it('starts a new paragraph after a long silence', () => {
|
||||
const out = toParagraphs([seg(' First thought.', 0, 2), seg(' Second thought.', 5, 7)]);
|
||||
expect(out).toBe('First thought.\n\nSecond thought.');
|
||||
});
|
||||
|
||||
it('does not break on a breath-length pause', () => {
|
||||
const out = toParagraphs([seg(' Hang on,', 0, 1), seg(' let me think.', 1.5, 3)]);
|
||||
expect(out).toBe('Hang on, let me think.');
|
||||
});
|
||||
|
||||
// The lone "." lines in the old response_format=text output came from segments like this.
|
||||
it('attaches stranded punctuation without a space in front of it', () => {
|
||||
const out = toParagraphs([seg(' See you tomorrow morning', 0, 2), seg(' .', 2.1, 2.2)]);
|
||||
expect(out).toBe('See you tomorrow morning.');
|
||||
});
|
||||
|
||||
it('drops segments that are probably silence decoded with low confidence', () => {
|
||||
const out = toParagraphs([
|
||||
seg(' Real speech here.', 0, 2),
|
||||
seg(' Thanks for watching!', 2.1, 3, { no_speech_prob: 0.95, avg_logprob: -1.8 }),
|
||||
seg(' More real speech.', 3.1, 5),
|
||||
]);
|
||||
expect(out).toBe('Real speech here. More real speech.');
|
||||
});
|
||||
|
||||
it('keeps a confident segment even when it looks like silence', () => {
|
||||
const out = toParagraphs([seg(' Definitely said.', 0, 2, { no_speech_prob: 0.95, avg_logprob: -0.2 })]);
|
||||
expect(out).toBe('Definitely said.');
|
||||
});
|
||||
|
||||
it('keeps segments that report neither probability', () => {
|
||||
const out = toParagraphs([seg(' No metadata at all.', 0, 2)]);
|
||||
expect(out).toBe('No metadata at all.');
|
||||
});
|
||||
|
||||
it('measures the pause from the last kept segment, not the dropped one', () => {
|
||||
const out = toParagraphs([
|
||||
seg(' Before.', 0, 2),
|
||||
seg(' Hallucinated.', 2.1, 9, { no_speech_prob: 0.9, avg_logprob: -2 }),
|
||||
seg(' After.', 9.1, 11),
|
||||
]);
|
||||
// The gap that matters is 2 -> 9.1, so these are separate thoughts.
|
||||
expect(out).toBe('Before.\n\nAfter.');
|
||||
});
|
||||
|
||||
it('collapses internal whitespace and ignores empty segments', () => {
|
||||
const out = toParagraphs([seg(' Too many\n spaces. ', 0, 2), seg(' ', 2.1, 2.2)]);
|
||||
expect(out).toBe('Too many spaces.');
|
||||
});
|
||||
|
||||
it('returns an empty string when every segment is filtered out', () => {
|
||||
expect(toParagraphs([])).toBe('');
|
||||
expect(toParagraphs([seg(' ', 0, 1)])).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to no break when segments carry no timings', () => {
|
||||
const out = toParagraphs([{ text: ' One.' }, { text: ' Two.' }]);
|
||||
expect(out).toBe('One. Two.');
|
||||
});
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
// Whisper transcription, always in the audio's own language.
|
||||
//
|
||||
// Detect the language first, then transcribe in it. Whisper's translate mode is English-only, so it
|
||||
// cannot honour "translate into <language>" for anything else — rather than silently returning a
|
||||
// rough English rendering of Portuguese audio, transcription is always faithful to the source.
|
||||
//
|
||||
// The detection must win when Whisper is confident. `spokenLanguages` only breaks ties on clips
|
||||
// Whisper is genuinely unsure about, where its low-confidence guess defaults to English.
|
||||
//
|
||||
// The transcription pass is tuned for one person talking into a phone, which is what this is mostly
|
||||
// used for. See the constants below: the defaults whisper.cpp ships are aimed at clean dictation and
|
||||
// produce loops, clipped words and breath-length paragraph breaks on real voice memos.
|
||||
//
|
||||
// Both requests pass `timeout: false`. Bun's fetch aborts at 300s by default, and whisper.cpp runs at
|
||||
// roughly 8x realtime, so anything over ~40 minutes of audio died with "The operation timed out"
|
||||
// about 20 seconds short of the answer. AbortSignal.timeout() does not raise that ceiling — only
|
||||
// disabling the built-in timeout does.
|
||||
|
||||
// Bun's fetch accepts `timeout: false` to disable its built-in 300s ceiling. The DOM RequestInit
|
||||
// type this project compiles against does not declare it, so widen it here rather than casting at
|
||||
// each call site.
|
||||
type BunRequestInit = RequestInit & { timeout?: boolean | number };
|
||||
|
||||
type TranscribeArgs = {
|
||||
file: File | Blob;
|
||||
whisperUrl: string;
|
||||
spokenLanguages: string[];
|
||||
};
|
||||
|
||||
export type TranscribeResult = {
|
||||
text: string;
|
||||
detectedLanguage: string;
|
||||
};
|
||||
|
||||
// Above this probability Whisper's own detection is taken as authoritative. Below it the clip is
|
||||
// treated as ambiguous and the user's spoken languages break the tie.
|
||||
const CONFIDENT_DETECTION = 0.5;
|
||||
|
||||
// whisper.cpp's "language" field uses full names ("english"); ISO codes live in
|
||||
// `language_probabilities`. Map covers what the user can pick in Profile settings.
|
||||
const LANG_NAME_TO_ISO: Record<string, string> = {
|
||||
english: 'en',
|
||||
portuguese: 'pt',
|
||||
spanish: 'es',
|
||||
french: 'fr',
|
||||
german: 'de',
|
||||
italian: 'it',
|
||||
dutch: 'nl',
|
||||
russian: 'ru',
|
||||
chinese: 'zh',
|
||||
japanese: 'ja',
|
||||
korean: 'ko',
|
||||
};
|
||||
|
||||
// Whisper reuses its own decoded output as the prompt for the next 30-second window, which is what
|
||||
// makes it fall into loops ("I know. I know. I know.") once it slips. `max_context` is the budget for
|
||||
// that carried text, and setting it to 0 stops the loops — but the same switch also disables the
|
||||
// initial prompt, since whisper.cpp gates both on `n_max_text_ctx > 0`.
|
||||
//
|
||||
// The way to keep the prompt and still starve the loop: `carry_initial_prompt` puts the prompt in a
|
||||
// static slot that is filled first and in full, and only the leftover budget goes to decoded text.
|
||||
// Every prompt below is comfortably longer than this budget, so there is never any leftover — the
|
||||
// vocabulary and style reach every window and none of the model's own output does.
|
||||
const PROMPT_CONTEXT_TOKENS = 96;
|
||||
|
||||
// Whisper imitates whatever it is primed with; it does not take instructions. So each prompt is a
|
||||
// worked example of the output wanted — one person talking, punctuated the way it should come back —
|
||||
// and nothing else. An earlier version opened by *describing* the format ("Commas separate clauses,
|
||||
// and apostrophes are written properly") and that backfired: priming words which never occur in
|
||||
// speech put them in play, and a character surname came back from a Monk episode as "Commas".
|
||||
// Everything here is ordinary conversational language for that reason.
|
||||
//
|
||||
// Each is written in its own language, since an English prompt on Portuguese audio biases the
|
||||
// output, and each deliberately runs past PROMPT_CONTEXT_TOKENS — see above for why the length is
|
||||
// load-bearing. Overflow is trimmed from the front, so nothing depends on the opening words.
|
||||
//
|
||||
// Languages without an entry fall through to no prompt at all, which is handled explicitly below.
|
||||
const TRANSCRIPT_PROMPTS: Record<string, string> = {
|
||||
en:
|
||||
'Hey, sorry for the late reply — I was stuck in a meeting all morning and only just got out. ' +
|
||||
"I had a look at the document you sent over, and honestly, I think it's mostly fine. There are " +
|
||||
"a couple of things I'd change in the second half, but nothing serious. The introduction reads " +
|
||||
'really well. Let me know when you have a few minutes and we can talk it through properly. ' +
|
||||
"I'm around all afternoon, so whenever suits you is fine by me. Thanks again for putting it " +
|
||||
'together, I know it took a while.',
|
||||
pt:
|
||||
'Olá, desculpa a demora a responder — estive numa reunião toda a manhã e só agora consegui ' +
|
||||
'sair. Já vi o documento que enviaste e, sinceramente, acho que está quase tudo bem. Há duas ' +
|
||||
'ou três coisas que mudava na segunda metade, mas nada de grave. A introdução está muito bem ' +
|
||||
'escrita. Diz-me quando tiveres uns minutos e falamos com calma. Estou por aqui toda a tarde, ' +
|
||||
'por isso quando te der jeito está bem para mim. Obrigado outra vez por teres tratado disto, ' +
|
||||
'sei que deu trabalho.',
|
||||
};
|
||||
|
||||
// Beam search rather than the default greedy decode (whisper.cpp ships `beam_size = -1`). It is the
|
||||
// single biggest quality lever here and it costs time, which this flow has — nothing waits on it.
|
||||
const BEAM_SIZE = 5;
|
||||
const BEST_OF = 5;
|
||||
|
||||
// The whisper container already runs silero VAD, but its defaults assume clean dictation. Widen the
|
||||
// segments so they break at real pauses instead of at breaths, and pad the edges so word onsets are
|
||||
// not clipped — clipped onsets are what shows up in the output as a word split down the middle.
|
||||
const VAD_TUNING: Record<string, string> = {
|
||||
vad_speech_pad_ms: '200', // default 30
|
||||
vad_min_silence_duration_ms: '500', // default 100 — a breath is not a sentence boundary
|
||||
vad_samples_overlap: '0.2', // default 0.1
|
||||
vad_max_speech_duration_s: '25', // default unbounded — keep a segment inside one 30s window
|
||||
};
|
||||
|
||||
// A silence this long reads as a new thought. Whisper reports segment times against the original
|
||||
// audio even when VAD is on (whisper.cpp maps them back through its VAD table), so these really are
|
||||
// gaps in the recording rather than artefacts of the filtering.
|
||||
const PARAGRAPH_GAP_SECONDS = 1.2;
|
||||
const MAX_PARAGRAPH_CHARS = 900;
|
||||
|
||||
// OpenAI Whisper's own silence rule: a segment the model thinks is probably not speech *and* decodes
|
||||
// with low confidence is a hallucination rather than a transcript. whisper.cpp applies a subtly
|
||||
// different form of this internally, so it is worth re-applying to the values it reports — this is
|
||||
// what removes the stray words that appear over music and room tone.
|
||||
const NO_SPEECH_PROB = 0.6;
|
||||
const LOW_CONFIDENCE_LOGPROB = -1.0;
|
||||
|
||||
export type WhisperSegment = {
|
||||
text?: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
no_speech_prob?: number;
|
||||
avg_logprob?: number;
|
||||
};
|
||||
|
||||
// Defaults are chosen so a segment missing either field is kept: dropping real speech is much worse
|
||||
// than keeping a stray word.
|
||||
const isHallucinatedSilence = (segment: WhisperSegment) =>
|
||||
(segment.no_speech_prob ?? 0) > NO_SPEECH_PROB && (segment.avg_logprob ?? 0) < LOW_CONFIDENCE_LOGPROB;
|
||||
|
||||
// Whisper emits each segment with a leading space and now and then strands trailing punctuation in a
|
||||
// segment of its own, which is why the old `response_format=text` output contained lone "." lines.
|
||||
const appendSegment = (paragraph: string, text: string) => {
|
||||
if (!paragraph) return text;
|
||||
return /^[.,;:!?…)\]]/.test(text) ? paragraph + text : `${paragraph} ${text}`;
|
||||
};
|
||||
|
||||
// Whisper returns one segment per utterance, not per paragraph. Rejoin them into prose and start a
|
||||
// new paragraph on a long pause, so a voice memo reads as something written rather than as a list of
|
||||
// fragments.
|
||||
export function toParagraphs(segments: WhisperSegment[]): string {
|
||||
const paragraphs: string[] = [];
|
||||
let current = '';
|
||||
let previousEnd: number | null = null;
|
||||
|
||||
for (const segment of segments) {
|
||||
const text = (segment.text ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || isHallucinatedSilence(segment)) continue;
|
||||
|
||||
const gap = previousEnd !== null && segment.start !== undefined ? segment.start - previousEnd : 0;
|
||||
// Only break an over-long paragraph where a sentence actually ended, never mid-thought.
|
||||
const overlong = current.length >= MAX_PARAGRAPH_CHARS && /[.!?…]["')\]]?$/.test(current);
|
||||
|
||||
if (current && (gap >= PARAGRAPH_GAP_SECONDS || overlong)) {
|
||||
paragraphs.push(current);
|
||||
current = '';
|
||||
}
|
||||
|
||||
current = appendSegment(current, text);
|
||||
if (segment.end !== undefined) previousEnd = segment.end;
|
||||
}
|
||||
|
||||
if (current) paragraphs.push(current);
|
||||
return paragraphs.join('\n\n');
|
||||
}
|
||||
|
||||
export async function transcribeAudio({
|
||||
file,
|
||||
whisperUrl,
|
||||
spokenLanguages,
|
||||
}: TranscribeArgs): Promise<TranscribeResult> {
|
||||
const base = whisperUrl.replace(/\/+$/, '');
|
||||
|
||||
// Step 1: detect language, and only fall back to the spoken-language bias when Whisper is unsure.
|
||||
const detectForm = new FormData();
|
||||
detectForm.append('file', file);
|
||||
detectForm.append('temperature', '0.0');
|
||||
detectForm.append('response_format', 'verbose_json');
|
||||
detectForm.append('detect_language', 'true');
|
||||
|
||||
const detectRes = await fetch(`${base}/inference`, {
|
||||
method: 'POST',
|
||||
body: detectForm,
|
||||
timeout: false,
|
||||
} as BunRequestInit);
|
||||
if (!detectRes.ok) throw new Error(`Language detection failed (${detectRes.status})`);
|
||||
const detectJson = (await detectRes.json()) as {
|
||||
language?: string;
|
||||
detected_language?: string;
|
||||
detected_language_probability?: number;
|
||||
language_probabilities?: Record<string, number>;
|
||||
};
|
||||
|
||||
const probs = detectJson.language_probabilities;
|
||||
const ranked = probs ? Object.entries(probs).sort((a, b) => b[1] - a[1]) : [];
|
||||
const [topCode, topProb] = ranked[0] ?? [];
|
||||
|
||||
let detectedLanguage: string;
|
||||
if (topCode && (topProb ?? 0) >= CONFIDENT_DETECTION) {
|
||||
// Whisper is sure. Trust it even when it is not a language the user speaks — that is exactly the
|
||||
// case translation exists for.
|
||||
detectedLanguage = topCode;
|
||||
} else if (ranked.length > 0 && spokenLanguages.length > 0) {
|
||||
// Genuinely ambiguous (usually a short clip). Break the tie toward a language the user speaks,
|
||||
// since Whisper's low-confidence guess defaults to English regardless of the audio.
|
||||
const bestSpoken = spokenLanguages
|
||||
.map((code) => [code, probs?.[code] ?? 0] as const)
|
||||
.sort((a, b) => b[1] - a[1])[0];
|
||||
detectedLanguage = bestSpoken?.[0] ?? topCode ?? 'en';
|
||||
} else if (topCode) {
|
||||
detectedLanguage = topCode;
|
||||
} else {
|
||||
// No probabilities field (non-whisper.cpp backend) — try to map the reported name.
|
||||
const raw = (detectJson.detected_language ?? detectJson.language ?? 'en').toLowerCase();
|
||||
detectedLanguage = LANG_NAME_TO_ISO[raw] ?? raw;
|
||||
}
|
||||
|
||||
// Step 2: transcribe in the detected language
|
||||
const transcribeForm = new FormData();
|
||||
transcribeForm.append('file', file);
|
||||
transcribeForm.append('language', detectedLanguage);
|
||||
transcribeForm.append('temperature', '0.0');
|
||||
transcribeForm.append('temperature_inc', '0.2');
|
||||
transcribeForm.append('beam_size', String(BEAM_SIZE));
|
||||
transcribeForm.append('best_of', String(BEST_OF));
|
||||
transcribeForm.append('suppress_nst', 'true');
|
||||
transcribeForm.append('response_format', 'verbose_json');
|
||||
// verbose_json re-runs a full language auto-detect unless this is set. Step 1 already answered
|
||||
// that question, so it is pure duplicated work on every transcription.
|
||||
transcribeForm.append('no_language_probabilities', 'true');
|
||||
// Segment timestamps drive the paragraphing below and are kept; per-token timestamps are extra
|
||||
// work on every segment and nothing here reads them.
|
||||
transcribeForm.append('token_timestamps', 'false');
|
||||
|
||||
const prompt = TRANSCRIPT_PROMPTS[detectedLanguage];
|
||||
if (prompt) {
|
||||
transcribeForm.append('prompt', prompt);
|
||||
transcribeForm.append('carry_initial_prompt', 'true');
|
||||
transcribeForm.append('max_context', String(PROMPT_CONTEXT_TOKENS));
|
||||
} else {
|
||||
// Nothing worth protecting the budget for, so close the loop-feeding path outright.
|
||||
transcribeForm.append('max_context', '0');
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(VAD_TUNING)) transcribeForm.append(key, value);
|
||||
|
||||
const res = await fetch(`${base}/inference`, {
|
||||
method: 'POST',
|
||||
body: transcribeForm,
|
||||
timeout: false,
|
||||
} as BunRequestInit);
|
||||
if (!res.ok) throw new Error(`Transcription failed (${res.status})`);
|
||||
|
||||
const json = (await res.json()) as { text?: string; segments?: WhisperSegment[] };
|
||||
// Fall back to the flat text for any backend that does not return segments.
|
||||
const segments = json.segments ?? [];
|
||||
const text = segments.length > 0 ? toParagraphs(segments) : (json.text ?? '').trim();
|
||||
|
||||
return { text, detectedLanguage };
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, createWriteStream } from 'node:fs';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '../../data-path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { killTree } from './process-tree';
|
||||
import { buildTaskApiEnv } from './task-api-env';
|
||||
import { prepareScriptRun } from './script-run';
|
||||
|
||||
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
|
||||
// file); `started`/`exit`/`error` are structural and get buffered for viewer replay.
|
||||
@@ -33,41 +32,6 @@ export type ExecuteScriptParams = {
|
||||
emit: (event: ScriptEvent) => void;
|
||||
};
|
||||
|
||||
const getRunner = (language: string): string[] =>
|
||||
language === 'python'
|
||||
? ['python3']
|
||||
: language === 'typescript'
|
||||
? ['bun', 'run']
|
||||
: language === 'javascript'
|
||||
? ['node']
|
||||
: ['bash'];
|
||||
const getFileName = (language: string): string =>
|
||||
language === 'python'
|
||||
? 'run.py'
|
||||
: language === 'typescript'
|
||||
? 'index.ts'
|
||||
: language === 'javascript'
|
||||
? 'index.js'
|
||||
: 'run.sh';
|
||||
|
||||
function materializeScript(language: string, implementation: string): string {
|
||||
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filePath = join(dir, getFileName(language));
|
||||
writeFileSync(filePath, implementation);
|
||||
chmodSync(filePath, 0o755);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const buildInputEnv = (inputs: Record<string, string>): Record<string, string> => {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) env[`INPUT_${key.toUpperCase()}`] = value;
|
||||
return env;
|
||||
};
|
||||
|
||||
const buildArgs = (inputs: Record<string, string>, argsOrder?: string[] | null): string[] =>
|
||||
!argsOrder || argsOrder.length === 0 ? [] : argsOrder.map((name) => inputs[name] ?? '');
|
||||
|
||||
// The persisted per-job output log — served by GET /jobs/:id/log for backfill / unattended viewing.
|
||||
export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.log`);
|
||||
|
||||
@@ -79,22 +43,16 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
|
||||
const task = await getTaskByDirName(params.taskDirName);
|
||||
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
|
||||
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
|
||||
if (!task.implementation) throw new Error(`Task ${params.taskDirName} has no implementation`);
|
||||
|
||||
const language = task.language ?? 'bash';
|
||||
const scriptPath = materializeScript(language, task.implementation);
|
||||
const inputEnv = buildInputEnv(inputs);
|
||||
const positionalArgs = buildArgs(inputs, task.args);
|
||||
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
|
||||
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir;
|
||||
|
||||
const spawnCmd = cmd;
|
||||
const apiEnv = await buildTaskApiEnv({ id: userId, email, username });
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...apiEnv, ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
// What the script sees is `script-run.ts`'s to decide, for all three runners. This one owns only how
|
||||
// the output is reported: streamed to viewers and appended to a durable log.
|
||||
const prepared = await prepareScriptRun({
|
||||
task,
|
||||
inputs,
|
||||
cwd: params.cwd,
|
||||
user: { id: userId, email, username },
|
||||
});
|
||||
const { cmd: spawnCmd, env: spawnEnv, cwd: spawnCwd, cleanup } = prepared;
|
||||
|
||||
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
|
||||
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
|
||||
@@ -115,15 +73,7 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
/* a log-stream error must never kill the pump */
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
emit({ type: 'started', taskName: task.name });
|
||||
emit({ type: 'started', taskName: prepared.taskName });
|
||||
|
||||
const proc = Bun.spawn(spawnCmd, { cwd: spawnCwd, env: spawnEnv, stdout: 'pipe', stderr: 'pipe' });
|
||||
|
||||
|
||||
@@ -12,13 +12,26 @@ import type { TurnMessage, MessageCost } from '../chat/types';
|
||||
import * as jobManager from './pipeline-job-manager';
|
||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-code';
|
||||
/**
|
||||
* What a task runs on when the account has set no task default.
|
||||
*
|
||||
* Sonnet since 2026-08-25, for the same reason as `CHAT_DEFAULT_MODEL` in `api/chat/websocket.ts`: the
|
||||
* previous value was the bare harness, which means whatever Claude Code chooses for itself, and that is
|
||||
* Opus. A task is the surface where that costs most — it runs unattended, often repeatedly, and nobody is
|
||||
* watching the bill.
|
||||
*
|
||||
* The account default still wins, so anybody who chose a model keeps it.
|
||||
*/
|
||||
const DEFAULT_MODEL = 'claude-code/sonnet';
|
||||
|
||||
async function resolveModel(userId: number): Promise<string> {
|
||||
try {
|
||||
const settings = await getUserSettings(userId);
|
||||
const tasks = settings?.tasks as Record<string, unknown> | undefined;
|
||||
return (tasks?.defaultModel as string) || DEFAULT_MODEL;
|
||||
// A bare `claude-code` is a harness, not a tier — read as unset. See `isTierlessModel` in
|
||||
// `api/chat/websocket.ts`; this machine had exactly that stored, so the default never applied.
|
||||
const stored = tasks?.defaultModel as string | undefined;
|
||||
return stored && stored.includes('/') ? stored : DEFAULT_MODEL;
|
||||
} catch {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getTaskByDirName, type TaskRecord } from './task-files';
|
||||
import { getOwnerHomeDir } from '../../data-path';
|
||||
import { buildTaskApiEnv } from './task-api-env';
|
||||
import type { CredentialScope } from '../../auth-token';
|
||||
|
||||
/**
|
||||
* Everything a script-mode task run needs before something spawns it.
|
||||
*
|
||||
* ── Why this is its own file ──
|
||||
*
|
||||
* Three runners spawn a script task and they must agree about HOW: the ephemeral socket
|
||||
* (`task-executor.ts`), the background job (`execute-script.ts`) and the direct REST call below. Each
|
||||
* carried its own copy of the same twenty lines — materialize the implementation into a temp dir, build
|
||||
* `INPUT_<NAME>` env vars, build positional args in `task.args` order, resolve a home-relative cwd, mint
|
||||
* the API env. Identical line for line, and nothing said they had to stay that way.
|
||||
*
|
||||
* They differ in how they REPORT — streaming to a socket, appending to a durable log, or returning a
|
||||
* string — and that difference is real, so it stays with each of them. What must not differ is what the
|
||||
* script sees: a task that behaves one way from the context menu and another from a commander is a bug
|
||||
* nobody can reproduce, because both callers are "running the task".
|
||||
*/
|
||||
|
||||
const getRunner = (language: string): string[] =>
|
||||
language === 'python'
|
||||
? ['python3']
|
||||
: language === 'typescript'
|
||||
? ['bun', 'run']
|
||||
: language === 'javascript'
|
||||
? ['node']
|
||||
: ['bash'];
|
||||
|
||||
const getFileName = (language: string): string =>
|
||||
language === 'python'
|
||||
? 'run.py'
|
||||
: language === 'typescript'
|
||||
? 'index.ts'
|
||||
: language === 'javascript'
|
||||
? 'index.js'
|
||||
: 'run.sh';
|
||||
|
||||
/**
|
||||
* Only `index.ts` (or its sibling by language) is written out — a script task is materialized ALONE, so
|
||||
* it has to be self-contained. That is a contract, not an oversight: see `platform/CLAUDE.md`.
|
||||
*/
|
||||
function materializeScript(language: string, implementation: string): string {
|
||||
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filePath = join(dir, getFileName(language));
|
||||
writeFileSync(filePath, implementation);
|
||||
chmodSync(filePath, 0o755);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export type ScriptRunUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
username?: string | null;
|
||||
/** The caller's credential narrowing, propagated to the script's own token. See `task-api-env.ts`. */
|
||||
scope?: CredentialScope | null;
|
||||
};
|
||||
|
||||
export type PreparedScriptRun = {
|
||||
taskName: string;
|
||||
cmd: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
/** Removes the temp directory. Every caller must call it, on every path out. */
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type PrepareParams = {
|
||||
task: TaskRecord;
|
||||
inputs: Record<string, string>;
|
||||
/** Home-relative or absolute. Absent means the owner's home. */
|
||||
cwd?: string;
|
||||
user: ScriptRunUser;
|
||||
};
|
||||
|
||||
export async function prepareScriptRun({ task, inputs, cwd, user }: PrepareParams): Promise<PreparedScriptRun> {
|
||||
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
|
||||
if (!task.implementation) throw new Error(`Task ${task.dirName} has no implementation`);
|
||||
|
||||
const language = task.language ?? 'bash';
|
||||
const scriptPath = materializeScript(language, task.implementation);
|
||||
|
||||
// Inputs reach the script two ways, and both are load-bearing: named, as `INPUT_<NAME>`, and
|
||||
// positionally in the order `task.args` declares.
|
||||
const inputEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) inputEnv[`INPUT_${key.toUpperCase()}`] = value;
|
||||
const positionalArgs = task.args?.length ? task.args.map((name) => inputs[name] ?? '') : [];
|
||||
|
||||
// A relative cwd is relative to the OWNER's real home, not the managed one — that is where task runs
|
||||
// execute. A missing cwd surfaces as ENOENT naming the binary rather than the directory, which is why
|
||||
// this resolves rather than passing it through.
|
||||
const homeDir = getOwnerHomeDir(user.email);
|
||||
|
||||
return {
|
||||
taskName: task.name,
|
||||
cmd: [...getRunner(language), scriptPath, ...positionalArgs],
|
||||
cwd: cwd ? (isAbsolute(cwd) ? cwd : join(homeDir, cwd)) : homeDir,
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
...(await buildTaskApiEnv(user)),
|
||||
...inputEnv,
|
||||
},
|
||||
cleanup: () => {
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type DirectRunResult = { code: number; output: string; taskName: string };
|
||||
|
||||
/**
|
||||
* Run a script task and wait for it. No job row, no socket, no streaming.
|
||||
*
|
||||
* This is the shape a caller with no browser needs — a commander's loop, a cron entry, anything holding a
|
||||
* credential rather than a session. The two existing runners both require something to watch them: the
|
||||
* socket needs a live connection, and the job needs somebody to open Jobs to see what happened.
|
||||
*
|
||||
* stdout and stderr are interleaved into one string in arrival order, which is what a caller reading it
|
||||
* as "what happened" wants — the script's own narration and its errors in the order it produced them.
|
||||
*/
|
||||
export async function runScriptTaskDirect(params: {
|
||||
taskDirName: string;
|
||||
inputs: Record<string, string>;
|
||||
cwd?: string;
|
||||
user: ScriptRunUser;
|
||||
}): Promise<DirectRunResult> {
|
||||
const task = await getTaskByDirName(params.taskDirName);
|
||||
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
|
||||
|
||||
const prepared = await prepareScriptRun({ task, inputs: params.inputs, cwd: params.cwd, user: params.user });
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(prepared.cmd, {
|
||||
cwd: prepared.cwd,
|
||||
env: prepared.env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
let output = '';
|
||||
const decoder = new TextDecoder();
|
||||
const drain = async (stream: ReadableStream<Uint8Array>) => {
|
||||
const reader = stream.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Both streams are drained CONCURRENTLY with the wait. Reading them in sequence deadlocks the moment
|
||||
// a script writes more to stderr than the pipe buffer holds while nothing is reading it.
|
||||
await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
|
||||
const code = await proc.exited;
|
||||
|
||||
return { code, output, taskName: prepared.taskName };
|
||||
} finally {
|
||||
prepared.cleanup();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user