Compare commits

..
Author SHA1 Message Date
PastilhasandClaude Opus 5 efa5703337 file-browser: a thumbnail URL says which version of the file it means
`/thumb` answers `immutable` for a year, and the comment above it explained why that was safe: the
cache key holds the file's mtime and size, so a different file is a different URL.

Half of that was true, and it was the half a browser cannot see. The DISK key has always held mtime and
size, so the server regenerated correctly every time — verified against a file renamed into a name that
had been drawn before: the bytes coming back were right. The URL held the path and the box size and
nothing else, so a browser told it could keep the answer for a year did exactly that, and the picture
on screen was whatever had last occupied the name. Upload, rename, replace a photograph: the thumbnail
did not move, and nothing cleared it but somebody who knew to hard-reload.

The listing already carries `modifiedAt` and `size` per entry, so the client had the facts and was not
using them. It now sends `v=<mtime>-<size>`, from one helper shared by the grid, the list and the
viewer's filmstrip — two spellings would be two URLs for one file, which is two renders and two cache
entries for nothing.

Nothing on the server reads `v`. This route stats the file regardless, and a parameter it depended on
would be a second source of truth about which file is meant. It is in the URL to be part of the URL.

No migration and nothing to clear: the stale entries stay in whatever cache holds them, and are never
asked for again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:41:44 +01:00
pastilhasandClaude Opus 5 06bed42ace a plugin's frontend gets the same defines as the platform's own
One line in a vendored plugin file took the whole app down. scripts/build/web.ts
defines process.env.NODE_ENV for the platform bundle; the plugin frontend build
in generate.ts defined nothing, so any process.* in a plugin's web code was
emitted verbatim into a browser chunk and threw "ReferenceError: process is not
defined" at module evaluation. One throwing module fails the whole graph, so the
symptom is a blank platform rather than a broken panel.

Hit on macbookpro after installing m3e-canvas, which carries four files from a
Next.js original reading process.env.NEXT_PUBLIC_BASE_PATH. This machine was
unaffected only because the plugin is on disk but not installed, so it never
entered Plugins.gen.tsx.

Defines process.env.NODE_ENV for parity and process.env to ({}) for the rest,
so every other process.env.X becomes undefined — which is what the `?? ''` in
that vendored code already expects. Longest match wins; verified against bun
build rather than assumed. Fixed in the build rather than in the vendored tree,
because vendored code stays close to upstream here and this protects every
plugin instead of the one that hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-09-22 13:16:52 +01:00
pastilhasandClaude Opus 5 f4147cef30 caldav cannot install radicale on macOS, and retries forever
Confirmed on macbookpro (macOS 26.6.2): four boots, four identical failures.
caldav's manifest declares apt/pacman/dnf and no brew, so the installer falls
back to the binary name — and radicale is not a Homebrew formula at all, it is
a Python app (pipx/uv). There is nothing correct to put in a brew field, which
is the real gap: PackageManager has no pipx member and osDependencies has no
way to say "unavailable on this platform", so the choice is a wrong guess
retried every boot or silence.

Also recorded, as a NON-bug, because it looked like the real failure and was
already over: the motion/react resolve error appeared on exactly one boot, when
m3e-canvas landed before plugins/node_modules/motion existed. Verified on the
machine — motion is installed and import('motion/react') resolves with 398
exports, and the current boot builds cleanly.

The gap that one exposes is still real: installNpmDependencies is called only
from the install flow, never at boot, so a plugin that arrives without that
flow completing leaves every boot failing a rebuild with "Maybe you need to bun
install" and nothing that ever will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-09-22 12:48:32 +01:00
pastilhas 091cfcca96 deploy report 2026.09.14.01 — 4 of 4 targets
alpha, fofos, macbookpro, deadpool-rezio-bridge all at the tag, officer
back online, health answering. officer-dev skipped as the rollout host;
macbruno unreachable; both Pertento hosts on the skip list.
2026-09-14 20:16:51 +01:00
pastilhas 4a37cfd2ce release 2026.09.14.01 2026-09-14 20:11:15 +01:00
pastilhas 36db9a2ae0 command-hq: self-reported arm/fire/disarm for loops that outlive their turn
The existing arm path only fires off the harness's own task:started/
task:notification tracking for the live session that submitted the dialog,
so a loop started detached (setsid/nohup) or as a tracked job that dies
with its turn — which is every real commander on this machine — never
arms in Postgres and never records a fire, even after running correctly
for weeks. Adds POST /commanders/dir/:dirName/{arm,fire,disarm}, a second
door to the same armCommandHqWatcher/recordCommandHqWatcherFired/
disarmCommandHqWatcher functions, authenticated with the commander's own
long-lived OFFICER_API_KEY and keyed on dirName since that's what a loop
already knows about itself.
2026-09-14 19:48:11 +01:00
pastilhas 78896793aa correct the 2026.09.09.01 report: no cron moved those hosts
The agent inferred auto-pull.sh had pulled alpha and fofos ahead of the rollout. It had not. alpha was
rolled by hand by the session that cut the release, before being stopped; fofos was current because it
had been deployed to all day for plugin work.

The wrong reading is kept in the text rather than deleted — a real cron, on the right machine, with the
right effect, and nothing to do with this. That is what a plausible wrong answer looks like.
2026-09-09 23:32:37 +01:00
pastilhasandClaude Opus 5 9217d1faaa deploy report 2026.09.09.01 — 4 of 4 targets
plugin_ports verified by query on every host. alpha and fofos were
already at the tag; macbookpro and deadpool were real fast-forwards
and the only two that needed @anthropic-ai/sdk.

Two plugins did not update, both blocked by uncommitted local work:
av-club on alpha, fofos-website on fofos. Nothing was discarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 23:28:31 +01:00
15 changed files with 474 additions and 18 deletions
+3 -3
View File
@@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**109 open, 33 closed**, across 35 themes.
**110 open, 33 closed**, across 35 themes.
| Theme | Open | Closed |
| --- | --- | --- |
@@ -41,7 +41,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Integrations — external tools worth wrapping as a plugin](todo/integrations/) | 1 | 0 |
| [Known bugs](todo/known-bugs/) | 7 | 4 |
| [Known bugs](todo/known-bugs/) | 8 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
@@ -71,6 +71,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-09-22` **caldav retries `brew install radicale` on every boot, and there is no such formula** — [known-bugs/radicale-cannot-be-installed-on-macos](todo/known-bugs/radicale-cannot-be-installed-on-macos.md)
- `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)
@@ -80,4 +81,3 @@ the owner's OS user and can never be granted. Indirection there really is accide
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
+71
View File
@@ -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.
+70
View File
@@ -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.
+71
View File
@@ -0,0 +1,71 @@
# 2026.09.14.01 — report
**4 of 4 hosts succeeded.** `officer-dev`, `macbruno` and both Pertento hosts skipped, all deliberately.
Backend-only release (`be3139f9..4a37cfd2`): three new self-reported `command-hq` routes. No schema
change, so no `db:push` and no schema query — the plan's two checks (`pm2 jlist`, `/api/health`) are the
whole verification, and both ran on every host.
Every host went `be3139f9` (`deploy/2026.09.09.01`) → `4a37cfd2` (`deploy/2026.09.14.01`), by
`git merge --ff-only` onto the TAG, not `master`. Every checkout path was read from that host's own
`officer` `pm_cwd`, never assumed.
## Per host
| Host | Before | After | Dirty | plugins:update | Restarted | Back? |
| ----------------------- | ---------- | ---------- | ----- | -------------- | --------- | --------------------------- |
| `alpha` | `be3139f9` | `4a37cfd2` | clean | 17/17 | `officer` | online, 0→1, health `401` |
| `fofos` | `be3139f9` | `4a37cfd2` | clean¹| 5/5 | `officer` | online, 1→2, health `401` |
| `macbookpro` | `be3139f9` | `4a37cfd2` | clean | none installed | `officer` | online, 1→2, health `401` (:9010) |
| `deadpool-rezio-bridge` | `be3139f9` | `4a37cfd2` | clean | 3/3 | `officer` | online, 0→1, health `401` |
¹ `fofos` carries two UNTRACKED files — `flip-records.ts` and `scripts/chk.ts`. Untracked is a note, not
a skip: a fast-forward cannot touch them and nobody's work was discarded.
All four checkouts were clean of TRACKED changes, so no host was skipped for a dirty tree and every merge
was a genuine fast-forward.
`401` is healthy here — `/api/health` is authenticated, and the plan names `200` or `401` as pass. The
port was read from each host's own `.env` rather than assumed: `9010` on `macbookpro`, `9000` elsewhere.
## Steps that ran
Per host, in order: tracked-only dirty check → `git fetch origin --tags` → `git merge --ff-only
deploy/2026.09.14.01` → `bun run plugins:update` → `pm2 restart officer` → `pm2 jlist` → health.
No `bun install` and no `bun db:push` on any host. The plan says neither is needed; running either would
not have been a finding, but it was not needed and was not done.
`plugins:update` restarts sidecars itself. On `alpha` that is more processes moving than the one line
above suggests — the table's "restarted" column is what the PLAN asked for, not the full count of what
bounced.
## Skipped — none of these is a failure
- **`officer-dev`** — the host this rollout ran on. Skipped entirely per the standard rule: not pulled,
nothing restarted. **It is still on `be3139f9` and the owner restarts it deliberately.**
- **`macbruno`** — `ssh` timed out on port 22 (`Connection timed out`, 8s). The plan names it as
check-live, unreachable-is-a-skip. Not attempted further; it cost one probe, not a retry budget.
- **`production-pertento-bridge`, `edge-pertento-bridge`** — on the deliberately-skipped list in
`docs/servers.md` since 2026-09-08. Owner's call. Nothing spent on either.
## What the owner must act on
- **`officer-dev` has not been deployed.** It is the one host still on the previous release. This is the
designed outcome, not an oversight — restarting `officer` here would have killed the rollout mid-flight.
- **`macbruno` is behind by one release** and unreachable as of this rollout. It will need the same
four-step sequence whenever it is back.
- Nothing else. No host failed, no process came back `errored`, no step was refused.
## Carried, not introduced
`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). Named in the plan, present before
this release, type-only. Not checked again here and not caused by this rollout.
## About this run
First release under a new watcher. The rollout procedure is proven — this is the tenth report — but this
loop had never executed it before, so every host was narrated before it was touched. The claim was taken
by the watcher that spawned this agent; this agent did not re-claim, and recorded `done` only after the
fourth host came back healthy.
+39 -3
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.09.09.01",
"version": "2026.09.14.01",
"private": true,
"type": "module",
"workspaces": [
+88 -3
View File
@@ -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
*
+10
View File
@@ -433,6 +433,16 @@ router.post('/rename', async (ctx) => {
*
* The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a
* different file is a different URL. That is what makes a year-long cache correct rather than a bet.
*
* It was only ever half true, and the half that was missing is the half a browser can see. The DISK key
* has always held mtime and size, so the server regenerated correctly every time. The URL held the path
* and the box size and nothing else — so a browser told to keep the answer for a year kept it for a
* year, and a file replaced or renamed into a name already drawn showed the picture that used to be
* there. Nothing cleared it but somebody who knew to hard-reload.
*
* The client now sends `v=<mtime>-<size>` — see `getThumbUrl`. Nothing here reads it: this route stats
* the file anyway, and a parameter the server depended on would be a second source of truth about which
* file is meant. It is in the URL to be part of the URL, which is the whole job.
*/
router.get('/thumb', async (ctx) => {
const user = ctx.get('user');
+21
View File
@@ -212,6 +212,27 @@ export async function rebuildFrontend(): Promise<BuildResult> {
plugins: [tailwind],
minify: process.env.NODE_ENV === 'production',
sourcemap: 'none',
/*
WITHOUT THIS A PLUGIN CAN TAKE THE WHOLE APP DOWN WITH ONE LINE.
`scripts/build/web.ts` — the platform's own bundle — defines `process.env.NODE_ENV`. This build
defined nothing, so any `process.*` in a plugin's frontend was emitted verbatim into a browser
chunk and threw `ReferenceError: process is not defined` at module evaluation. One throwing module
fails the whole graph, so the symptom is not a broken panel, it is a blank platform.
Reached by vendored code rather than by anyone writing Node in a component: m3e-canvas carries four
files from a Next.js original that read `process.env.NEXT_PUBLIC_BASE_PATH`. Vendored trees stay
close to upstream here (see the hls.js note in CLAUDE.md), so the build is the right place to
absorb it — and it protects every plugin rather than the one that happened to hit it.
Both keys, and the order does not matter: the longest match wins, so `NODE_ENV` keeps its real
value and every other `process.env.X` becomes `({}).X`, which is `undefined` — exactly what the
`?? ''` in that vendored code expects. Verified against `bun build` rather than assumed.
*/
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV === 'production' ? 'production' : 'development'),
'process.env': '({})',
},
});
if (!result.success) {
return {
@@ -52,7 +52,7 @@ import { cardStyle } from '@/components/Card';
import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskGroup, TaskSummary } from '../../useTasks';
import type { AgentGroup, AgentSummary } from '../../useAgents';
import { getFileType } from '../../../FileViewer';
import { getFileType, thumbVersion } from '../../../FileViewer';
import { useTranslation } from '../../../../locales';
import { useFormat } from 'i18n';
@@ -670,7 +670,16 @@ export const FileItem = ({
rounded box would trim content off a photograph for decoration.
*/
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
<Thumbnail path={path} root={root} size={size} isVideo={kind === 'video'} className={className}>
<Thumbnail
path={path}
root={root}
size={size}
// Which version of the file, so replacing or renaming one does not leave the old picture in a
// browser cache that was told it could keep it for a year. See `Thumbnail`.
version={thumbVersion(entry)}
isVideo={kind === 'video'}
className={className}
>
{node}
</Thumbnail>
);
@@ -52,6 +52,7 @@ export const Thumbnail = ({
path,
root,
size,
version,
isVideo,
className,
children,
@@ -59,6 +60,16 @@ export const Thumbnail = ({
/** Root-relative, the same path every other file-browser call uses. */
path: string;
root?: string;
/**
* Which VERSION of the file this is — `mtime-size`, from the listing.
*
* The response is `immutable` for a year, and the URL used to carry only the path, so a browser that
* had drawn a thumbnail for a name went on drawing it however the file behind that name changed. A
* rename into a name already seen showed the picture that used to be there, and nothing short of a
* hard reload cleared it. The SERVER was never wrong — its own cache key has always held mtime and
* size — the URL simply did not say which file it meant. See `getThumbUrl`.
*/
version?: string;
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
size: number;
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
@@ -79,7 +90,8 @@ export const Thumbnail = ({
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
return (
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { getFileType, getThumbUrl } from './file-types';
import { getFileType, getThumbUrl, thumbVersion } from './file-types';
import { useFileViewer } from './FileViewerContext';
/**
@@ -128,7 +128,7 @@ export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
>
{entry.thumb ? (
<img
src={getThumbUrl(path, root)}
src={getThumbUrl(path, root, 256, thumbVersion(entry))}
alt=""
loading="lazy"
decoding="async"
@@ -165,14 +165,31 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
*
* Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a
* format that needs a plugin to decode works here exactly where it works there.
*
* `version` is what makes the year-long `immutable` on that response honest. The SERVER's cache key has
* always held the file's mtime and size; this URL held only the path, so a browser that had once drawn
* a thumbnail for a name kept drawing it for a year however the file behind that name changed. Upload,
* rename something else into the name, replace a photograph — the picture on screen stayed. Pass
* `mtime-size` and a changed file is simply a different URL. The server ignores the parameter; it stats
* the file regardless.
*/
export function getThumbUrl(filePath: string, root?: string, size = 256): string {
export function getThumbUrl(filePath: string, root?: string, size = 256, version?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
}
/**
* What identifies this VERSION of a file, for a cache key — the same two facts the server hashes.
*
* One place, because the grid and the carousel strip must agree: two spellings would mean two URLs for
* one file, which is two renders and two cache entries for no reason.
*/
export const thumbVersion = (entry: { modifiedAt?: number; size?: number }): string | undefined =>
Number.isFinite(entry.modifiedAt) && Number.isFinite(entry.size) ? `${entry.modifiedAt}-${entry.size}` : undefined;
export function getAudioTracksUrl(filePath: string, root?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
@@ -4,7 +4,7 @@ export { FileViewerProvider } from './FileViewerProvider';
export { FileViewerHeader } from './FileViewerHeader';
export { FileViewerBody } from './FileViewerBody';
export { useFileViewer } from './FileViewerContext';
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
export { getFileType, getLang, getExt, getArchiveBaseName, thumbVersion, ARCHIVE_EXTS, type FileType } from './file-types';
// No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard:
// it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header`
@@ -0,0 +1,54 @@
---
id: known-bugs/radicale-cannot-be-installed-on-macos
title: 'caldav retries `brew install radicale` on every boot, and there is no such formula'
theme: known-bugs
status: open
created: 2026-09-22
date_source: stated
order: 8
---
**On macOS, caldav fails its own dependency install on every single boot**, logging a line that reads like
a transient error and is permanent:
```
[caldav] radicale install failed (exit 1): … Warning: No available formula with the name "radicale".
```
Confirmed on `macbookpro` (macOS 26.6.2) 2026-09-22 — four consecutive boots, four identical failures.
**Two things are wrong and only one is caldav's.** `plugins/caldav/manifest.ts` declares
`packages: { apt, pacman, dnf }` with **no `brew` entry**, so the installer falls back to the binary name
and runs `brew install radicale`. And the fallback cannot succeed anyway: **radicale is not a Homebrew
formula.** It is a Python application, installed with `pipx install radicale` or `uv tool install radicale`.
Homebrew's suggestion in the log — "Did you mean radicle or cadical?" — is two unrelated projects.
So the manifest has nothing correct to put in a `brew` field, which is the actual gap:
`PackageManager` is `'apt' | 'pacman' | 'dnf' | 'brew'` (`plugins/manifest.ts`). There is no way to express
"this comes from pipx", and no way to say "not available on this platform" — so the only outcomes are a
wrong guess retried forever, or silence.
Consequences, in order of how much they matter: caldav does not work on that machine at all (no calendars,
no contacts — the manifest's own `reason` says so); the failure repeats every boot with no backoff and no
memory; and the log line is indistinguishable from something worth chasing, which is how it cost a
debugging session that had been opened about something else entirely.
Worth deciding rather than patching: either `PackageManager` grows a `pipx`/`uv` member, or
`osDependencies` grows a way to declare a platform where a binary is genuinely unavailable, so the
installer can say it once and stop.
---
**Found alongside, and NOT a bug — recorded so the next person does not chase it.** The same log carried
`[plugins] frontend rebuild failed: ResolveMessage: Could not resolve: "motion/react"`, which looked like
the real failure and was already over. It appeared on exactly one boot: the m3e-canvas plugin had landed on
disk before `plugins/node_modules/motion` existed. Verified on the machine afterwards — `motion` is listed
in the generated `plugins/package.json`, installed, and `import('motion/react')` resolves with 398 exports;
the current boot rebuilds cleanly.
**The gap it exposes is real even though it healed.** `installNpmDependencies` is called from exactly one
place, `plugins/install.ts`, the install flow. Boot (`plugins/mount.ts`) rebuilds the plugin frontend and
never installs anything. So a plugin that reaches `plugins/` without that flow completing — cloned by hand,
or an install interrupted — leaves every subsequent boot failing the rebuild with a message that says
_"Maybe you need to bun install"_ while nothing ever will. Here the install finished and the next boot was
fine; on a machine where it does not, the platform cannot recover on its own.