Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a37cfd2ce | ||
|
|
36db9a2ae0 | ||
|
|
78896793aa | ||
|
|
9217d1faaa | ||
|
|
be3139f97d | ||
|
|
fafc1adbfd | ||
|
|
b0ceb52bf1 | ||
|
|
861e828fe2 | ||
|
|
da07048f53 | ||
|
|
5fa3f3ec76 | ||
|
|
fb5304563b | ||
|
|
f31aeb4f6e | ||
|
|
d9110c373d | ||
|
|
518d329c29 | ||
|
|
b99a1e7443 | ||
|
|
f5bd611848 | ||
|
|
5dac80e13b |
@@ -22,14 +22,14 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**108 open, 33 closed**, across 35 themes.
|
||||
**109 open, 33 closed**, across 35 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 5 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Backburner](todo/backburner/) | 4 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Calendar and contacts](todo/calendar/) | 1 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
@@ -71,6 +71,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-09-04` **M3E Canvas as a plugin — sketch a UI, hand the prompt to the agent** — [backburner/m3e-canvas-as-a-plugin](todo/backburner/m3e-canvas-as-a-plugin.md)
|
||||
- `2026-08-29` **Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it** — [known-bugs/officer-blocks-its-event-loop-parsing-transcripts](todo/known-bugs/officer-blocks-its-event-loop-parsing-transcripts.md)
|
||||
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
|
||||
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
|
||||
@@ -80,4 +81,3 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
|
||||
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
|
||||
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "officer",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
"@anthropic-ai/sdk": "^0.71.0",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -324,6 +325,8 @@
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.59", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-xPOUZZimZI5ChaO791olWGXqaRvCwOfj9/1micu42EL9czdcwiDm0WK1OGsqb2mZ7LSCoYWBB0ZHVKOxehemDA=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
@@ -1616,6 +1619,8 @@
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
@@ -2290,6 +2295,8 @@
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# 2026.09.09.01 — ten days of platform work, and one new table
|
||||
|
||||
**Twenty-seven commits**, `deploy/2026.08.30.03..fafc1adb`. The fleet is ten days behind; this is a
|
||||
catch-up release rather than a fix for anything found in the last one.
|
||||
|
||||
The larger threads in it, so a host that misbehaves has somewhere to point:
|
||||
|
||||
- **Plugin ports** — the platform can allocate a plugin a stable port and remember it (`518d329c`,
|
||||
`d9110c37`). This is the schema change; see below.
|
||||
- **Publishing is a tag** — the marketplace reads releases, not working trees (`78e89ebc`, `19bbbab9`).
|
||||
- **Plugin routing** — a plugin owns the routing under its mount and can declare its own routes
|
||||
(`89820972`, `1fe767ea`), with `:section` given back to the fifteen plugins the splat took it from
|
||||
(`eaa92738`).
|
||||
- **The code editor's tree and file browser** are exported for plugins to reuse (`fb530456`, `5fa3f3ec`,
|
||||
`da07048f`).
|
||||
- **`anthropicViaProxy`** (`fafc1adb`) — the boundary through which platform-side code, including a
|
||||
plugin's server routes, reaches Claude on the owner's own subscription.
|
||||
|
||||
## The schema change — one new table, one nullable column
|
||||
|
||||
Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.30.03..master | grep -iE 'schema|databases/'
|
||||
```
|
||||
|
||||
Two files, and they are **not** equally in scope:
|
||||
|
||||
1. **`officer_db` — `plugin_ports`, a brand-new table.** No foreign key onto it and no existing column
|
||||
pointing at it, so this is not the create-then-constrain shape that `db:push` cannot do. A plain
|
||||
`bun db:push` is enough, and it is a create on every host.
|
||||
|
||||
2. **`marketplace_db` — `plugins.release_tag`, a nullable text column.** **Out of scope for this
|
||||
rollout.** `bun db:push` covers `officer_db` only, and the marketplace runs on exactly one machine:
|
||||
|
||||
```
|
||||
officer-marketplace → officer-dev only (verified 2026-09-09, pm2 jlist on all six)
|
||||
```
|
||||
|
||||
`officer-dev` is the host the rollout skips. So no target needs it, and nothing should improvise a
|
||||
second push to reach it. It is named here so that when `officer-dev` is restarted deliberately, the
|
||||
marketplace database is known to be the one outstanding piece.
|
||||
|
||||
## Dependency change — `bun install` is required
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.30.03..master -- package.json bun.lock # both changed
|
||||
```
|
||||
|
||||
`@anthropic-ai/sdk@^0.71.0` was added (plus its `json-schema-to-ts` dependency). The lockfile is
|
||||
committed and matches, so the frozen-lockfile default resolves from it — **plain `bun install`, never
|
||||
`--no-frozen-lockfile`.** A host that cannot resolve from the lockfile is a finding, not something to
|
||||
force past.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the rollout (dirty check — TRACKED only,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.09.09.01`, `bun run plugins:update`), plus,
|
||||
in this order:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun db:push < /dev/null
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`bun install` **before** `plugins:update`, because the plugin installer runs against the platform's own
|
||||
resolved modules. `db:push` after both, because `gen-plugin-schemas` runs inside it and reads the
|
||||
plugins as they now are.
|
||||
|
||||
`< /dev/null` on the push is not decoration: stdin closed means a push that wants to ask a question
|
||||
fails instead of hanging forever in an unattended run. This change should ask nothing — a create of a
|
||||
table nothing else references — so **a prompt here is a finding.**
|
||||
|
||||
`pm2 restart officer` only. `plugins:update` has already restarted the plugin sidecars itself.
|
||||
|
||||
### Verify it came back — three checks, per host, by query
|
||||
|
||||
Exit 0 from `db:push` is not evidence; `drizzle-kit push` can fail and still exit 0.
|
||||
|
||||
1. **The table exists**, asked of that host's own database:
|
||||
|
||||
```bash
|
||||
psql "$DB_URL" -tAc "select to_regclass('public.plugin_ports')" # → plugin_ports
|
||||
```
|
||||
|
||||
Read the URL from that checkout's `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`, not
|
||||
`POSTGRES_URL`.
|
||||
|
||||
2. **`pm2 jlist`** — `officer` is `online` and its `restart_time` moved.
|
||||
|
||||
3. **HTTP** — `/api/health` on the host's own port. `200` or `401` are both healthy. The port is in
|
||||
`.env` and is **9010 on `macbookpro`**, 9000 elsewhere.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Login | State before | Note |
|
||||
| ----------------------- | -------------- | ------------------------------ | --------------------------------------------------------- |
|
||||
| `alpha` | `pastilhas` | `19bbbab9`, 8 commits past the tag | Partially pulled at some point; the fast-forward is shorter than the others'. |
|
||||
| `fofos` | `pastilhas` | `fafc1adb` — already at master | Already carries every commit; the merge is a no-op that only moves the tag onto it. Still runs install/push/restart. One untracked file — a note, not a skip. |
|
||||
| `macbookpro` | `pastilhas` | `c97da029` (`2026.08.30.03`) | Laptop. Asleep is a SKIP with a note. `export PATH=$PATH:/opt/homebrew/bin` for `bun`, and run `pm2` as a separate command. Port **9010**. |
|
||||
| `deadpool-rezio-bridge` | **`br`** | `c97da029` (`2026.08.30.03`) | Not `pastilhas`. |
|
||||
|
||||
### Skipped, deliberately — not failures
|
||||
|
||||
- **`officer-dev`** — the host the rollout runs on. Skipping it entirely is the rule; restarting
|
||||
`officer` there ends the rollout mid-report. It is at `fafc1adb` and therefore **not behind** in code;
|
||||
what it does still want is the marketplace column above, whenever the owner restarts it.
|
||||
- **`macbruno`** — unreachable at cut time (`connect to host macbruno port 22: Connection timed out`,
|
||||
verified 2026-09-09). A Mac, so most likely asleep.
|
||||
- **Both Pertento hosts** — on the deliberately-skipped list in `docs/servers.md` since 2026-09-08.
|
||||
Owner's call, no fault on either box.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Four hosts attempted: `alpha`, `fofos`, `macbookpro`, `deadpool-rezio-bridge`. Each at
|
||||
`deploy/2026.09.09.01`, `plugin_ports` present by query, `officer` back online, health answering on the
|
||||
right port.
|
||||
|
||||
A host that fails does not stop the rollout. It gets its own line saying which step failed and what
|
||||
never ran.
|
||||
|
||||
## Known and carried, not introduced here
|
||||
|
||||
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
|
||||
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types). It is present on `master`
|
||||
before this release's commits, it is type-only, and Bun strips types at run time — so it does not
|
||||
affect the rollout. Named here so it is not discovered mid-deploy and mistaken for something this
|
||||
release did.
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026.09.09.01 — report
|
||||
|
||||
**4 of 4 target hosts succeeded.** `officer-dev` skipped entirely, as the host the rollout ran on.
|
||||
`macbruno` and both Pertento hosts skipped per the plan and `docs/servers.md`.
|
||||
|
||||
Every target ended at `be3139f9` — the commit `deploy/2026.09.09.01` points at. `plugin_ports` was
|
||||
verified **by query on each host's own database**, not inferred from `db:push` exit codes.
|
||||
|
||||
**Two hosts were already at the tag before the rollout touched them, and the cause was a person, not a
|
||||
process.** `alpha` was at `be3139f9` rather than the `19bbbab9` the plan recorded, and `fofos` was at
|
||||
`be3139f9`. The deploy agent inferred `auto-pull.sh` on its minute crontab; **that inference is wrong
|
||||
and is corrected here** so nobody spends time hunting a mechanism that was not involved.
|
||||
|
||||
What actually happened: the session that cut this release also rolled `alpha` by hand before being
|
||||
stopped — the release is the releaser's job and the rollout is this commander's, and that line was
|
||||
crossed. `fofos` was at `master` for an unrelated reason: it had been deployed to repeatedly through
|
||||
the day during work on the `fofos-website` plugin.
|
||||
|
||||
So their merges were no-ops. Only `macbookpro` and `deadpool-rezio-bridge` were genuine fast-forwards,
|
||||
and they are also the only two where `bun install` had work to do.
|
||||
|
||||
The `auto-pull` reading is worth keeping visible rather than deleting, because it is exactly the shape
|
||||
of a plausible wrong answer: a real cron, on the right machine, with the right effect, that had nothing
|
||||
to do with this. A host being unexpectedly current is evidence of *something*, and "the obvious
|
||||
candidate" is not the same as the cause.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | `bun install` | `plugins:update` | `plugin_ports` by query | `officer` restart_time | health |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | — | — | **SKIPPED** — the host this ran on |
|
||||
| `alpha` | `be3139f9` → `be3139f9` | no changes | **16/17, exit 1** | ✓ present, 5 cols | 10 → 11, online | `401` (9000) |
|
||||
| `fofos` | `be3139f9` → `be3139f9` | no changes | **4/5, exit 1** | ✓ present, 5 cols | 380 → 381, online | `401` (9000) |
|
||||
| `macbookpro` | `c97da029` → `be3139f9` | **+ @anthropic-ai/sdk@0.71.2** | no plugins installed | ✓ present, 5 cols | 0 → 1, online | `401` (**9010**) |
|
||||
| `deadpool-rezio-bridge` | `c97da029` → `be3139f9` | **+ @anthropic-ai/sdk@0.71.2** | 2/2, exit 0 | ✓ present, 5 cols | 0 → 1, online | `401` (9000) |
|
||||
|
||||
`401` on `/api/health` is healthy — the endpoint is behind auth. Ports were read from each `.env`.
|
||||
|
||||
The schema check run per host was `select to_regclass('public.plugin_ports')`, answered `plugin_ports`
|
||||
on all four, with columns `id`, `app_name`, `name`, `port`, `allocated_at timestamptz`. Databases:
|
||||
`officer_dev` on `alpha` and `macbookpro`, `officer` on `fofos` and `deadpool-rezio-bridge`.
|
||||
No `db:push` prompted for anything — `< /dev/null` was used on every host, as the plan required.
|
||||
|
||||
## What the owner should act on
|
||||
|
||||
- **`plugins:update` failed on two hosts, both for uncommitted work in a plugin checkout.** Nothing was
|
||||
discarded. Each is one `git commit` or `git checkout --` away from clean:
|
||||
- `alpha` — `plugins/av-club` at `62972c1`, 3 modified: `docs/SECURITY.md`, `sidecar/config.ts`,
|
||||
`sidecar/paths.ts`. That plugin did **not** update on `alpha` and is still on its old checkout.
|
||||
- `fofos` — `plugins/fofos-website` at `7001eb0`, 1 modified: `frontend/Layout/Footer/Footer.tsx`.
|
||||
Same consequence: that plugin did not update.
|
||||
- **`alpha` could not fetch.** `git fetch origin --tags` failed with
|
||||
`Connection closed by 77.42.39.155 port 22` — gitea's public SSH, rate-limited at the proxy. Its
|
||||
`origin` is `git@gitea.officer.dev`. It made no difference this time because the tag was already
|
||||
local, but a host that genuinely needed the tag would have failed there. **The remote on that host is
|
||||
the thing that is wrong**; pointing it at the tailnet address removes the whole class. The same
|
||||
recommendation was made in the `2026.08.30.03` report about `macbookpro`, which fetched cleanly today.
|
||||
- **`macbruno` was not attempted.** The plan recorded it unreachable at cut time (`Connection timed
|
||||
out`, 2026-09-09) and named it skipped, so no retry budget was spent on it. It is at `c97da029` as far
|
||||
as anyone knows and will be a real fast-forward whenever it is next reached.
|
||||
- **`officer-dev` still wants the marketplace column.** `plugins.release_tag` in `marketplace_db` is
|
||||
out of scope for this rollout by the plan's own words — `bun db:push` covers `officer_db` only, and
|
||||
`officer-marketplace` runs on this host alone, which is the host the rollout skips. Whoever restarts
|
||||
`officer-dev` deliberately should apply it then. Nothing improvised a second push to reach it.
|
||||
|
||||
## Not done, deliberately
|
||||
|
||||
- `officer-dev` — not pulled, not restarted, nothing touched. It is already at `be3139f9` in code.
|
||||
- `macbruno`, `production-pertento-bridge`, `edge-pertento-bridge` — skipped, no fault on any of them.
|
||||
- The `bunx tsgo` error at `src/servers/sidecar/claude/claude-manager.ts:667` was neither checked nor
|
||||
fixed. The plan names it as pre-existing and type-only; nothing in this rollout would have surfaced it.
|
||||
@@ -0,0 +1,70 @@
|
||||
# 2026.09.14.01 — command-hq self-reported arm/fire/disarm
|
||||
|
||||
**One commit**, `deploy/2026.09.09.01..36db9a2a`. Backend-only, no schema change, no new dependency.
|
||||
|
||||
## What it is
|
||||
|
||||
Three new routes, `POST /command-hq/commanders/dir/:dirName/{arm,fire,disarm}` — a second, self-reported
|
||||
door onto the existing `armCommandHqWatcher`/`recordCommandHqWatcherFired`/`disarmCommandHqWatcher`
|
||||
functions, for a commander loop that outlives its own turn (detached, or a tracked background job that
|
||||
dies with the turn anyway) and so never produces the harness's own `task:started`/`task:notification`
|
||||
events. Authenticated with the commander's own long-lived `OFFICER_API_KEY`. Full account in
|
||||
`docs/command-hq.md` → "Self-reported arm".
|
||||
|
||||
No route removed, no existing route's contract changed, no frontend change. The only way this release
|
||||
does anything is a host restarting `officer` to pick up the new routes — a commander whose loop already
|
||||
calls them (this machine's four) gets nothing from the rollout on any OTHER host, because those hosts'
|
||||
own commanders, if any, don't call them yet.
|
||||
|
||||
## Verified, not assumed
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.09.09.01..HEAD | grep -iE 'schema|databases/' # → nothing
|
||||
git diff --name-only deploy/2026.09.09.01..HEAD -- package.json bun.lock # → nothing
|
||||
git diff --name-only deploy/2026.09.09.01..HEAD -- docs/servers.md # → nothing, host list unchanged
|
||||
bun run docs:check # → green
|
||||
```
|
||||
|
||||
No schema change, no dependency change, no host-list change since the last release. `package.json`
|
||||
version bumped to match this tag and nothing else in it changed.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
Standard steps only, nothing extra: dirty check (TRACKED only), `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.09.14.01`, `bun run plugins:update`, `pm2 restart officer`. No
|
||||
`bun install`, no `db:push` — neither is needed and running either is not a finding, just unnecessary.
|
||||
|
||||
### Verify it came back — two checks, per host
|
||||
|
||||
No schema table to query this time, so the usual three checks drop to two:
|
||||
|
||||
1. **`pm2 jlist`** — `officer` is `online` and its `restart_time` moved.
|
||||
2. **HTTP** — `/api/health` on the host's own port. `200` or `401` are both healthy. Port is **9010 on
|
||||
`macbookpro`**, 9000 elsewhere.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Login | Note |
|
||||
| ----------------------- | -------------- | ----------------------------------------------------------|
|
||||
| `alpha` | `pastilhas` | |
|
||||
| `fofos` | `pastilhas` | |
|
||||
| `macbookpro` | `pastilhas` | Laptop. Asleep is a SKIP with a note. Port **9010**. |
|
||||
| `deadpool-rezio-bridge` | **`br`** | Not `pastilhas`. |
|
||||
|
||||
### Skipped, deliberately — not failures
|
||||
|
||||
- **`officer-dev`** — the host the rollout runs on. Skip entirely, per the standard rule.
|
||||
- **`macbruno`** — reachability unknown at cut time; check live, unreachable is a SKIP not a failure.
|
||||
- **Both Pertento hosts** — deliberately-skipped list in `docs/servers.md` since 2026-09-08.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Four hosts attempted: `alpha`, `fofos`, `macbookpro`, `deadpool-rezio-bridge`. Each at
|
||||
`deploy/2026.09.14.01`, `officer` back online, health answering on the right port. A host that fails
|
||||
does not stop the rollout — its own line, what failed, what never ran.
|
||||
|
||||
## Known and carried, not introduced here
|
||||
|
||||
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
|
||||
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types) — present before this release,
|
||||
type-only, stripped at run time. Named here so it isn't mistaken for something this release did.
|
||||
+39
-3
@@ -62,11 +62,14 @@ for exact request/response shapes; the table below is the map, not the full cont
|
||||
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
|
||||
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
|
||||
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
|
||||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
|
||||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms. This route still can't arm — see the `dir/:dirName` routes below for the one path that can from outside a running turn |
|
||||
| DELETE | `/commanders/:sessionId` | — | deletes the TRANSCRIPT (`/clear` chain and all) via `deleteClaudeSession`, then the row. Not reversible, and not the same thing `/purge` does — see "The `/chat` ↔ `/command-hq` relationship" below |
|
||||
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
||||
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
||||
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
|
||||
| POST | `/commanders/dir/:dirName/arm` | `{taskId}` | self-reported arm, for a loop the harness never sees fire — see "Self-reported arm" below. `:dirName` is the commander's own numeric id as a string, not the transcript uuid |
|
||||
| POST | `/commanders/dir/:dirName/fire` | `{taskId}` | self-reported trigger; no-ops (`{ok:false}`) unless `taskId` matches the row's current `watcherTaskId` |
|
||||
| POST | `/commanders/dir/:dirName/disarm` | — | self-reported clean exit; same effect as `PATCH {watcherArmed:false}`, resolved by dirName instead |
|
||||
|
||||
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
|
||||
duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
|
||||
@@ -463,8 +466,9 @@ occur.
|
||||
`recordCommandHqTurn` in `queries.ts`; exposed read-only on `GET /commanders` and
|
||||
`GET /commanders/:sessionId` (`watcherLastFiredAt` survives as a name in the JSON response — it is just
|
||||
computed there, `MAX(fired_at)`, not read off a column), and `PATCH /commanders/:sessionId` accepts
|
||||
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
|
||||
running turn has).
|
||||
`{watcherArmed: false}` to disarm. Arming still has no route THERE — it needs a live task id, which only
|
||||
a running turn has — but see "Self-reported arm" below for the route that exists precisely because a
|
||||
loop that outlives its turn doesn't have one.
|
||||
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
|
||||
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
|
||||
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
|
||||
@@ -537,6 +541,38 @@ OWN user id, not the row it belonged to). Found by re-reading every call site th
|
||||
after the fact, not by any tool — worth remembering the next time a signature changes shape rather than
|
||||
just its types: a compiler that stays quiet is not the same as a diff that is correct.
|
||||
|
||||
### Self-reported arm — 2026-09-14
|
||||
|
||||
The mechanism above has a gap it never surfaced until four real commanders on the owner's own machine were
|
||||
checked against it: `pendingArmWatcher` is consumed on the harness's `task:started` for the live session
|
||||
that submitted the arm dialog, and stays valid only as long as that session's own tracked-background-task
|
||||
plumbing does. A loop that needs to outlive its turn — `setsid`/`nohup`/`disown`, or any tracked background
|
||||
job that the harness itself drops when the turn ends, which multiple real commanders here do on purpose,
|
||||
one of them (`docs`-adjacent `WATCHERS.md` for the OCR watcher) as an explicit standing decision favouring
|
||||
job-panel visibility over survival — never produces that event. `watcherArmed` stayed `false` and
|
||||
`command_hq_watcher_fires` stayed empty forever for every one of them, even while the loop genuinely polled
|
||||
and genuinely fired, repeatedly, for weeks. Not cosmetic: `GET /commanders`'s card already papers over the
|
||||
`watcherArmed` boolean with a live pid check (`row.watcherArmed || liveLoopPid(...) !== null`, `router.ts`),
|
||||
so the badge was always right — but nothing papers over an empty `command_hq_watcher_fires`, so
|
||||
`watcherLastFiredAt` read `null` forever on every card for a commander that had fired dozens of times.
|
||||
|
||||
Fixed by giving the loop a second, independent way to say the same thing the harness's own tracking says
|
||||
for a session-scoped commander: three routes under `/commanders/dir/:dirName/{arm,fire,disarm}`,
|
||||
authenticated with the commander's own long-lived `OFFICER_API_KEY` (the same credential its loop already
|
||||
holds and already uses for `/api/notify` and `/api/tasks/*/run` — nothing new minted), keyed on `dirName`
|
||||
because that is what a loop already knows about itself without needing its own transcript uuid. `arm` and
|
||||
`disarm` call `armCommandHqWatcher`/`disarmCommandHqWatcher` directly — the same functions `websocket.ts`
|
||||
calls, just reached from a second door. `fire` keeps the identical staleness guard `task:notification`
|
||||
already has (`watcherTaskId` must match), so a superseded instance of a loop — an old pid a crash left
|
||||
running, now replaced by a freshly-armed one — can't keep writing fires for a watch it no longer is. The
|
||||
task id itself is no longer harness-issued for this path: the loop mints its own (pid + start time is
|
||||
enough) at the top of the script and reuses it for every `fire` call until it restarts.
|
||||
|
||||
This does not change anything about the original harness-driven path — a commander whose loop genuinely is
|
||||
a tracked background job on a live session still arms exactly as before, and most of what's written above
|
||||
about `pendingArmWatcher` and the noticing in `createMessageHandler` is unchanged. It adds a second, parallel
|
||||
way to reach the same two DB functions, for the class of commander the first path structurally can't reach.
|
||||
|
||||
## Later — explicitly deferred, not forgotten
|
||||
|
||||
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
|
||||
|
||||
+16
-8
@@ -21,7 +21,8 @@ ssh br@deadpool-rezio-bridge # not implied
|
||||
|
||||
## Officer platforms
|
||||
|
||||
Eight, all running. **Verified 2026-08-27** — every row below was read off the machine, not recalled.
|
||||
Six, all running. **Verified 2026-08-27**, minus the two Pertento hosts moved to "Deliberately skipped" on
|
||||
2026-09-08 — every remaining row below was read off the machine, not recalled.
|
||||
|
||||
| Host | Tailnet IP | Login | Port | Checkout | Domain |
|
||||
| ---------------------------- | ----------- | ----------------------------------------- | -------- | ----------------------------------- | --------------------------------- |
|
||||
@@ -30,8 +31,6 @@ Eight, all running. **Verified 2026-08-27** — every row below was read off the
|
||||
| `fofos` | 100.64.0.17 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | **9010** | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||
| `macbruno` | 100.64.0.22 | **`brunoalcadapadezmonteirodaconceicao`** | 9000 | `~/officerdev/platform` | — (none yet) |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | 9000 | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
|
||||
**THE PORT IS NOT ALWAYS 9000.** Two hosts run on 9010 and it is in `OFFICERDEV_PORT` in each checkout's
|
||||
@@ -70,12 +69,21 @@ ssh host 'ls /opt/homebrew/bin/pm2 /usr/local/bin/pm2 ~/.nvm/versions/node/*/bin
|
||||
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.
|
||||
|
||||
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.
|
||||
**Both Pertento hosts, since 2026-09-08** — `production-pertento-bridge` and `edge-pertento-bridge`. Owner's
|
||||
call, not a fault found on either box. Details kept here rather than dropped, so putting them back is a
|
||||
one-line move, not a re-derivation:
|
||||
|
||||
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.
|
||||
| Host | Tailnet IP | Login | Port | Checkout | Domain |
|
||||
| ----------------------------- | ----------- | ------------ | -------- | ------------------------- | ----------------------- |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
|
||||
`edge-pertento-bridge` was skipped once before, from 2026-08-23 to 2026-08-24 — but that was downtime, not
|
||||
a choice, and it came back as a deploy target. This is a different reason and there is no return date.
|
||||
|
||||
It was down long enough to matter last time, so expect it to be behind whenever it returns: when it came
|
||||
back on 2026-08-24 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
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.30.03",
|
||||
"version": "2026.09.14.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
"@anthropic-ai/sdk": "^0.71.0",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
|
||||
@@ -32,7 +32,7 @@ import { CATALOGUE } from '../src/servers/app-store/catalogue';
|
||||
import { PLATFORM_DIR } from '../src/servers/data-path';
|
||||
import { discoverPlugins } from '../src/servers/plugins/discover';
|
||||
import { pluginPm2Entry, type Pm2App } from '../src/servers/plugins/ecosystem';
|
||||
import { listPluginInstalls, listSidecarInstalls } from 'officerdb';
|
||||
import { listPluginInstalls, listPluginPorts, listSidecarInstalls } from 'officerdb';
|
||||
|
||||
const done = (message: string): never => {
|
||||
console.log(`[gen:ecosystem] ${message}`);
|
||||
@@ -92,7 +92,24 @@ const DISABLED = new Set(
|
||||
async function pluginEntries(): Promise<Pm2App[]> {
|
||||
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
|
||||
const enabled = new Set(installs.filter((i) => i.enabled).map((i) => i.appName));
|
||||
return plugins.filter((p) => enabled.has(p.appName)).flatMap((p) => pluginPm2Entry(p) ?? []);
|
||||
/*
|
||||
Ports come from the database, not from a fresh allocation.
|
||||
|
||||
This script rebuilds the whole file from what is installed, and it must produce the SAME entries the
|
||||
installer produced — allocating here would hand a plugin a different number than the one already
|
||||
written into somebody's nginx configuration.
|
||||
*/
|
||||
const allocations = await listPluginPorts();
|
||||
const byPlugin = new Map<string, Record<string, number>>();
|
||||
for (const row of allocations) {
|
||||
const current = byPlugin.get(row.appName) ?? {};
|
||||
current[row.name] = row.port;
|
||||
byPlugin.set(row.appName, current);
|
||||
}
|
||||
|
||||
return plugins
|
||||
.filter((p) => enabled.has(p.appName))
|
||||
.flatMap((p) => pluginPm2Entry(p, byPlugin.get(p.appName)) ?? []);
|
||||
}
|
||||
|
||||
/** Installed AND enabled. Unlike a plugin, every catalogue entry has a `process` — none are filtered out. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { pluginInstalls } from './schema';
|
||||
import { pluginInstalls, pluginPorts } from './schema';
|
||||
|
||||
export type PluginInstall = typeof pluginInstalls.$inferSelect;
|
||||
|
||||
@@ -47,3 +47,90 @@ export async function removePluginInstall(appName: string): Promise<boolean> {
|
||||
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// ── Ports the platform hands out and remembers ────────────────────────────────────────────────────
|
||||
|
||||
export type PluginPort = typeof pluginPorts.$inferSelect;
|
||||
|
||||
/** Every allocation, for the settings screen and for anyone asking what is spoken for. */
|
||||
export async function listPluginPorts(): Promise<PluginPort[]> {
|
||||
return db.select().from(pluginPorts).orderBy(pluginPorts.port);
|
||||
}
|
||||
|
||||
/** What this plugin has been given, as `{ name: port }`. Empty when it has asked for nothing. */
|
||||
export async function getPluginPorts(appName: string): Promise<Record<string, number>> {
|
||||
const rows = await db.select().from(pluginPorts).where(eq(pluginPorts.appName, appName));
|
||||
return Object.fromEntries(rows.map((r) => [r.name, r.port]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a port nothing is using, by asking the kernel and then checking our own book.
|
||||
*
|
||||
* Binding `port: 0` is the only honest way to know a port is free — a list of what we have handed out
|
||||
* says nothing about what else on this machine is listening, and a hardcoded range says nothing about
|
||||
* either. So the kernel picks, we let go, and the caller binds it a moment later.
|
||||
*
|
||||
* ── The gap between letting go and binding ──
|
||||
*
|
||||
* Real, and unavoidable without holding the socket open across a process boundary. Something else can
|
||||
* take the port in between. That is why allocation is not the last word: a sidecar that cannot bind its
|
||||
* remembered port must say so loudly rather than quietly choosing another, because the number is written
|
||||
* down in configuration this database cannot see.
|
||||
*/
|
||||
async function freePort(taken: Set<number>): Promise<number> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const port = probe.port;
|
||||
probe.stop(true);
|
||||
if (port && !taken.has(port)) return port;
|
||||
}
|
||||
throw new Error('could not find a free port after 20 attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* The port this plugin was given for `name`, allocating one the first time.
|
||||
*
|
||||
* Idempotent and stable: the same plugin and name always gets the same number back, which is the entire
|
||||
* point — see the note on `pluginPorts`. Re-installing, upgrading and restarting all return what was
|
||||
* given the first time.
|
||||
*/
|
||||
export async function allocatePluginPort(appName: string, name: string): Promise<number> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(pluginPorts)
|
||||
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
|
||||
if (existing) return existing.port;
|
||||
|
||||
/*
|
||||
Racing another install is possible and is handled by the unique index rather than by a lock: two
|
||||
installs running at once can be handed the same number by two different kernel probes, and exactly
|
||||
one of the inserts will win. The loser tries again with a fresh number rather than failing, because
|
||||
"somebody else installed a plugin at the same moment" is not a reason to refuse.
|
||||
*/
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const taken = new Set((await db.select().from(pluginPorts)).map((r) => r.port));
|
||||
const port = await freePort(taken);
|
||||
try {
|
||||
const [row] = await db.insert(pluginPorts).values({ appName, name, port }).returning();
|
||||
return row!.port;
|
||||
} catch {
|
||||
const [now] = await db
|
||||
.select()
|
||||
.from(pluginPorts)
|
||||
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
|
||||
if (now) return now.port; // the other writer was allocating the same thing
|
||||
}
|
||||
}
|
||||
throw new Error(`could not allocate a port for ${appName}/${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up a plugin's ports. NOT called on uninstall — see the note in schema.ts.
|
||||
*
|
||||
* The number lives in configuration this database cannot see, so handing it to something else because a
|
||||
* plugin was briefly removed points that configuration at the wrong service. Only `purge` does this.
|
||||
*/
|
||||
export async function releasePluginPorts(appName: string): Promise<number> {
|
||||
const rows = await db.delete(pluginPorts).where(eq(pluginPorts.appName, appName)).returning();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, integer, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
// Which plugins are installed on this machine, and whether they should be mounted.
|
||||
//
|
||||
@@ -56,3 +56,57 @@ export const pluginInstalls = pgTable(
|
||||
uniqueIndex('uq_plugin_installs_app_name').on(t.appName),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Ports the platform has given out to plugins, and remembers.
|
||||
*
|
||||
* ── Why the platform allocates these when it allocates nothing else ──
|
||||
*
|
||||
* A sidecar's own port is not allocated: it binds `port: 0`, the kernel picks one, and it announces the
|
||||
* number over the registration socket. That is right for what it is — a private detail between two
|
||||
* processes, on loopback, re-chosen every restart, and nothing outside Officer ever needs to know it.
|
||||
*
|
||||
* A port something ELSE connects to is a different thing entirely. A plugin serving a site has to be
|
||||
* reachable from nginx, which means the number must be stable across restarts and must be knowable by a
|
||||
* human writing a proxy configuration. Ephemeral fails both.
|
||||
*
|
||||
* Doing it here rather than in each plugin's own settings buys the one thing per-plugin config cannot:
|
||||
* **two plugins cannot be given the same number**, and the platform knows which are spoken for. That is
|
||||
* `uq_plugin_ports_port` below, and it is the whole argument for this table.
|
||||
*
|
||||
* ── Kept when a plugin is uninstalled ──
|
||||
*
|
||||
* Deliberate. The number is written down somewhere this database cannot see — an nginx proxy host, a
|
||||
* firewall rule, somebody's notes — so handing it to a different plugin because this one was briefly
|
||||
* removed would point that configuration at the wrong service. Reinstalling gets the same port back.
|
||||
* `purge` is the deliberate act that gives it up.
|
||||
*/
|
||||
export const pluginPorts = pgTable(
|
||||
'plugin_ports',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The plugin's app name — its directory, as everywhere else. */
|
||||
appName: text('app_name').notNull(),
|
||||
/**
|
||||
* Which of the plugin's ports this is, as the manifest named it.
|
||||
*
|
||||
* A plugin may want more than one — a site and a preview of it — so the number alone is not an
|
||||
* identity. Reaches the sidecar as `PORT_<NAME>` in its environment, uppercased.
|
||||
*/
|
||||
name: text('name').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
allocatedAt: timestamp('allocated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One allocation per named port per plugin.
|
||||
uniqueIndex('uq_plugin_ports_app_name_name').on(t.appName, t.name),
|
||||
/*
|
||||
No two plugins on the same number, said structurally.
|
||||
|
||||
This is the reason the table exists rather than a convention people follow. A collision here is not
|
||||
a duplicate row, it is two services silently fighting over a listener, where the loser fails to bind
|
||||
at startup and the winner is whichever happened to start first.
|
||||
*/
|
||||
uniqueIndex('uq_plugin_ports_port').on(t.port),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { ANTHROPIC_PROXY_URL } from './officer-url.mjs';
|
||||
import { readProxySecretFromDisk } from './sidecar/claude/state';
|
||||
|
||||
/**
|
||||
* The sanctioned way for PLATFORM-SIDE code — including a plugin's server routes — to call Claude.
|
||||
*
|
||||
* ── Why this module exists ──
|
||||
*
|
||||
* `officer-anthropic-proxy` swaps a local secret for the owner's OAuth bearer, and that secret lives
|
||||
* in `sidecar/claude-state.json` — platform-internal state whose location, format and permissions are
|
||||
* this repo's to change. The first caller outside the sidecar (fofos-website's translate route) read
|
||||
* the file directly, which worked and meant the sidecar could no longer move its own state without
|
||||
* breaking a plugin. This module is the boundary: plugins import IT, and only platform code touches
|
||||
* the file.
|
||||
*
|
||||
* ── Why a module and not an HTTP route ──
|
||||
*
|
||||
* Every caller so far is server-side code in this same process, already behind its own gate. An HTTP
|
||||
* endpoint that spends the owner's subscription would be NEW surface — reachable by anything holding a
|
||||
* session, needing its own registry entry and permission — bought for no caller that exists. The day
|
||||
* a browser-side caller appears, the route is a thin wrapper over this plus that registry entry.
|
||||
*
|
||||
* ── The secret is read per call ──
|
||||
*
|
||||
* A fresh install writes it through a 30s debounce, so anything that cached at boot could hold an
|
||||
* empty string forever. Reading late costs one small file read and removes the failure mode.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A client pointed at the proxy, or null when the secret is not on disk yet.
|
||||
*
|
||||
* Null rather than a throw, because "not yet" is an ordinary state on a fresh install — the caller
|
||||
* says which process is missing, in its own words, with its own status code.
|
||||
*/
|
||||
/*
|
||||
Re-exported so a caller's `instanceof` works.
|
||||
|
||||
A plugin that imported '@anthropic-ai/sdk' itself could resolve a SECOND copy from the plugins'
|
||||
own node_modules — and an error thrown by the platform's copy is not an instance of the other
|
||||
one's classes, so `catch (e) { e instanceof RateLimitError }` silently stops matching. One import
|
||||
path, one copy, one class identity.
|
||||
*/
|
||||
export { default as Anthropic } from '@anthropic-ai/sdk';
|
||||
|
||||
export function anthropicViaProxy(): Anthropic | null {
|
||||
const secret = readProxySecretFromDisk();
|
||||
if (!secret) return null;
|
||||
return new Anthropic({ apiKey: secret, baseURL: ANTHROPIC_PROXY_URL });
|
||||
}
|
||||
@@ -41,9 +41,12 @@ import {
|
||||
setCommanderSessionId,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
getCommandHqSessionById,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
deleteCommandHqSession,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
@@ -721,9 +724,9 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
|
||||
* rename action calls — there is no separate name field on this table to disagree with it.
|
||||
* `watcherArmed: false` — disarm a watcher, so the harness stops re-arming it on its next fire. Only
|
||||
* `false` is accepted here: arming requires a live task id (`armCommandHqWatcher`), which only the turn
|
||||
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
|
||||
* this route is not it.
|
||||
* `false` is accepted here: arming through THIS route would need a live task id, which only the turn
|
||||
* that starts the watcher has. A loop reporting its own task id arms through `POST .../dir/:dirName/arm`
|
||||
* below instead — that route exists precisely because this one can't take it.
|
||||
*/
|
||||
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -751,6 +754,88 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* The self-report counterpart to `armCommandHqWatcher`/`recordCommandHqWatcherFired`'s harness-driven
|
||||
* calls in `websocket.ts`. Those fire off a `task:started`/`task:notification` event from the harness's
|
||||
* OWN background-task tracking on the live session that submitted the arm dialog — which never happens
|
||||
* for a loop that outlives its turn (`setsid`/`nohup`, or a tracked background job that dies with the
|
||||
* turn anyway, which is most of them). `watcher_armed` stayed false and `command_hq_watcher_fires` stayed
|
||||
* empty forever for exactly that class of commander — see `docs/command-hq.md` → "Self-reported arm".
|
||||
*
|
||||
* Authenticated with the commander's own `OFFICER_API_KEY` (minted per-commander in `credential.ts`), the
|
||||
* same door its loop already uses for `/api/notify` and `/api/tasks/*/run`. Keyed on `:dirName`, not
|
||||
* `:sessionId` — a loop knows its own directory name (it's the folder it's running from; `dirName` is
|
||||
* literally `String(id)`, see `createCommander`) and has no reason to also know its transcript's uuid.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/arm', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (!taskId) throw errors.BAD_REQUEST('taskId required');
|
||||
|
||||
await armCommandHqWatcher(user.id, row.sessionId, taskId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `fire`'s own guard mirrors the harness-driven path exactly: only records a trigger when this row is
|
||||
* CURRENTLY armed with a matching task id, so an old instance of a loop that a fresh restart superseded
|
||||
* (new pid, new self-generated task id) can't keep writing fires for a watch it no longer is — the same
|
||||
* protection `websocket.ts`'s `task:notification` handler gets from checking `watcherTaskId === taskId`.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/fire', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (!taskId) throw errors.BAD_REQUEST('taskId required');
|
||||
|
||||
if (!row.watcherArmed || row.watcherTaskId !== taskId) {
|
||||
return ctx.json({ ok: false, reason: 'not the current armed task' });
|
||||
}
|
||||
await recordCommandHqWatcherFired(row.id, taskId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* The loop's own clean-exit counterpart to `arm` — best-effort, same as every other disarm path: a loop
|
||||
* killed without running its trap still leaves `watcher_armed: true` until the next arm or an explicit
|
||||
* disarm, exactly as it already could before this route existed.
|
||||
*
|
||||
* Takes the SAME `taskId` guard as `fire`, and for the same reason: a restart replaces the old instance
|
||||
* with a fresh arm before the old one's process necessarily notices it's been superseded (an inotify
|
||||
* loop's kill can outrace the new one's `arm` call by seconds). An unguarded disarm from the OLD
|
||||
* instance's exit trap would then clear the NEW instance's `watcher_armed` a moment after it was set —
|
||||
* caught in exactly this shape while restarting a real duplicate on this machine, 2026-09-14. `taskId`
|
||||
* is optional so an out-of-band disarm (nothing self-reported ever armed this row) still works — the
|
||||
* guard only applies once there is a task id on the row to disagree with.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/disarm', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (taskId && row.watcherTaskId && row.watcherTaskId !== taskId) {
|
||||
return ctx.json({ ok: false, reason: 'not the current armed task' });
|
||||
}
|
||||
|
||||
await disarmCommandHqWatcher(user.id, row.sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /command-hq/commanders/:sessionId
|
||||
*
|
||||
|
||||
@@ -59,7 +59,23 @@ export async function isKnownToPm2(name: string, cwd: string): Promise<boolean>
|
||||
*/
|
||||
export async function startProcess(name: string, cwd: string): Promise<Pm2Result> {
|
||||
const known = await isKnownToPm2(name, cwd);
|
||||
const args = known ? ['start', name] : ['start', ECOSYSTEM, '--only', name];
|
||||
/*
|
||||
Through the ECOSYSTEM FILE either way, and that is the whole point of this line.
|
||||
|
||||
`pm2 start <name>` restarts a known process with the environment PM2 already holds for it, and never
|
||||
looks at the file again. That was fine while an entry was just a script path — it is not now that an
|
||||
entry carries `env`, because a plugin whose allocated port changed would have its new number written
|
||||
to the file and go on running with the old one.
|
||||
|
||||
Seen exactly once, on the day ports were added: the ecosystem said PORT_SITE=34507, the process was
|
||||
serving 3011, and nothing anywhere reported a disagreement.
|
||||
|
||||
`restart <file> --only <name>` re-reads the entry; `--update-env` makes PM2 replace the stored
|
||||
environment rather than merge into it, so a variable REMOVED from the entry actually goes away.
|
||||
*/
|
||||
const args = known
|
||||
? ['restart', ECOSYSTEM, '--only', name, '--update-env']
|
||||
: ['start', ECOSYSTEM, '--only', name];
|
||||
|
||||
const { code, out } = await pm2(args, cwd);
|
||||
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
|
||||
|
||||
@@ -30,8 +30,22 @@ import type { DiscoveredPlugin } from './manifest';
|
||||
|
||||
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
|
||||
|
||||
/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
|
||||
export type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean };
|
||||
/**
|
||||
* One PM2 app entry. Matches what `services.sh` emits for the core processes.
|
||||
*
|
||||
* `env` carries the ports the platform allocated for this plugin — `PORT_<NAME>`. It is written into the
|
||||
* ecosystem file rather than sent over the registration socket because a socket answer arrives after the
|
||||
* process has already had to decide what to bind, and pm2 is the only thing that can tell a process
|
||||
* something before it starts.
|
||||
*/
|
||||
export type Pm2App = {
|
||||
name: string;
|
||||
script: string;
|
||||
args?: string;
|
||||
cwd: string;
|
||||
watch: boolean;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header to write when there is no file to take one from.
|
||||
@@ -81,12 +95,24 @@ function writeEcosystemApps(apps: Pm2App[]): void {
|
||||
const marker = existing.indexOf('module.exports');
|
||||
const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER;
|
||||
|
||||
const lines = apps.map(
|
||||
(app) =>
|
||||
const lines = apps.map((app) => {
|
||||
/*
|
||||
Values are emitted as JSON rather than quoted by hand. A port is a number today, but this is the
|
||||
seam where a path or a URL arrives eventually, and one apostrophe in a value would produce a file
|
||||
PM2 cannot parse — which takes every process on the machine down, not just this plugin's.
|
||||
*/
|
||||
const env =
|
||||
app.env && Object.keys(app.env).length
|
||||
? `, env: { ${Object.entries(app.env)
|
||||
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||
.join(', ')} }`
|
||||
: '';
|
||||
return (
|
||||
` { name: '${app.name}', script: '${app.script}'` +
|
||||
(app.args ? `, args: '${app.args}'` : '') +
|
||||
`, cwd: '${app.cwd}', watch: ${app.watch} },`,
|
||||
`, cwd: '${app.cwd}', watch: ${app.watch}${env} },`
|
||||
);
|
||||
});
|
||||
writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`);
|
||||
}
|
||||
|
||||
@@ -98,7 +124,7 @@ function writeEcosystemApps(apps: Pm2App[]): void {
|
||||
* the database says is installed and enabled. Neither should duplicate the other's idea of what a
|
||||
* plugin's entry looks like.
|
||||
*/
|
||||
export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
|
||||
export function pluginPm2Entry(plugin: DiscoveredPlugin, ports?: Record<string, number>): Pm2App | null {
|
||||
if (!plugin.sidecar) return null;
|
||||
|
||||
const name = pluginProcessName(plugin.appName);
|
||||
@@ -108,9 +134,16 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
|
||||
? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1)
|
||||
: plugin.sidecar.script;
|
||||
|
||||
// `PORT_<NAME>`, uppercased. Absent entirely when a plugin asked for none, which is nearly all of them.
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(ports ?? {}).map(([name, port]) => [`PORT_${name.toUpperCase().replace(/-/g, '_')}`, String(port)]),
|
||||
);
|
||||
|
||||
const base = { name, cwd: PLATFORM_DIR, watch: false, ...(Object.keys(env).length ? { env } : {}) };
|
||||
|
||||
return plugin.sidecar.runtime === 'node'
|
||||
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
|
||||
: { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false };
|
||||
? { ...base, script: 'node', args: script }
|
||||
: { ...base, script: 'bun', args: `run ${script}` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,8 +152,8 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
|
||||
* Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved.
|
||||
* Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
|
||||
*/
|
||||
export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean {
|
||||
const entry = pluginPm2Entry(plugin);
|
||||
export function addPluginToEcosystem(plugin: DiscoveredPlugin, ports?: Record<string, number>): boolean {
|
||||
const entry = pluginPm2Entry(plugin, ports);
|
||||
if (!entry) return false;
|
||||
|
||||
const apps = readEcosystemApps().filter((a) => a.name !== entry.name);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-
|
||||
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
||||
import { discoverPlugins } from './discover';
|
||||
import { fetchPlugin } from './fetch';
|
||||
import { revokeGrantsForPermissions } from 'officerdb';
|
||||
import { allocatePluginPort, releasePluginPorts, revokeGrantsForPermissions } from 'officerdb';
|
||||
import { grantPluginToAllRoles } from './grant-on-install';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
@@ -453,7 +453,29 @@ export async function installPlugin(
|
||||
);
|
||||
|
||||
if (hasSidecar(plugin)) {
|
||||
addPluginToEcosystem(plugin);
|
||||
/*
|
||||
Ports the manifest asked the platform to own, allocated before the entry is written because the
|
||||
entry carries them.
|
||||
|
||||
Idempotent: a plugin that already has an allocation gets the same number back, so re-installing
|
||||
and upgrading do not move a port somebody has written into an nginx proxy host. See
|
||||
`pluginPorts` in officerdb for why the platform allocates these when it allocates nothing else.
|
||||
*/
|
||||
const ports: Record<string, number> = {};
|
||||
for (const declared of plugin.manifest.extraPorts ?? []) {
|
||||
ports[declared.name] = await allocatePluginPort(appName, declared.name);
|
||||
}
|
||||
if (Object.keys(ports).length) {
|
||||
await step(
|
||||
steps,
|
||||
onStep,
|
||||
`ports: ${Object.entries(ports)
|
||||
.map(([name, port]) => `${name}=${port}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
addPluginToEcosystem(plugin, ports);
|
||||
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
|
||||
|
||||
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
|
||||
@@ -609,6 +631,17 @@ export async function purgePlugin(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Ports go here and NOT on uninstall, which is the deliberate asymmetry.
|
||||
|
||||
An allocated number is written down where this database cannot see it — an nginx proxy host, a
|
||||
firewall rule, somebody's notes. Uninstalling is reversible and often temporary, so giving the number
|
||||
up there would let a later plugin take it and point that configuration at the wrong service. Purge is
|
||||
the act that says the plugin is gone for good, and that is when the number goes back in the pool.
|
||||
*/
|
||||
const released = await releasePluginPorts(appName);
|
||||
if (released) await step(steps, onStep, `ports released: ${released}`);
|
||||
|
||||
await rm(plugin.dir, { recursive: true, force: true });
|
||||
await step(steps, onStep, `deleted ${plugin.dir}`);
|
||||
|
||||
|
||||
@@ -405,6 +405,51 @@ export type PluginManifest = {
|
||||
*/
|
||||
protocolHeaders?: string[];
|
||||
|
||||
/**
|
||||
* Ports this plugin needs the PLATFORM to allocate and remember.
|
||||
*
|
||||
* ── Not the same thing as a sidecar's own port ──
|
||||
*
|
||||
* A sidecar already gets a port, and nothing here changes that: it binds `port: 0`, the kernel picks
|
||||
* one, and it announces the number over the registration socket. That port is a private detail between
|
||||
* two processes — loopback, re-chosen on every restart, and nothing outside Officer ever needs it.
|
||||
*
|
||||
* This is for a port something ELSE connects to. A plugin serving a website has to be reachable from
|
||||
* nginx, which means the number must survive a restart and must be knowable by whoever writes the proxy
|
||||
* configuration. Ephemeral fails both, which is why the first site plugin had to hardcode 3011 and hope.
|
||||
*
|
||||
* ── What the platform guarantees, and what it does not ──
|
||||
*
|
||||
* Guaranteed: the same plugin and name always get the same number back, and no two plugins are ever
|
||||
* given the same one — that second part is a unique index, not a convention, because a collision is two
|
||||
* services fighting over a listener rather than a duplicate row.
|
||||
*
|
||||
* NOT guaranteed: that the port is still free when the sidecar starts. Something else on the machine
|
||||
* can take it while Officer is down. A sidecar that cannot bind its remembered port must fail loudly
|
||||
* rather than quietly choose another, because the number is written down where this platform cannot
|
||||
* see it.
|
||||
*
|
||||
* ── How it reaches the sidecar ──
|
||||
*
|
||||
* As `PORT_<NAME>` in its environment, uppercased, written into the pm2 entry at install. The sidecar
|
||||
* reads it rather than being told over the socket, because a socket answer would arrive after the
|
||||
* process has already had to decide what to bind.
|
||||
*
|
||||
* ```ts
|
||||
* extraPorts: [{ name: 'site' }] // → PORT_SITE=<n> in the sidecar's environment
|
||||
* ```
|
||||
*
|
||||
* A manifest does NOT get to ask for a particular number, and that is deliberate. A preference sounds
|
||||
* helpful and fails silently: when the wished-for port is taken the plugin is handed a different one,
|
||||
* the install succeeds, and whatever was configured against the old number keeps pointing at nothing.
|
||||
* Nobody is told. Drawing a fresh one every time makes the number always something you had to go and
|
||||
* read, which is the state that keeps the configuration honest.
|
||||
*
|
||||
* Binding is still the sidecar's business, including which interface. A port that exists to be reached
|
||||
* from a container cannot be loopback, and the platform has no way to know which a plugin means.
|
||||
*/
|
||||
extraPorts?: { name: string }[];
|
||||
|
||||
/**
|
||||
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
|
||||
*
|
||||
@@ -957,6 +1002,24 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
|
||||
}
|
||||
}
|
||||
}
|
||||
if (manifest.extraPorts !== undefined) {
|
||||
if (!Array.isArray(manifest.extraPorts)) {
|
||||
problems.push('extraPorts must be an array');
|
||||
} else {
|
||||
const seen = new Set<string>();
|
||||
for (const [i, entry] of manifest.extraPorts.entries()) {
|
||||
const name = (entry as { name?: unknown } | null)?.name;
|
||||
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,30}$/.test(name)) {
|
||||
problems.push(`extraPorts[${i}].name must be a lowercase name, e.g. "site"`);
|
||||
continue;
|
||||
}
|
||||
// The name becomes an environment variable, so two that differ only by case would collide there
|
||||
// while looking distinct here.
|
||||
if (seen.has(name)) problems.push(`extraPorts[${i}] repeats the name "${name}"`);
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (manifest.protocolHeaders !== undefined) {
|
||||
if (!Array.isArray(manifest.protocolHeaders)) {
|
||||
problems.push('protocolHeaders must be an array of header names');
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* A screenshot, shrunk to the largest size the model will actually look at.
|
||||
*
|
||||
* ── Why there is a ceiling at all ──
|
||||
*
|
||||
* An attached image goes to the model as base64 INSIDE the message, and it stays there: every later
|
||||
* turn in the conversation re-sends every earlier picture. So a screenshot is not paid for once, it is
|
||||
* paid for on every turn for the rest of the session — in request bytes and in tokens.
|
||||
*
|
||||
* ── Why 1568 specifically ──
|
||||
*
|
||||
* It is the point past which Anthropic downscales server-side. A 2539px-wide screenshot is resized to
|
||||
* 1568 before the model sees a pixel of it, so the extra width was never read — it was carried up the
|
||||
* wire, counted, and thrown away. Sending it already small changes nothing about what the model can
|
||||
* see and stops paying for what it cannot.
|
||||
*
|
||||
* Measured on one day of this session: 25 screenshots, 10 of them over the line, 53,300 tokens of
|
||||
* image data — 23% of which bought nothing.
|
||||
*
|
||||
* ── The ORIGINAL is still uploaded ──
|
||||
*
|
||||
* This makes the copy that travels in the prompt. The file that lands on disk is untouched, so an agent
|
||||
* that needs the detail can `Read` it at full size — which is the right split: the conversation carries
|
||||
* what is worth re-reading every turn, and the disk keeps what was actually captured.
|
||||
*/
|
||||
const MAX_EDGE = 1568;
|
||||
|
||||
/** JPEG for photographs would be smaller, and a screenshot of text is exactly where it looks worst. */
|
||||
const TYPE = 'image/png';
|
||||
|
||||
export async function downscaleForPrompt(file: File): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file).catch(() => null);
|
||||
if (!bitmap) return readAsDataUrl(file);
|
||||
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
||||
// Already small enough. Re-encoding it would cost quality and could cost SIZE — a PNG round-tripped
|
||||
// through a canvas is often bigger than the one the screenshotter wrote.
|
||||
if (scale === 1) {
|
||||
bitmap.close();
|
||||
return readAsDataUrl(file);
|
||||
}
|
||||
|
||||
const width = Math.round(bitmap.width * scale);
|
||||
const height = Math.round(bitmap.height * scale);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
bitmap.close();
|
||||
return readAsDataUrl(file);
|
||||
}
|
||||
|
||||
// `high` because this is text: the default is a box filter, and screenshot text resampled with one
|
||||
// comes out fringed in a way that is harder to read than the same text smaller.
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
|
||||
return canvas.toDataURL(TYPE);
|
||||
}
|
||||
|
||||
/** The file as it is, for anything this cannot or should not shrink. */
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { downscaleForPrompt } from './components/downscale';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { Attachment } from './types';
|
||||
@@ -52,11 +53,22 @@ export function useAttachments(params?: UseAttachmentsParams) {
|
||||
if (params?.sessionId) formData.append('sessionId', params.sessionId);
|
||||
formData.append('provider', 'chat');
|
||||
|
||||
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
||||
/*
|
||||
The original is uploaded and a SMALLER copy is what travels in the prompt.
|
||||
|
||||
The server's `dataUrl` is the file byte for byte, and that copy is re-sent on every turn for the
|
||||
rest of the conversation. Past 1568px on the long edge the model never sees the extra pixels —
|
||||
they are downscaled server-side before it looks — so the width was being carried and counted and
|
||||
then thrown away. See downscale.ts.
|
||||
|
||||
Both, deliberately: the disk keeps what was captured, so `Read` can still open it at full size.
|
||||
*/
|
||||
const [res, dataUrl] = await Promise.all([
|
||||
client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData),
|
||||
downscaleForPrompt(file),
|
||||
]);
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, i) =>
|
||||
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
||||
),
|
||||
prev.map((a, i) => (i === idx ? { ...a, dataUrl, attachmentId: res.attachmentId, loading: false } : a)),
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
@@ -21,6 +21,14 @@ type FileTreeProps = {
|
||||
* an agent had just written to showing the listing from before its turn.
|
||||
*/
|
||||
refreshToken?: number;
|
||||
/**
|
||||
* A file to reveal: every directory on the way to it opens, loading as it goes.
|
||||
*
|
||||
* For a tree whose selection is driven from OUTSIDE — a nav beside it, a URL — where a folder left
|
||||
* shut around the open file hides the one thing being looked at. Expanding is still disclosure and
|
||||
* still remembered, so anything opened by hand stays open.
|
||||
*/
|
||||
revealPath?: string | null;
|
||||
};
|
||||
|
||||
type TreeNodeProps = {
|
||||
@@ -31,6 +39,7 @@ type TreeNodeProps = {
|
||||
searchForFile?: ((path: string) => string) | null;
|
||||
depth: number;
|
||||
refreshToken?: number;
|
||||
revealPath?: string | null;
|
||||
};
|
||||
|
||||
const FileIcon = ({ name }: { name: string }) => {
|
||||
@@ -44,7 +53,16 @@ const sortEntries = (entries: DirEntry[]) => {
|
||||
return [...dirs, ...files];
|
||||
};
|
||||
|
||||
const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, refreshToken }: TreeNodeProps) => {
|
||||
const TreeNode = ({
|
||||
entry,
|
||||
parentPath,
|
||||
root,
|
||||
onOpenFile,
|
||||
searchForFile,
|
||||
depth,
|
||||
refreshToken,
|
||||
revealPath,
|
||||
}: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -73,6 +91,32 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
/*
|
||||
On the way to the revealed file: open, and load if this is the first time.
|
||||
|
||||
Only ever opens. A node that closes itself when the selection moves elsewhere would collapse the
|
||||
tree under somebody as they clicked around it.
|
||||
*/
|
||||
const onTheWay = !!revealPath && isDir && revealPath.startsWith(`${fullPath}/`);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onTheWay) return;
|
||||
setExpanded(true);
|
||||
if (children !== null) return;
|
||||
|
||||
let alive = true;
|
||||
listDirRef
|
||||
.current(fullPath)
|
||||
.then((res) => alive && setChildren(sortEntries(res.entries)))
|
||||
.catch(() => alive && setChildren([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// `children` is deliberately absent: this must run when the PATH changes, not when the load it
|
||||
// started completes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onTheWay, fullPath]);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!isDir) {
|
||||
onOpenFile(fullPath, entry.name);
|
||||
@@ -140,6 +184,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
|
||||
searchForFile={searchForFile}
|
||||
depth={depth + 1}
|
||||
refreshToken={refreshToken}
|
||||
revealPath={revealPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -148,7 +193,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
|
||||
);
|
||||
};
|
||||
|
||||
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
|
||||
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken, revealPath }: FileTreeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { listDir } = useFilesAPI(root);
|
||||
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||
@@ -208,6 +253,7 @@ export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshTok
|
||||
searchForFile={searchForFile}
|
||||
depth={0}
|
||||
refreshToken={refreshToken}
|
||||
revealPath={revealPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,9 @@ import { CodeEditorHeader } from './CodeEditorHeader';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { CodeEditorView } from './CodeEditor';
|
||||
// The tree on its own, for a panel that wants the editor's file list against its own directory rather
|
||||
// than the whole editor. It only LISTS — opening is the caller's callback.
|
||||
export { FileTree } from './FileTree';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Breadcrumb } from './components/Breadcrumb';
|
||||
import { Toolbar } from './components/Toolbar';
|
||||
import { UploadProgress } from './components/UploadProgress';
|
||||
@@ -35,6 +36,14 @@ type FileBrowserAppProps = {
|
||||
* handling reimplemented, all of it free to drift from the real browser.
|
||||
*/
|
||||
onPickFile?: (file: PickedFile) => void;
|
||||
/**
|
||||
* The folder being shown, whenever it changes.
|
||||
*
|
||||
* For a chooser whose answer is a FOLDER rather than a file. Folders navigate rather than being
|
||||
* pickable — getting to a file requires it — so the only way to say "this one" is to be standing in
|
||||
* it, and the caller needs to know which one that is to put a button on it.
|
||||
*/
|
||||
onPathChange?: (path: string) => void;
|
||||
};
|
||||
|
||||
export const FileBrowserApp = ({
|
||||
@@ -45,9 +54,18 @@ export const FileBrowserApp = ({
|
||||
panelId,
|
||||
pathParam,
|
||||
onPickFile,
|
||||
onPathChange,
|
||||
}: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
|
||||
const { handleNavigate } = fileBrowserManager;
|
||||
const { handleNavigate, currentPath } = fileBrowserManager;
|
||||
|
||||
// Reported from here rather than threaded through the hook: the hook's job is the folder, and one
|
||||
// more positional argument on a six-argument call is how a signature stops being readable.
|
||||
const report = useRef(onPathChange);
|
||||
report.current = onPathChange;
|
||||
useEffect(() => {
|
||||
report.current?.(currentPath);
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
|
||||
@@ -36,7 +36,7 @@ export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
||||
export type { SelectedSession } from './apps/ChatHistory';
|
||||
// The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way.
|
||||
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
|
||||
export { CodeEditorView } from './apps/CodeEditor';
|
||||
export { CodeEditorView, FileTree } from './apps/CodeEditor';
|
||||
|
||||
// Same for /system-monitor, where the param is the scope rather than a section.
|
||||
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
|
||||
|
||||
@@ -149,7 +149,20 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
would be indefensible.
|
||||
*/
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing = Boolean(target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)));
|
||||
/*
|
||||
A CODE EDITOR is typing too, and it does not look like it.
|
||||
|
||||
Monaco's modern input surface is neither a textarea nor contenteditable — it is a plain div the
|
||||
EditContext API writes into — so a tagName test misses it entirely and `ctrl+shift+←/→` cycled
|
||||
the theme while somebody was selecting words in a file. `.monaco-editor` is the class its root
|
||||
always carries; `closest` because the event lands on whatever is inside it.
|
||||
*/
|
||||
const typing = Boolean(
|
||||
target &&
|
||||
(target.isContentEditable ||
|
||||
/^(input|textarea|select)$/i.test(target.tagName) ||
|
||||
target.closest?.('.monaco-editor, [data-typing]')),
|
||||
);
|
||||
|
||||
/*
|
||||
Star or unstar the CURRENT theme, from anywhere, list open or not.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
id: backburner/m3e-canvas-as-a-plugin
|
||||
title: "M3E Canvas as a plugin — sketch a UI, hand the prompt to the agent"
|
||||
theme: backburner
|
||||
status: open
|
||||
created: 2026-09-04
|
||||
date_source: stated
|
||||
order: 4
|
||||
---
|
||||
|
||||
**M3E Canvas as a plugin.** [github.com/lnkiai/m3e-canvas](https://github.com/lnkiai/m3e-canvas) — a
|
||||
browser design tool for Material 3 Expressive screens that exports the design as a natural-language
|
||||
prompt for an AI coding tool. MIT.
|
||||
|
||||
The upstream question is now **answered** — see below; vendoring is the path. The rest is still open.
|
||||
This card holds the measurements so the decision is made from numbers rather than from a README. Everything below was read from the code at **`cfde698`, 2026-09-04**.
|
||||
|
||||
## Why it is interesting
|
||||
|
||||
Its output is *a prompt you paste into Claude*. Officer is the thing that removes the paste — the export
|
||||
button could open a chat session with the prompt already in it, pointed at a project directory on this
|
||||
machine. **That last step is the only part Officer uniquely adds, and it is the whole reason to bother.**
|
||||
The canvas already works standalone today.
|
||||
|
||||
## What it actually is
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| size | 65 files, **14,822 lines** of TS/TSX |
|
||||
| deps | `next` 16.3.4, `react` 19.2.8, `motion` ^13.1.1, `html-to-image` ^1.11.13 |
|
||||
| licence | MIT |
|
||||
| storage | `localStorage` only, no backend |
|
||||
|
||||
**Next.js is not actually there.** The entire codebase imports it **once**, and it is a type-only import:
|
||||
`import type { Metadata, Viewport } from "next"` in `app/layout.tsx`. Eighteen files carry `'use client'`.
|
||||
So it is a client-side React SPA that happens to be scaffolded in Next — there is nothing to strip, and
|
||||
porting means giving it a different mount point.
|
||||
|
||||
That is the finding that changes the estimate. An earlier read of the README concluded "weeks of work,
|
||||
own a fork forever", and that was wrong.
|
||||
|
||||
## The fork line falls on a file boundary
|
||||
|
||||
`app/page.tsx` imports about **twenty modules, every one a named export** through the `@/` alias, and
|
||||
reaches into no internals. `lib/tokens.ts` is the shared spine with 16 importers. `app/` holds exactly
|
||||
one file that matters.
|
||||
|
||||
| | lines | |
|
||||
| --- | --- | --- |
|
||||
| `app/page.tsx` | 3,433 | the whole editor: 15 components, 103 hook calls. **Yours.** |
|
||||
| `lib/ai.ts` | ~200 | **Delete it** — stores provider API keys in `localStorage`. Officer has a secret store and the agent. |
|
||||
| `lib/tokens.ts` | 1,614 | Material 3 tokens. Data. Vendor. |
|
||||
| `components/M3Node.tsx` | 1,233 | the component renderer. Vendor. |
|
||||
| `components/Inspector.tsx` | 1,171 | Vendor. |
|
||||
| `lib/prompt.ts` | 1,151 | the export engine. Vendor. |
|
||||
| `components/Preview.tsx` | 899 | Vendor. |
|
||||
| `components/ui.tsx` | 882 | Vendor. |
|
||||
| rest | ~4,200 | `Mobile`, `i18n`, `tidy`, `Toolbar`, `color`, `shapes`, `theme`, `project`. Vendor. |
|
||||
|
||||
**~3,600 lines owned, ~11,200 vendored** — and the split means something. What is vendored is *"what a
|
||||
Material 3 component looks like and how to describe it in words"*, where upstream's value accumulates and
|
||||
we have no wish to innovate. What is owned is *"where does this live in the workspace, whose is it, and
|
||||
what happens on export"*, where upstream can never help because it does not know Officer exists.
|
||||
|
||||
**So deep integration and tracking upstream are not in tension.** The integration work lives entirely in
|
||||
the one file that was always going to be rewritten.
|
||||
|
||||
## The export point is clean
|
||||
|
||||
```ts
|
||||
buildPrompt(doc: Doc, widths: Record<string, number>, onlyFrameId?: string, lang: Lang): string
|
||||
effectivePrompt(doc, widths, lang): string
|
||||
```
|
||||
|
||||
Two pure functions in `lib/prompt.ts`. `Doc` in, string out, no side effects. No `postMessage` bridge and
|
||||
no clipboard interception needed — **call the function.** Today it ends in
|
||||
`navigator.clipboard.writeText` in three places; that is what gets replaced.
|
||||
|
||||
`Doc` is plain serialisable JSON (`lib/project.ts` already downloads and reloads it), so persisting to a
|
||||
plugin table instead of `localStorage` is nearly free. Current keys: `DOC_KEY` and `UI_KEY` in
|
||||
`page.tsx`, `m3e:ai`, and `m3e:sec:<id>`.
|
||||
|
||||
## The upstream problem, measured
|
||||
|
||||
This is the part that makes it a backburner card rather than a build.
|
||||
|
||||
- **37 commits, 3 days old** — first 2026-09-02, and **22 of them landed on 2026-09-04**, the day this
|
||||
was read.
|
||||
- **Two human authors** (`lnkiai` 25, `CQMHV` 10) plus dependabot.
|
||||
|
||||
It is under heavy active development *right now*. The export surface will churn, and no amount of
|
||||
discipline on our side changes that. "Vendor it and track upstream" assumes an upstream that has settled,
|
||||
and this one has not started.
|
||||
|
||||
### If it is vendored anyway
|
||||
|
||||
`web/vendor/m3e/`, committed with upstream URL, commit SHA and date in a README beside it. **The
|
||||
precedent is already in the tree** — `plugins/jellyfin/web/vendor/` and `public/vendor/pako/`.
|
||||
|
||||
The piece that makes it survivable is **an adapter**: Officer's panels import from one `adapter.ts` that
|
||||
re-exports from `vendor/`, never from `vendor/` directly. An upstream rename then costs one file instead
|
||||
of twenty. Updating is: re-copy, read the diff, fix the seam.
|
||||
|
||||
## Asked upstream, 2026-09-04. Declined — vendoring is the path
|
||||
|
||||
[Issue #55](https://github.com/lnkiai/m3e-canvas/issues/55) asked for the seam;
|
||||
[PR #56](https://github.com/lnkiai/m3e-canvas/pull/56) implemented the smaller half of it. Both closed
|
||||
unmerged on 2026-09-05. The reply is worth reading in full, but the two reasons were:
|
||||
|
||||
1. **The PR did not actually unblock embedding.** It wired one of the three clipboard writes —
|
||||
`app/page.tsx` — and left `components/Inspector.tsx:443` and `components/PromptPanel.tsx:35`
|
||||
writing directly. So it "adds a public surface I'd have to keep stable" without delivering the
|
||||
thing it was for. **That was our error, not a judgement call**: the issue text named all three
|
||||
sites and the PR wired one.
|
||||
2. **Embedding is not on their roadmap, and it is a single-user need so far.** Fair on both counts.
|
||||
|
||||
**What they said they would want** if that changes, quoted because it is the specification for any
|
||||
future attempt:
|
||||
|
||||
> If I decide to support embedding I'd want to do it in one piece: the `Canvas` extraction plus the
|
||||
> adapter wired into every export path. Your design for the adapter is sound and I'd start from it
|
||||
> then.
|
||||
|
||||
So the adapter design survives — it is validated by the person who would have to maintain it — and the
|
||||
work it needs is known: **the file move plus all three export paths**, not one of them.
|
||||
|
||||
The branch is on the fork at [andrepadez/m3e-canvas `embeddable-canvas`](https://github.com/andrepadez/m3e-canvas/tree/embeddable-canvas),
|
||||
two commits, typecheck and build green. Worth keeping: if this is vendored, that is the diff to apply
|
||||
on top, extended to the other two call sites.
|
||||
|
||||
### What this settles
|
||||
|
||||
**Vendor, do not depend.** The upstream door is open in principle and shut in practice, so the fork is
|
||||
the only route to the integration — which is what the split below was already designed for. Nothing
|
||||
about the file-boundary analysis changes; only the hope of upstreaming the shell does.
|
||||
|
||||
## If it is picked up anyway
|
||||
|
||||
**Open an issue asking upstream to make the seam explicit.** If `page.tsx` grew an entry point taking the
|
||||
persistence layer and the export sink — `<Canvas storage={…} onExport={…} />` — we would **depend rather
|
||||
than fork**, and the 3,600 owned lines shrink to a few hundred.
|
||||
|
||||
**Answered: no, for now.** Recorded above. Asking cost an afternoon and bought a specification for
|
||||
what upstream would accept, plus confirmation that the adapter shape is right — which is a good return
|
||||
on a declined PR.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Is this a design tool that lives in Officer, or the front end of "sketch it and the agent builds
|
||||
it"?** If the second, `page.tsx` is where we deliberately diverge, and adopting the fork is the right
|
||||
frame from day one rather than something discovered in six months.
|
||||
2. **Whose designs are they?** `Doc` is per-user data. Multi-user makes that a permissions question, and
|
||||
the plugin table answers it in a way `localStorage` cannot.
|
||||
3. **Does the phone editor survive the port?** `components/Mobile.tsx` (534 lines) is a separate
|
||||
phone-optimised editor. Officer's mobile story is a native app, so this may be redundant or may be the
|
||||
best part.
|
||||
4. **An iframe was considered and rejected.** It buys a day and costs the function call, the theming, the
|
||||
persistence, and forces back the clipboard round-trip the whole idea exists to remove. Recorded so it
|
||||
is not re-proposed as the cheap option.
|
||||
Reference in New Issue
Block a user