Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa5703337 | ||
|
|
06bed42ace | ||
|
|
f4147cef30 | ||
|
|
091cfcca96 | ||
|
|
4a37cfd2ce | ||
|
|
36db9a2ae0 | ||
|
|
78896793aa | ||
|
|
9217d1faaa |
@@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
**109 open, 33 closed**, across 35 themes.
|
**110 open, 33 closed**, across 35 themes.
|
||||||
|
|
||||||
| Theme | Open | Closed |
|
| 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 |
|
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||||
| [Integrations — external tools worth wrapping as a plugin](todo/integrations/) | 1 | 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 |
|
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||||
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
|
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
|
||||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
| [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
|
## 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-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-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` **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` **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` **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-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)
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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/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` |
|
| 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` |
|
| 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 |
|
| 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 | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
||||||
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
||||||
| DELETE | `/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
|
**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,
|
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
|
`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
|
`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
|
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
|
`{watcherArmed: false}` to disarm. Arming still has no route THERE — it needs a live task id, which only
|
||||||
running turn has).
|
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 &&
|
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
|
||||||
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
|
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
|
||||||
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
|
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
|
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.
|
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
|
## 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
|
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.09.09.01",
|
"version": "2026.09.14.01",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -41,9 +41,12 @@ import {
|
|||||||
setCommanderSessionId,
|
setCommanderSessionId,
|
||||||
listCommandHqSessions,
|
listCommandHqSessions,
|
||||||
getCommandHqSession,
|
getCommandHqSession,
|
||||||
|
getCommandHqSessionById,
|
||||||
setCommandHqSessionCategory,
|
setCommandHqSessionCategory,
|
||||||
purgeCommandHqSessions,
|
purgeCommandHqSessions,
|
||||||
deleteCommandHqSession,
|
deleteCommandHqSession,
|
||||||
|
armCommandHqWatcher,
|
||||||
|
recordCommandHqWatcherFired,
|
||||||
disarmCommandHqWatcher,
|
disarmCommandHqWatcher,
|
||||||
getCommandHqCostSummary,
|
getCommandHqCostSummary,
|
||||||
listCommandHqTurns,
|
listCommandHqTurns,
|
||||||
@@ -721,9 +724,9 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
|||||||
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
|
* `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.
|
* 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
|
* `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
|
* `false` is accepted here: arming through THIS route would need a live task id, which only the turn
|
||||||
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
|
* that starts the watcher has. A loop reporting its own task id arms through `POST .../dir/:dirName/arm`
|
||||||
* this route is not it.
|
* below instead — that route exists precisely because this one can't take it.
|
||||||
*/
|
*/
|
||||||
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
@@ -751,6 +754,88 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
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
|
* DELETE /command-hq/commanders/:sessionId
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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) => {
|
router.get('/thumb', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
@@ -212,6 +212,27 @@ export async function rebuildFrontend(): Promise<BuildResult> {
|
|||||||
plugins: [tailwind],
|
plugins: [tailwind],
|
||||||
minify: process.env.NODE_ENV === 'production',
|
minify: process.env.NODE_ENV === 'production',
|
||||||
sourcemap: 'none',
|
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) {
|
if (!result.success) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+11
-2
@@ -52,7 +52,7 @@ import { cardStyle } from '@/components/Card';
|
|||||||
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||||
import type { TaskGroup, TaskSummary } from '../../useTasks';
|
import type { TaskGroup, TaskSummary } from '../../useTasks';
|
||||||
import type { AgentGroup, AgentSummary } from '../../useAgents';
|
import type { AgentGroup, AgentSummary } from '../../useAgents';
|
||||||
import { getFileType } from '../../../FileViewer';
|
import { getFileType, thumbVersion } from '../../../FileViewer';
|
||||||
import { useTranslation } from '../../../../locales';
|
import { useTranslation } from '../../../../locales';
|
||||||
import { useFormat } from 'i18n';
|
import { useFormat } from 'i18n';
|
||||||
|
|
||||||
@@ -670,7 +670,16 @@ export const FileItem = ({
|
|||||||
rounded box would trim content off a photograph for decoration.
|
rounded box would trim content off a photograph for decoration.
|
||||||
*/
|
*/
|
||||||
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
|
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}
|
{node}
|
||||||
</Thumbnail>
|
</Thumbnail>
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-1
@@ -52,6 +52,7 @@ export const Thumbnail = ({
|
|||||||
path,
|
path,
|
||||||
root,
|
root,
|
||||||
size,
|
size,
|
||||||
|
version,
|
||||||
isVideo,
|
isVideo,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -59,6 +60,16 @@ export const Thumbnail = ({
|
|||||||
/** Root-relative, the same path every other file-browser call uses. */
|
/** Root-relative, the same path every other file-browser call uses. */
|
||||||
path: string;
|
path: string;
|
||||||
root?: 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. */
|
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
|
||||||
size: number;
|
size: number;
|
||||||
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
|
/** 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 token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
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 (
|
return (
|
||||||
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
|
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||||
import { getFileType, getThumbUrl } from './file-types';
|
import { getFileType, getThumbUrl, thumbVersion } from './file-types';
|
||||||
import { useFileViewer } from './FileViewerContext';
|
import { useFileViewer } from './FileViewerContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,7 +128,7 @@ export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
>
|
>
|
||||||
{entry.thumb ? (
|
{entry.thumb ? (
|
||||||
<img
|
<img
|
||||||
src={getThumbUrl(path, root)}
|
src={getThumbUrl(path, root, 256, thumbVersion(entry))}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
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
|
* 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.
|
* 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 headers = getHeaders();
|
||||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
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 {
|
export function getAudioTracksUrl(filePath: string, root?: string): string {
|
||||||
const headers = getHeaders();
|
const headers = getHeaders();
|
||||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export { FileViewerProvider } from './FileViewerProvider';
|
|||||||
export { FileViewerHeader } from './FileViewerHeader';
|
export { FileViewerHeader } from './FileViewerHeader';
|
||||||
export { FileViewerBody } from './FileViewerBody';
|
export { FileViewerBody } from './FileViewerBody';
|
||||||
export { useFileViewer } from './FileViewerContext';
|
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:
|
// 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`
|
// 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.
|
||||||
Reference in New Issue
Block a user