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
10 changed files with 205 additions and 11 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 ## 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)
+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.
+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 * 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');
+21
View File
@@ -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 {
@@ -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>
); );
@@ -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.