4ecd45c4a9c27f97062187bf2578147f0f2e7681
1447
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4ecd45c4a9 |
themes get their own control, and the vimazing keys
Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where a preference goes when nobody has decided it matters. It now has its own button in the header, between the controls and the avatar, grouped by family. ── The keys, ported from the owner's vimazing ── ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI ctrl+alt+space open the list j / k move the focus l or space apply the focused theme to the app BEHIND the list h back to where you started enter keep what you are looking at escape restore and close The browse is the point, and it is why the overlay is a portal over a 55% backdrop rather than a modal: you are not reading names, you are watching the application change. The theme you started on is captured when the list opens, so `h` and `escape` undo a whole browse however many previews happened inside it. Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea, select or anything contenteditable. ── Where it lives ── The key handling is in the theme package — it is behaviour that belongs to themes, and it needs no UI. The list and the button are Officer's, because a dialog would drag a component library into a package that currently has none. Verified by driving the real keyboard in a browser: start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay j, l -> tokyo-night (previewed) · escape -> plain, closed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba0c8600b8 |
chat: render images in a turn, and stop mangling the ones that were already links
Remote images were broken in the web chat and the failure looked like a network fault. It was a rewrite. injectImages scans a turn for absolute file paths ending in an image extension and points them at the file browser — good behaviour, since an agent that writes /tmp/shot.png should show it rather than make you go and open it. But 'https://host/images/duck.png' CONTAINS '/images/duck.png', so a perfectly good markdown image became  scheme stripped, host read as a directory. Every remote image failed. injectImages now skips regions that are already links — !?[..](..) alongside the code fences it already skipped — and refuses any match preceded by ':' or '/', or starting with '//', since those are the tail or the whole of a URL somebody wrote deliberately. Moved to chat-images.ts with tests, because a regex with three exceptions is exactly the thing that regresses silently. Rendering added with it: markdown images now go through ChatImage, capped at 14rem HIGH — not wide, so a wide screenshot and a tall phone capture take comparable room — and open full size in a dialog on click. A failure names the alt text and the URL, because the useful question is always which host did not answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8fa3f7da3e |
hex everywhere, because the palettes were half broken
Reported as "the ellipsis doesn't show in the streaming bubble". It was the visible corner of something much larger: every Tailwind semantic utility was broken under all eleven ported palettes. `@theme` mapped `--color-muted-foreground: hsl(var(--muted-foreground))`, which needs an unwrapped triplet. A ported palette publishes `#6e6a86`, so the mapping produced `hsl(#6e6a86)` — invalid — and the utility fell back to white or transparent: duck-dark text-muted-foreground=rgb(148,163,184) bg-muted=rgb(30,41,59) rose-pine text-muted-foreground=rgb(255,255,255) bg-muted=rgba(0,0,0,0) The themes looked convincing because panels, the page and the header read their variables DIRECTLY rather than through a utility class. Everything that went through Tailwind was quietly wrong, and the thinking dots — `bg-muted-foreground/60` — were simply invisible. So: 123 HSL triplets converted to hex across the three hand-written themes, and 37 `hsl(var(--x))` mappings unwrapped. One format everywhere, which is also the format every published palette ships in. The triplets only ever existed so `bg-card/50` could get opacity. That was tested earlier tonight and turned out unnecessary — Tailwind v4 emits `color-mix(in oklab, var(--card) 50%, transparent)` for any colour format. Having proved it, I then left the mappings wrapped, which is what broke the palettes. Verified across six themes: `bg-muted` and `bg-muted-foreground/60` resolve in every one of them, and Duck's values are unchanged through the conversion — `bg-muted` is `rgb(30,41,59)` before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
94a55fe679 |
panel chrome stops being black
Reported against a dark theme: the panel header's title, zoom readout and controls were hardcoded `text-black`, `text-black/60`, `hover:bg-black/10` and `border-black/10`. Legible on Duck's pale glass, invisible on every palette theme — which is the whole class of bug the theme work exists to remove, sitting in the one component I moved between packages and did not re-read. Three derived tokens, so no theme has to think about them: --panel-header-fg-dim the header's ink at 60% (the zoom readout) --panel-header-border the header's ink at 15% (the rule under it) --panel-control-hover the header's ink at 12% (control hover) The swap, drop-target and app-picker chrome used `duck-teal` and `text-white` directly. Those now use `--panel-accent` and a new `--panel-accent-fg`, so a chip on the accent stays readable whatever the accent is. Verified across five themes rather than by reading: duck-dark ink=rgb(248,250,252) duck-light ink=rgb(2,8,23) rose-pine ink=rgb(224,222,244) gruvbox ink=rgb(235,219,178) catppuccin-latte ink=rgb(76,79,105) Two comments corrected while there: both asserted the chrome "is black throughout", which was true when written and is now the opposite of true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fd78495b5a |
every theme declares the mark
The logo is now stated by each of the fourteen themes rather than supplied by a default none of them mentions. The base default stays empty, which is the right answer for a theme written by a stranger — showing nothing beats showing someone else's brand. Explicit costs one line and buys two things: a theme that ships a brand says so, and a white-labelled build replaces a line it can see instead of hunting for the default that was quietly doing the work. Verified in the browser across four themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b106f67288 |
twenty tokens is the contract; eleven palettes prove it
Officer now ships fourteen themes. Eleven of them were written by other people, ported unchanged, and none of them knows that panels, docks, glass, grids or scrollbars exist. ── What changed ── `base.css` derives every surface from the standard tokens: a panel is `card` at 92%, the header is `card` at 85%, grid lines are `border` faded, scrollbars are `muted-foreground`. Those defaults sit on `:root`; a theme sits on `[data-theme='x']` and both land on <html>, so a theme's own declaration simply outranks them. No machinery. State tones are defaulted too — shadcn's vocabulary has `destructive` and nothing else, and green-means-good is a convention rather than a palette decision. That is what drops the required contract from 29 to 20: exactly the tokens every published shadcn palette already ships. So there are now three tiers, and an author picks one: palette 20 tokens Tokyo Night, Gruvbox, Nord — surfaces derived textured + a few surfaces glass, blur, radius, a wallpaper of its own white-label + assets its own logo and background Duck is tier three and is untouched — verified by probe: it still reports `panel=#ffffff1f`, `blur=24px` and its own grid, because its declarations outrank the new defaults. ── Verified before committing, not assumed ── The whole HSL-triplet design existed to make `bg-card/50` work. Tested it: Tailwind v4 emits `color-mix(in oklab, var(--card) 50%, transparent)` for a HEX token, with a `@supports` fallback. The unwrapped triplets were never necessary, and the ported palettes are plain hex. Six themes probed in the browser: each derives panels from its own card colour, including the light one (Catppuccin Latte). ── Credit ── The eleven palettes and the idea of a small standard contract come from the owner's `vimazing` repository, which solved this more simply than we had. What Officer adds is the surface layer underneath, so a palette gets a textured app for free. ── One open decision ── `--logo-src` defaults to empty, so a palette theme shows no mark at all. Correct for a third-party theme; probably wrong for Officer, where the alternative is defaulting to the officer.dev logo and letting a white-label theme replace it. One line either way. tsgo clean, suite unchanged, builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1dfadef506 |
stop plugin clones being committable, and undo the symlinks that were
Merging the workspaces branch replaced `plugins/{example,music,offscale}` — real git
clones — with symlinks pointing at themselves. The build then failed with
`SymLinkLoop`, which is a confusing way to be told your plugins are gone.
My fault, in a way worth writing down. I symlinked the plugins into a git worktree so
it could build against them, then committed with `git add -A`. The links were untracked
rather than ignored, so they went in. On master, checking out a symlink where a
directory stands removes the directory — and since the clone contents were untracked,
git did it without complaint.
Recovered by re-cloning all three from gitea. Nothing was lost that was not already
pushed there; uncommitted work inside those clones would have been, which is the part
that could have been expensive.
`plugins/*` is now gitignored with `!plugins/*.md`, so the convention that plugin
clones are not this repository's content is enforced rather than remembered. A worktree
symlink cannot be committed again.
Verified after recovery: tsgo clean, frontend rebuilds, the live app serves, and
`/api/plugins` answers 200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
571deac731 |
one react-resizable-panels, and honest metadata for the peers
Two dependency problems, and only one of them was real. ── Real: two majors of the same library ── The root asked for `react-resizable-panels@^4.0.15` (4.6.5) while `src/workspaces/components` asked for `^2.1.7` (2.1.9). The workspace code targets 2.x, whose API differs — v4 has no `PanelGroup` export, no `order` prop and no `ref` on `Panel` — so a file that resolved the root's copy failed to typecheck in a way that read like a broken extraction. Nothing imported the root's version. It was vestigial, and removing it collapses the repository to one: every importer now resolves 2.1.9 and the lockfile has zero v4 entries. ── Not real: the second React ── Declaring react/react-dom in the package created `src/workspaces/workspaces/node_modules/react`, which looks exactly like the duplicate-instance bug that silently breaks hooks. It is not one. Both locations resolve to the SAME file in bun's store — the local entry is a symlink — which I established by resolving the specifier from each place instead of reading directory names, having already reported it as a hazard on the strength of an `ls`. Declared as optional peers regardless: that is the right metadata for a consumer, and it stops bun installing peers it does not need to. Notes corrected to match, since the wrong version of that story was written down. tsgo clean, package 114/114, full suite 950 pass / 7 fail unchanged, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4c97e69ecd |
extract the workspace framework into its own package
Phase 1: the Workspace/Panel system is `src/workspaces/workspaces`, and it no longer
knows Officer exists.
The proof is its test suite. 114 tests mount `WorkspaceView` under a BARE React tree —
no react-query provider, no router, no Officer hook — which is only possible because
the host now supplies everything: a registry mapping `appType` to a component, and a
layout with a way to change it.
Officer is untouched. All 24 `WorkspaceView` sites and 8 `WorkspaceLayout` sites call
exactly what they called before; `Workspace/index.ts` became an adapter that adds back
the three things that are genuinely ours — the app registry, fullscreen persistence in
sessionStorage, and the handshake that stands the nav header down for a fullscreen
panel.
── The shape, and why it is this shape ──
`core/` is the tree and every operation on it: pure, no dependencies, no React. `react/`
is one renderer. That split is phase 2's whole cost saving — a second renderer reuses
`splitPanel`, `movePanel`, `normalizeLayout` and the rest untouched, and what will not
port is the resize INTERACTION rather than the layout engine.
It was already half done: `layout-utils.ts` imported only `./types`, and `WorkspaceView`
already received `{ key, value, setValue, isLoaded }` as a prop and always wrote through
an updater. The real coupling was the registry, fullscreen, and four UI imports.
Also deleted ~175 lines of dead code — `DragOverlay`, `LayoutEditor`, `WorkspaceHeader`
— referenced by nothing.
── Two dependency traps ──
**A second React.** Declaring react/react-dom in the package produced a second copy
under its own node_modules. Two React instances break hooks at runtime and are invisible
until something renders. The package now declares almost nothing while it lives here.
**Two versions of react-resizable-panels.** The root asks ^4.0.15 (4.6.5),
`src/workspaces/components` asks ^2.1.7 (2.1.9), and this code targets 2.x — v4 has no
`PanelGroup`, no `order`, no `ref` on Panel. Inheriting the root resolution produced a
screenful of type errors that read like a broken extraction and were a version
mismatch. Pinned; the duplication itself is worth fixing one day.
── Verified ──
tsgo clean. Package suite 114/114. Full suite 950 pass / 7 fail, unchanged. Frontend
builds, and the running app was screenshotted against the pre-extraction baseline: 9 of
16 byte-identical, the rest live data and the rotating wallpaper. Panel chrome, headers,
splitters and the grid all render as before.
Docs written as part of the work: the package README is the contract, and
WORKSPACES-NOTES.md carries the open items — panel-to-panel channels, the theme
classes the package still assumes, and phase 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f977d26fef |
a ratchet on hardcoded colour, and the docs to match
The three previous attempts at this job died of endlessness, not difficulty. Nothing stopped the next component arriving with its own `text-emerald-400` while an old one was being fixed, so the number never fell for long. `bun run check:theme` counts palette classes per directory against a committed baseline. It does not care what the number is, only which way it moves. 942 across 21 directories today. Every directory can be paid down independently and none can quietly regrow — which turns an infinite job into a finite one. A baseline rather than a ban, deliberately: a rule forbidding palette classes outright fails on its first run, and a check nobody can make pass is a check everybody turns off. Proven by adding one `text-emerald-400` and watching it fail with the directory, the delta, and what to use instead. ── Documentation, which is part of the work rather than after it ── `src/workspaces/theme/README.md` is new and is the contract: how to write a theme, all 84 declarations by family, why colours are unwrapped HSL triplets while surfaces are complete CSS values, why assets are quoted paths, and the rules for components. Start from Plain, not from Duck — every value you change is then one you decided about. `plugins/EXTRACTING-A-PLUGIN.md` gains a rule beside the others: a plugin never hardcodes a colour, and pays its debt down DURING the extraction. The counts are named there — Soulseek 313, Invoices 95, Wallet 68 — because "some colours" does not convey that it is two thirds of the platform's total. The kickoff prompt says the same thing in the form a fresh agent will meet it. That is what makes the plugin exemption safe rather than a way of losing the work: the ratchet stops covering a directory the moment it leaves, so the rule has to travel with it. `THEMES-NOTES.md` rewritten to match reality — it still listed done things as open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df92a985d2 |
build failures say what went wrong
Every failed frontend build has reported `Bundle failed` and nothing else, for as long as anyone remembers. The cause is one line that was never there: `Bun.build` THROWS on failure by default. It throws an `AggregateError` whose own message is the string "Bundle failed" and whose `errors` array holds the real diagnostics. So the carefully written `result.success` branch — with its comment about what a failed build must not do — was unreachable, and the catch dutifully reported `err.message`. `throw: false` makes the returned result carry `logs`, which is what the code below it always assumed. The catch keeps a fallback that unpacks `AggregateError.errors` anyway: the build should no longer throw, and the failure mode this hides is exactly the one that has been wasting people's evenings. Truncation went 500 -> 1000 characters. A single bundler diagnostic with a file, a line and a code frame routinely exceeds 500, and cutting mid-path leaves it unactionable. Proven by breaking the app on purpose: before: Bundle failed after: ResolveMessage: Could not resolve: "./this-module-does-not-exist" ── The first attempt at that proof was worthless ── An UNUSED import reported `ok: true`, which briefly looked like a much worse bug — a broken app building clean. It is not: an unused import is elided by the TypeScript transform before anything tries to resolve it, so there was no error to report. The break only becomes a break once the import is used. Worth recording because "I broke it and the build passed" is an alarming sentence to arrive at by accident. Also verified that a failed build leaves `build/` alone — the staging swap does what its comment claims, so a bad build cannot take the running app down with it. This one shows up in the UI too: the Rebuild button in Settings -> System already displays `error`, so it stops saying "Rebuild failed" and starts naming the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
997f7e7a87 |
the account stores a theme, not a colour mode
Your theme now follows the account instead of living in one browser's localStorage.
Choosing Plain on a laptop reaches the phone.
`appearance.colorMode` is superseded by `appearance.theme`. Along the way this
resolves a mismatch that had been sitting there: the server's default was
`appearance: { theme: 'light' }` while the client read `appearance.colorMode`, so that
default had never once been read by anything. Both sides say `theme` now.
The Light/Dark item is gone from the user menu. Light and dark are two themes, both
already in the list above it, and that toggle was the last writer of the legacy key.
── Two bugs found by testing this, both mine, both silent ──
**A default that looked like a choice.** `useSettings` returns `DEFAULT_SETTINGS`
until the request lands — `settings` is never null, it is a complete valid object. So
"wait for settings" fired on the first render against the defaults, marked itself
done, applied the default theme and never saw the real one. Then the persist side
wrote that default over the account's actual preference. It overwrote the owner's own
`dark` while I was testing it; restored by hand from the `colorMode` it had not yet
destroyed. `useSettings` now exposes `settingsLoaded` so the difference is expressible.
**Defaults masking a migration.** With that fixed, an account holding only
`colorMode: 'dark'` STILL came up light. The client merges `DEFAULT_SETTINGS` over
whatever arrives, so a missing `theme` is filled in with the default before any
consumer sees it — and a default is indistinguishable from a choice. The migration
therefore belongs on the SERVER, which sees the stored object before anything is
merged over it. It is read-only: a GET that rewrites the row on every call is a
surprise, and the client persists on its next change anyway.
Verified by putting the account into the pre-themes shape and asking the new handler
directly, rather than by restarting to find out:
stored: {"colorMode":"dark"} -> returned: {"colorMode":"dark","theme":"duck-dark"}
── Needs a restart ──
The first server-side change in this work. Everything until now was frontend and a
rebuild was enough.
950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ecb6864ff9 |
delete the landing app
Built and shipped from somewhere else now, so this copy was dead weight that still carried its own third copy of the palette — `duck-teal` had three different definitions across three apps, and this was one of them. Removed: `src/apps/landing`, `scripts/build/landing.ts`, the `build:landing` script, and the tree entry in CLAUDE.md. `/landing-page-data` STAYS and is unrelated despite the name — it is the API the sign-in screen asks whether registration is open. Worth saying because a grep for "landing" hits both and only one of them was meant. Nothing imported from the app. tsgo clean, frontend builds, the live app serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
12f1e93c4a |
panels are a themed surface, not three literal rgba pairs
The cornerstone of the layout was the least themeable thing in it. Measured before touching anything, which is the only reason this is the right fix: duck-dark radius=8px bg=rgba(255,255,255,0.12) border=rgba(0,0,0,0.25) blur=24px plain radius=0px bg=rgba(255,255,255,0.12) border=rgba(0,0,0,0.25) blur=24px So the RADIUS was already following `--radius` and I had been telling the owner otherwise — the rounding in the Plain screenshot was something else. What did not follow anything was the surface: three literal rgba pairs and a fixed `backdrop-blur-xl` in the component. Now: plain radius=0px bg=rgb(255,255,255) border=rgb(0,0,0) blur=0px Two distinctions the old code drew in literals are kept as tokens rather than flattened, because both are real and a theme should be able to revisit them. `--panel-raised-*` is the fullscreen surface: DENSER glass, not darker, because panel chrome is dark text and a heavy dark fill hid every icon. `--panel-border-bare` is for a panel with no header, which has nothing else separating it from what it sits on. `--panel-blur` earns its place beyond theming: a flat theme was paying for a 24px backdrop blur it did not want, and on a solid panel the compositor does that work for nothing. ── A near miss worth recording ── The first attempt silently changed nothing. The patch matched on four spaces of indentation where the file has two, `str.replace` reported no error, the build succeeded, and `panel-blur` DID apply — so the class landed while the surface did not. It looked like a partial success and would have shipped as one. Caught by measuring computed styles again rather than trusting the edit; the fix now asserts the replacement changed the file. 950 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dfb8a6d6cb |
a sixth tone, and no raw palette left in the core apps
The five core apps — FileBrowser, FileViewer, SystemMonitor, Chat, Plugins — now contain ZERO raw Tailwind palette classes. Every colour in them names a state or a surface, so all of it follows the theme. ── Busy ── The 38 ambers were the blocker, and the answer was not to pick between two wrong mappings. They mean RUNNING — Chat's own comment says "the amber a RUNNING task chip wears" — and the vocabulary had no word for it. `warning` would have made a working job look like a failing one, and `--status-in-progress` is blue. So `tone.ts` has six tones instead of five. It was always written as a closed set that could grow when a genuine sixth state appeared; this is that. `--busy-ink` comes with it, and generalises: a fill bright enough to carry black text is far too light to BE text. The other five tones are dark enough to do both jobs, which is why the problem had not surfaced. `text-amber-950` — ink ON the fill — became `text-busy-foreground`, which is what it always was. ── On fidelity, which stopped being the constraint ── Duck's exact colours were preserved up to this point because an extraction has to be provably invisible. The owner has since said the palette is not precious: the wallpaper, the graph-paper grid and the mark are what matter, and everything else can move. So shades collapse — one busy, one red, one green — rather than being approximated to keep old pixels. The verification built for the old constraint keeps its value: it proves the token layer resolves, which is a different claim from "the colours did not change". Verified: the utilities compile (`text-busy-ink`, `bg-busy`, `border-busy`, `text-busy-foreground`) and resolve in the browser. 950 pass / 7 fail unchanged. tsgo clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a52d3e06f3 |
one red and one green, in the core apps
82 raw palette classes in FileBrowser, FileViewer, SystemMonitor, Chat and Plugins now name a state instead of a colour. `docs/design-language-interface.md` has said to do this for a long time and `tone.ts` implements it; eight files were following it. Only the families whose semantic token IS the colour already in use: destructive #ef4444 == text-red-500 exact success #16a34a == text-green-600 exact So `red-500` and `green-600` are byte-identical renames, and the neighbouring shades (`red-400`, `red-600`, `green-500`, `emerald-500`) shift by a little on purpose — having one red is the point. Verified on screen, not just typechecked. Three greys became `muted-foreground`, which is what "quieter than the text" means here. ── Amber is deliberately untouched, and needs a decision ── 38 amber classes remain and a regex must not have them. They are not warnings. Chat's own comment says it plainly: "the amber a RUNNING task chip wears". It is a BUSY tone, and the token set has no such thing — `--status-in-progress` exists and is blue. Mapping them to `warning` would change both meaning and colour: Duck's `--warning` is #fbbd23 (amber-400) while the code uses amber-600 #d97706 as text and amber-950 as ink on a fill. Text at that lightness on white is not readable, and "running" would start looking like "something is wrong". Two ways out, both the owner's call: add a sixth tone for busy/queued, or accept warning's colour and meaning for it. ── Deliberately out of scope ── The plugin candidates — Soulseek 308, Invoices 95, Wallet 68, Jellyfin 48, Photos 47, Transmission 41 — are two thirds of the debt and are leaving this repository. Their colours belong to their extraction, when the rules exist and their imports are being rewritten anyway. White and black are also untouched: they are media overlays in the file viewer and photo surfaces, correct in every theme because they sit on someone's photograph rather than on a themed surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a0013e4f28 |
note that theme asset packaging is deferred, not solved
A theme references its wallpaper and logo by path because the CSS bundler cannot resolve a runtime `public/` file at build time. That is fine for themes we ship and says nothing about where a third party's assets live. The owner's intent is a convention under `public/` — recorded so the workaround is not mistaken for a design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e9cb244818 |
the wallpaper, the header and the mark belong to the theme
Asked whether these could follow the theme too. They are the three most visible things
on screen and were the three least reachable: `Background.tsx` hardcoded
`url('/landscape1.webp')`, the header carried `rgba(255,255,255,0.25)` and `#1d2724`
in an inline style, and the logo was an `<img src>`.
Between them that is most of what "the app looks like Officer" actually meant. The
Plain theme turned every panel white and all three stayed exactly as they were, which
is how they were found — the leak detector doing its job on its first outing.
Verified across all three themes:
duck-dark wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
duck-light wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
plain wallpaper=none header=rgb(255,255,255)
── Images could not be done the obvious way ──
`--logo-image: url('/officer-logo.svg')` fails the build. Bun's CSS bundler resolves
`url()` at BUILD time and these files are served from `public/` at RUNTIME, so it goes
looking for a module that does not exist. Colours apply themselves; images cannot.
So a theme publishes a quoted PATH — an ordinary string token the bundler ignores —
and `useThemeAsset` reads it and wraps it in `url()`. One hook rather than the read
inline in two components, because the quote-stripping is the kind of detail that gets
forgotten the second time.
The logo is a `<div role="img">` with a background image rather than an `<img>`, since
CSS cannot set a `src` and a white-labelled build has to replace the mark from its
theme rather than by editing the header.
The header's title colour goes through a `.header-ink` class: `EditableTitle` accepts
a className and not a style, and the token stays the source of truth either way.
Panel radius and the ~985 raw palette usages are still outstanding — see
THEMES-NOTES.md.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1be4bcd39a |
prove the extraction faithful, and fix the one thing that was not
The screenshots said seven dark screens differed and could not say why. Comparing COMPUTED theme variables between the old build and the new one, in the same colour mode, answered it in a single run: 47 of 53 matched exactly, and the six that did not were the `--duck-*` variables — EMPTY in the new build. The utilities were remapped to `--brand-primary`, `--ink` and friends, which is correct. What that missed is code reading the VARIABLE rather than using the utility. `prose.css` does exactly that eleven times — chat markdown links, blockquotes, inline code, checkboxes — and `landing/styles.css` does it six more. All silently resolved to nothing. Fixed by keeping the legacy names as aliases in every theme, pointing at the new source of truth rather than duplicating values. That also covers any plugin or inline style doing the same thing, which I have not enumerated. They retire when the utility rename lands. Every variable now matches. ── What the screenshots are actually worth here ── Less than I assumed. The wallpaper rotates per page load, so a control capture of the same build against itself had 4 of 16 screens differ — and a different 4 each time. Between 5 and 6 come out byte-identical to the baseline on any run; the rest is noise I mistook for signal for two rounds, including one where I removed a `color-scheme` line to test a theory it turned out not to explain. The variable diff is the instrument that settles it, and it is cheap. Added as `scripts/theme-vardiff.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
105766a4eb |
extract the visual layer into its own workspace
The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.
Proven both directions, because either alone would be worthless:
Duck renders IDENTICALLY to before — checked against screenshots taken before
anything was touched, not asserted.
Plain renders as white with black ink, no component edits — panels turn white, the
teal avatar turns black, the grid fills vanish.
── What moved ──
`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.
Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.
The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.
Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.
The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.
── Bug found by the Plain theme ──
`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.
── Tools ──
Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.
Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.
── Not done, deliberately ──
`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7980ee3761 |
photograph the app before touching how it looks
The themes work moves every colour out of the components and into a token layer, and the entire value of that is being INVISIBLE. Nothing here could have told us whether that held: the tests do not render, and comparing two screens from memory an hour apart misses exactly the things this can break — a border colour, a radius, one mode of two. Eight routes, both colour modes, against the running app. Byte comparison rather than a pixel-tolerance diff: a false alarm costs opening two images, a false all-clear costs the refactor. The first version was worthless and looked fine. It reused one browser context and added an init script per mode — Playwright ACCUMULATES those, so the dark pass ran the light script too, and the class toggle after `goto` was undone by the provider's own effect. Six of eight routes came out byte-identical in light and dark, which reads as a clean run. A context per mode fixes it, the class is asserted after settling rather than before, and identical light/dark pairs now print a warning instead of passing quietly. Verified rather than assumed: 15 distinct images of 16, every route differs between modes, and one was opened to confirm it is the authenticated app and not eight variations of an error boundary. Baselines are gitignored — 7MB of machine-specific PNGs. Regenerate with the script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
690a726f55 |
a member's turn ran as the owner after any idle gap
This is the cause. Everything before it was symptom.
`handleChat` had two spawn call sites. The first, for a new session, passed `member`.
The second — "session already live" — was a separate object literal that did not.
That branch is taken whenever OFFICER still holds the session, and officer holds it
long after the SIDECAR has let go: the sidecar reaps a session after 30 minutes idle,
`_claudeKill` stays set here regardless. So a gap sent a turn with no identity to a
sidecar with nothing to push it onto, and it created a fresh session from those
params. No member means the owner.
[claude:spawn:…] resume=49527b33… cwd=…/data/jg@pertento.ai/home as=owner
green is a Developer with their own Linux account, their own `claude` and their own
login. Every turn after a break ran as the OWNER: owner's HOME, owner's Anthropic
credential, owner's MCP config — which carries a JWT that signs as the owner. The
entire per-member boundary, undone by an omitted line in the branch nobody reads.
It surfaced as breakage rather than as silent escalation only by luck. The owner's
`claude` was handed the member's cwd — a 700 home it cannot enter — and the member's
transcript id, so it exited 1 instead of quietly working. Reported as "he went for a
walk and it stopped working", which is the idle timeout to the minute.
── The fix, and what it then found ──
Both paths now build from one `turnParams`, so they cannot drift again.
Then `member` was made REQUIRED-though-possibly-undefined in the protocol, and the
compiler immediately found three more sites that had left it out:
- `agent-handoff/deliver.ts` — the same bug, worse. No member AND a cwd from
`getOwnerHomeDir`, which ignores the email it is given. A member's agent panel ran
as the owner in the owner's home. Now resolves identity like chat does.
- `agents/agent-runner.ts`, `tasks/pipeline-executor.ts` — correct as they were:
both are `kind: 'execution'`, owner-only and never grantable. They now say
`member: undefined` explicitly, because a field you must fill in is a decision and
a field you may omit is an accident.
`resolveTurnIdentity` is exported so "whose turn is this" has one implementation
rather than three.
── What I got wrong on the way ──
Three wrong answers first — the credential, the working directory, then a recovery
whose condition could never fire — each from reading code and reasoning instead of
making the system say what it was doing. The log line that found this took five
minutes to write and should have been the first thing I did, not the fourth.
Also: `--resume` by hand appeared to work twice, which is what sent me down the wrong
paths. It proved nothing. The id I tested was not the id the platform passed.
tsgo clean, 950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9203472be8 |
the recovery could never have fired — fix its condition
Shipped
|
||
|
|
e642205207 |
a chat that cannot be resumed stops being permanently dead
A member's conversation answered "Claude Code process exited with code 1" to every message, four times identically, and could never recover. A brand new chat worked throughout. Diagnosing it took an evening and produced two wrong answers — the credential, then the working directory — both disproved by hand on the server, because the one input most likely to be at fault appeared in no log anywhere. Three changes, and the first is the one that matters most next time. ── Say what is being run ── `[claude:spawn:<key>] resume=… cwd=… as=… model=…`, before the spawn. The resume id is NOT the id in the URL — it comes from a stored map, so the two can disagree, and nothing could previously show that they had. Every hypothesis about it had to be tested by hand. ── Carry the child's reason to the person who hit it ── stderr was logged and then dropped, so the client got a number. The tail is now kept on the session and travels with the exit, both to the log and to the user. When the child says nothing at all — which is what happened here — the sentence still reads, rather than trailing a dangling colon. ── Stop retrying a resume that cannot work ── `sawOutput` records whether the child ever produced a single message. False, on a turn that was resuming, means it never got as far as loading the conversation — and since a fresh chat works with the same process, account, credential and binary, `--resume` is the only input that differs. So the stored id is dropped and the next message starts clean. The history is lost to the agent, which is a real cost, and strictly better than a thread that can only ever return an error. Nothing reconsidered that id before, which is why four identical failures in a row was the floor rather than a phase. Deliberately NOT done when the child spoke first: that is a turn that broke halfway, the transcript is fine, and clearing it would trade one bad turn for an amnesiac conversation. That negative case is the second test. `diagnoseExit` is its own module because everything around it needs the SDK to construct — which is how the old single sentence went unexamined for so long. 8 tests. This does NOT explain why the resume failed; it makes the next occurrence say so instead of costing an evening. The cause is still open. tsgo clean, 945 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b3b44ee77d |
changing route in a preview no longer reloads the app
Reported as "I change routes and a second later the whole app refreshes". The second
was mine: the 800ms debounce on writing the location down.
`DevProxyFrame`'s session effect had the composed URL in its dependency list. That URL
changes every time the app navigates, so the effect re-ran — and its first act is
`setReady(false)`, which swaps the frame for "Connecting…" and takes the iframe out of
the tree. Coming back mounted a fresh one. A full page load, one debounce after every
click.
Watching the URL was never right: the cookie is issued with `Path=/api/dev-proxy` and
no port in it, so it already covers every path and port the panel can reach. It now
depends on entering proxy mode and on an explicit reload, where re-requesting is the
whole point.
── A second bug, found while fixing the first, not yet reported ──
Freezing `src` at mount stopped the app's navigation from reloading the frame — and
also stopped a newly TYPED url from loading, because nothing remounted. The panel
would have silently ignored a change of address.
The two pull in opposite directions, which is what makes the pair the interesting
part rather than either fix. They are told apart by which value changes: `entry` moves
only when a person points the panel somewhere else, `target` moves when the app does.
So the frame is keyed on `entry` and loads `target`.
── The tests were made to fail first ──
Three component tests, and both bugs were re-introduced one at a time to check the
tests actually catch them:
URL back in the effect deps -> "the app navigating does NOT reload" FAILS
key back to reloadToken alone -> "a newly typed url DOES load" FAILS
Either bug alone would have been passed by a test written for the other, so they only
mean anything as a pair. Both were invisible to reading the component and obvious in
the DOM — the assertion is node identity across a rerender, which is the same question
as whether the browser re-requested the page.
`fetch` is stubbed and restored per test rather than `mock.module('hooks/useClient')`:
Bun has one module registry for the whole run, so that mock is process-wide and
permanent, and `useDashboardState.test.tsx` already owns it.
tsgo clean, 937 pass / 7 fail unchanged, frontend builds. Needs a rebuild — frontend
only, so the Rebuild button is enough; no restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d771736a0d |
the preview panel follows the app's navigation
The panel knew one URL: the one you typed. Navigate three pages into an app and the header still showed the front door, "open in a new tab" still went there, and a reload — which fires by itself whenever a dev server restarts — threw away where you were and started again at `/`. Now the app reports where it is, and a reload composes it back. ── How the panel finds out ── The frame is same-origin for a proxied preview, so `contentWindow.location` is readable. It is PUSHED rather than read, because a dev server is an SPA: a route change is `history.pushState`, which fires no event and never reloads the document. Polling would have to be constant and would still lag. The script the proxy already injects now patches `pushState`/`replaceState`, listens for `popstate`/`hashchange`, and posts the path — with the proxy prefix stripped, since that is plumbing and the panel should not have to know about it to show a URL. Only proxied previews can do this. A cross-origin frame reports nothing, and grafting a remembered path onto someone else's host would be inventing a URL, so `targetUrl` composes only when there is a port. ── Two fields, and the loop that forced them ── `url` is what you pointed the panel at; `current` is where it has got to. The obvious design is one field — a browser has one address bar — and it feeds itself: the stored value IS the iframe's `src`, so recording a route change hands the frame a new `src` for the page it is already showing, which browsers treat as a navigation. Every click would cost a reload. Belt and braces, because one of the two would be enough only until someone changed the other: the fields are separate, AND `PreviewFrame` captures its `src` at mount and ignores later changes. Remount-by-key is still how a reload happens. `ev.source` identifies which frame is talking. `message` is a window-level event and two same-origin previews hear each other; comparing the posting window to this iframe's `contentWindow` is exact, where matching on URL would break the moment two panels pointed at the same port. Writes are debounced 800ms. `useDashboardState.setValue` PATCHes the server on every call and an SPA emits a route change per click; the value is only read at mount, so being a second behind costs nothing. Also: the header shows `localhost:3001/settings` rather than `api/dev-proxy/3001/settings`, and editing starts from that — one is a thing you can retype, the other is not. ── Tests ── The reporter is RUN, not string-matched: `new Function` against a fake window, which also means a syntax error in the injected script fails the suite rather than silently breaking every proxied page. Covers prefix stripping, the app root reporting `/` rather than empty, query and hash surviving, the report on load, pushState, and popstate. Plus the composition helpers, including that an ordinary URL is never composed with a path. ── One stale comment corrected while in there ── The file claimed `allow-same-origin` "is present, and has to be". There is no `sandbox` attribute at all, so nothing was present — and a sandbox with `allow-same-origin` plus `allow-scripts` is worth roughly nothing anyway, since the frame can remove its own sandbox attribute. Now says that. Not verified against a live dev server through the real proxy — that needs the server restarted with this code. tsgo clean, 934 pass / 7 fail unchanged, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
430346e14b |
a restarted job outlives the server that restarted it
The other half of the same report. The pill came back after a refresh, and then a
`pm2 restart officer` killed the dev server outright — because `restartBackgroundTask`
spawned it as a plain child of the platform. A job the AGENT starts is a child of the
`officer-claude-code` sidecar, a PM2 peer, so those were never affected. The tray
therefore showed two chips that looked identical and behaved differently, and the
restart button's own help text promised the opposite of what happened.
Measured against a throwaway PM2 app rather than reasoned about, because the two
plausible causes call for different fixes — a signal to the process GROUP, or a walk
of the process TREE:
plain `sh -c` killed
`setsid sh -c` killed <- a new session is NOT enough
`setsid --fork sh -c` survived
PM2 walks the tree by ppid, so a new session leaves the job a child and it is found
anyway. `--fork` is what matters: setsid forks and its parent exits at once, so the
job is reparented to init before any restart can enumerate it.
── What that costs, and how it is paid ──
The handle `Bun.spawn` returns is now the setsid parent, which exits immediately.
Waiting on it reported every job as finished the moment it started, so exit detection
moved to `watchForExit`: poll who holds the output file, and when nobody does, read
the status the wrapper recorded. That is the same liveness question Stop and Pause
already ask, so it adds no second notion of "running" — and a paused job still holds
its file, so SIGSTOP correctly reads as running rather than as an exit.
Two details that would each have been a bug:
- A GRACE PERIOD. Between the fork and the job opening its output there is a moment
with no holders; polling into it announces a completion for a job that never ran.
- The command is a POSITIONAL PARAMETER to the wrapper, not interpolated into it.
Interpolation would let a command containing quotes terminate the wrapper's own
quoting. Tested with `echo "quoted 'both' ways"`.
A missing status file is reported as a clean finish, not a failure: that is what Stop
looks like, and "you stopped it" is nearer the truth than "it crashed".
Also corrected the button text. It said background jobs survive "because they are
their own PM2 processes" — true of agent-started jobs, false of restarted ones, and
now the wrong reason for both. They survive because they are outside this process's
tree.
── Verified ──
End-to-end through `buildHonoApp`, on a synthetic task:
POST /restart -> 200
holders: pid 906186 (ppid 1, session leader), pid 906188 (ppid 906186)
probe process was 906173 — in neither chain
quotes survived the wrapper
durable events: task:started -> task:notification (completed), exit file "0"
My own assertion in that probe was wrong and the output is what corrected it: it
demanded ppid 1 of EVERY holder, but only the session root is reparented — its own
children hang off it, which is the point.
Not in `bun test`: nothing in the suite touches Postgres or spawns processes.
tsgo clean, 919 pass / 7 fail unchanged. Needs a server restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b7d26a3350 |
a restarted background job survives a refresh
Reported as "I refreshed the page and the pill was gone, but the server is still
running and now I have no way to control it". Not a design choice — a bug, and it
came in with the restart button.
The tray is not stored anywhere. It is a fold over the durable chat event log:
`task:started` minus `task:notification`, per `collectRunningTasks`. That is what
rebuilds it after a refresh.
The restart route announced its respawn with `emitToSession`, which folds into the
in-memory session and writes to open sockets — and never called `appendChatEvent`.
So the pill existed only in the RAM of the browser tab that clicked the button. The
log still read:
seq 10979 task:started "Run dev server"
seq 11362 task:notification failed <- the ORIGINAL dying
Folded on refresh that says "not running", so the chip vanished — while the process
it described was up and serving on 3001. And every task route takes an id, so the
job became unreachable from the UI at the moment the browser forgot the id. Stop,
Pause and Restart would all still have worked; nothing could name the task.
The second half was quieter: `emitToSession` returned false when the session was not
in memory, and the route ignored the return. A platform restart drops the in-memory
session while the job keeps running, so the announcement went nowhere AND was never
written down.
Now it appends first and unconditionally — persistence does not need a listener,
only delivery does — and sends with the seq the log assigned, so a client's replay
cursor stays correct. Both events: the `task:started` is awaited so it is durable
before the client is told the restart worked, and the exit notification is
fire-and-forget because it lands long after the response.
This makes officer a writer of `chat_session_events`, which the note in websocket.ts
said it no longer was. `cut-off` had already been that exception. The real rule is
narrower and is now written down: officer writes events it ORIGINATES, because no
sidecar will. Turn output still belongs to the harness that produced it.
── How it was verified ──
End-to-end, out of band: a synthetic task with a real process holding a real output
file, a transcript in the harness's shape, and the two durable events that reproduce
the reported log. Driven through `buildHonoApp` so the permission gate and auth
middleware were in the path — the last two bugs of this kind both passed tests that
mounted the router alone.
BEFORE restart: 2 durable events -> tray would show 0 running (bug reproduced)
POST /restart -> 200
AFTER restart : 3 durable events -> tray would show 1 running
Not added to `bun test`: nothing in the suite touches Postgres, and this claim is
untestable without it. So this is a probe that ran once, not coverage.
Needs a server restart. tsgo clean, 919 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
eaa38c7d43 |
the maintenance buttons tell you when they fail
Asked whether there was feedback on failure, and for the restart there was not —
there was the opposite.
`useClient` throws on any status >= 400. The restart handler caught everything and
reported success, on the reasoning that a dropped connection is normal when the
process answering the request is the one being replaced. That is true, and it made
the honest refusal built into the route unreachable: a 400 saying "not running under
PM2" arrived through the same catch, so the button would have said "Restarting…"
while nothing restarted, and stayed disabled forever.
The two failures are opposites and are now told apart by whether an error carries a
STATUS. A status means the server answered and declined — real error, message shown,
button re-enabled. No status means the connection dropped — expected, and the
success path.
Both buttons also unwrap the message. The client throws with the raw response body,
and these routes answer with JSON, so an unhandled failure showed
`{"ok":false,"error":"…"}` and asked the reader to find the sentence inside the
punctuation. Five tests on that, including the cases where it must NOT invent one:
JSON with no error field, and a non-Error thrown value.
Found while answering the question rather than by testing, which is the wrong order.
── One thing I should not have done ──
I exercised the restart route against the live server to see its refusal shape. PM2
knows `officer` here, so it returned 200 and scheduled a real restart; the only
reason it did not happen is that my test process called `process.exit(0)` before the
250ms timer fired. Luck, not design. `pm_uptime` confirms the platform never
restarted.
tsgo clean, frontend builds, 919 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1426c3a856 |
rebuild the frontend without restarting, and restart when you must
Settings → System → Maintenance. Two buttons that are deliberately not a pair. ── Rebuild: real, and free ── The same `rebuildFrontend` a plugin install already runs. `Bun.build` reads source from disk on every call and writes `build/`, and the server serves that directory by reading each file per request — so a rebuild lands on the next page load with no process restart, no dropped websocket and no chat session lost. ── Restart: the only way to load server code, and it costs ── Bun caches modules for the life of the process. Measured rather than assumed: editing a file and re-importing returns the ORIGINAL export, and a cache-busting query picks up that one specifier while everything it imports stays cached. So `rebuildHonoApp` — which install already does — re-wires the same module instances into a new tree and cannot see a backend edit. Busting the whole graph would duplicate singletons: two database clients, two session maps, one process. There is therefore no third button, and the screen says why. A "remount the API" control would look like it reloaded the backend and would not. The restart answers BEFORE it schedules itself, because the process serving that request is the one being replaced. It refuses outright when PM2 does not know this process — in development the platform runs under `bun --watch`, where the restart would fail into a promise nobody awaits while the button reported success. Verified: `POST /rebuild-frontend` → 200, 3.0s, 3 outputs; anonymous → 401. The restart button is deliberately untested from here — pressing it would end the session this was written in, which is the same reason it needs a label that says so. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e8eea2b677 |
setup installs poppler, or an attached PDF cannot be read
Reading a PDF attachment needs `pdftoppm`. It was not installed on this machine and is not in any package list, so the first real PDF attachment failed at the last step — uploaded fine, landed on disk fine, unreadable. That failure reads as the feature being broken rather than a package being absent, which is the worst shape for something a fresh install would hit on its first try. Added to all four managers: `poppler-utils` on apt and dnf, `poppler` on pacman and brew. Worth recording WHY it is needed, because it is not obvious: the agent reads a PDF by RENDERING its pages, not by extracting text. On the document that prompted this — a sitemap of tree diagrams and wireframes — extraction returns almost nothing and rendering returns everything. The dependency is on the approach that works. Installed on this machine by hand already; this is so the next one has it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
92bac490d7 |
attach a PDF and the agent reads it; the microphone button is gone
Two requests, one file apart. ── The microphone ── Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav` are also the file browser's DictateDialog, which is not what was asked to go. ── PDFs, by not extracting anything ── A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered it and did nothing, because nothing here could extract PDF text. It is back and real, and it still extracts nothing: the file is uploaded, left on disk, and the agent is given its PATH. Its own `Read` handles PDFs by page, better than any extraction this could have shipped, and it can go back for a section later without paying for it twice. The alternative was base64 in the message. That is megabytes in the turn, re-sent for the rest of the conversation, to produce worse text. ── What this uncovered ── `attachmentIds` has been on this websocket and in `types.ts` for a long time and NOTHING on the server read it. Images work because they travel separately as base64, so the ids were dead on the wire — and a PDF is exactly the case that needs them. They resolve to paths now, appended to the prompt as `Attached files (read from disk)`. Ids arrive in a websocket message and become a filesystem path, so they are checked rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only in the two directories uploads actually write to. Verified — a valid id resolves, an unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield nothing. Both upload directories are searched because the first message of a chat carries ids written to `tmp/` (no session yet) and later ones carry ids under the session. PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the ceiling is storage rather than context. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. `[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether their Linux user can read it depends on the confinement ACLs. Untested — the owner path is what was verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f4f8ea32d1 |
a paused job shows a pause icon, not a spinner
The details panel kept spinning after Pause, so a frozen dev server looked like a working one. The spinner is the whole claim being made, and it was false — the state most likely to be watched, since the panel is where you sit while a `bun dev` runs. Amber either way: it is still a live job holding its port and its memory, which is what separates paused from stopped. What changes is the motion, and the line beside it now reads `paused` rather than `running…`. ── And the state moved up a level ── `paused` lived in the panel, which unmounts when you close it. Closing and reopening would have put a spinning icon back over a stopped process — the exact failure this commit is fixing, arriving by a different route. It is the tray's now, so it survives the panel being closed. Still per-session: a reload forgets, and the button then offers Pause on a job that is already frozen. SIGSTOP makes that harmless, and the honest fix is asking the process rather than remembering — `ps -o state=` on the pids holding the output file would make it survive anything, at the cost of a spawn per poll. Worth doing if this ever misleads. The chip is unchanged: a paused job still shows the running dot there, because live is what that dot means and the distinction belongs where the controls are. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c7749308e1 |
a path that climbs out of the proxy is collapsed before it is prefixed
The blank preview, and it was never the restarts. Bun's dev server emits
href="/../../../chunk-qmw09gd9.css"
Root-absolute AND climbing. Prefixed naively that is
`/api/dev-proxy/3001/../../../chunk-…`, which the BROWSER normalises straight back
to `/chunk-…` — the platform root, where it 404s. Two failed asset requests, a
white pane, and nothing in the network tab suggesting a proxy was involved. The
rewrite looked correct in isolation and was undone by whoever read it.
`..` is resolved by the consumer, so it has to be resolved before the prefix goes
on. Both halves do it now: `rewriteHtml` for what is written in the document, and
the injected script for URLs built at runtime, which Bun also emits in this shape.
Verified through the whole stack against the real app on 3001: document 200, both
assets 200 with `text/css` and `text/javascript` — where before they were 404s at
the platform root.
13 tests, and one of them asserts the failure rather than the fix: that
`/api/dev-proxy/3001/../../../chunk-x.css` really does resolve to `/chunk-x.css`.
Without that line the other tests look like arbitrary string manipulation.
── Separately, and not a bug in this code ──
Two processes were listening on 3001, holding DIFFERENT task output files
(`b0oe99a47` and `b6zat0x7e`) — two independent jobs, not one restarted. Bun allows
both to bind through SO_REUSEPORT, so requests were split between them and stopping
one legitimately left the other serving. That is worth knowing before reading a
Stop as broken, and it is the second time today SO_REUSEPORT has made a duplicate
invisible.
tsgo clean, frontend builds, 914 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
bda5eb15de |
a job's controls can no longer freeze the platform
The server went down. It had not: it was STOPPED — eleven threads in state T, port 9000 still listening with connections queueing behind a process that would never accept them. Indistinguishable from a crash unless you look, which is why it took a terminal to see. `kill -CONT` brought it back with nothing lost. I did it, with one missing line. `restartBackgroundTask` opened the job's output file to hand the child a descriptor and never closed it, so the PLATFORM became a holder of that file. `pidsHoldingFile` asks `fuser` who holds it — that is how Stop finds a job with no recorded pid — so the platform was in the answer. The next Pause sent SIGSTOP to it. Every part worked as designed. Pause froze exactly what was holding the file, and one of those things was the thing doing the freezing. Two fixes, and the second matters more than the first: **Close the descriptor.** The child gets its own duplicate at spawn, so closing costs it nothing. That removes this instance. **Never signal our own pids, whatever `fuser` says.** `pidsHoldingFile` now filters `process.pid` and `process.ppid`. The leak is fixed; this is what makes the next one survivable. Nothing that manages a job should be able to name the process doing the managing — a rule worth having independently of the bug that suggested it. Verified: after a restart this process holds the output file zero times, `fuser` returns only the spawned shell and its child, and our own pid is filtered even when present. tsgo clean, frontend builds, 901 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bc2bafe24a |
a queue can no longer be delivered to the wrong conversation
My fix was worse than the bug it replaced. Queue in one chat, switch to another,
and the messages were SENT there — into a conversation the person had not written
them in.
`ChatDetailPanel` mounts `<EmbeddableChat sessionId={undefined}>` and passed no
panel id, so `queueKey` fell through to `screen:new` for every conversation it
showed. One key, every chat. Switching restored the queue into the chat being
opened, and the mount-drain I added — which exists so a returning queue is not left
sitting there — then delivered it. Two of my changes, each defensible alone,
combining into the worst outcome available.
Three things, in order of what actually protects the user:
**A key that cannot be shared.** `queueKey` returns null when neither a panel nor a
session identifies the mount, and null persists nothing. Then an unidentifiable
chat behaves exactly as it did before any of this: queue local to the mount, lost on
a switch. That is the original complaint, and it is strictly better than delivering
to the wrong place. A shared key is worse than no key.
**The drain refuses a null key too.** Belt and braces: nothing was restored, so
nothing may be sent.
**`ChatDetailPanel` now says which conversation it is**, from `resumeSessionId`,
which it already had in scope. The identity was one line away the whole time.
Three tests pin it, including the collision itself: two resumed conversations must
not produce the same key, and a null key must never persist.
The lesson I would rather write down than repeat: I added the mount-drain reasoning
about a queue's promise, and never asked what it would do if the key were wrong. A
mechanism that sends messages needs its failure mode considered before its happy
path.
tsgo clean, frontend builds, 901 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
19e75651e7 |
a restart runs the job in its own directory, or not at all
Restarting a `bun dev` belonging to a project in ~/dockers/officer-atelier started the PLATFORM's dev server instead. The respawn passed no cwd, so it inherited the server's own working directory, where `bun dev` means something else entirely. The result was not an error. `bun --watch src/server.tsx` came up and bound port 9000 ALONGSIDE the running platform — Bun allows that through SO_REUSEPORT — so requests were split at random between two instances of the platform. Confirmed on the machine by ancestry: `sh -c bun dev` → `bun dev` → `bun --watch src/server.tsx`, with two listeners on 9000. Stray killed; the real server was unaffected. The information was there all along: every transcript entry carries `cwd`, and the entry holding the tool call is the one that says where it ran. `commandForTask` returns both now. And when there is no cwd it REFUSES, rather than falling back to the server's own. The fallback is what produced this, and it produced the worst outcome available — not a failure, but a different program running successfully. A command is only meaningful with the directory it was written for. Verified with a job whose transcript says it ran in /tmp/cwd-probe-app: the restart reports that directory and the respawned process prints it, reading a file that exists only there. tsgo clean, frontend builds, 898 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0d3a7fc33f |
queued messages survive switching to another chat
First report from somebody who is not us. Queue messages in a chat that is working, open a different conversation, come back: gone. Not a bug in the sense of code doing the wrong thing — `ChatPanelInner` is keyed on the session id, so opening another conversation REMOUNTS it, and that key is load-bearing (`useChat` seeds its state from the first session id it sees, so a remount is the honest way to say "this is a different chat"). The queue was local state and died with the mount. It was built for the minute between writing a message and a turn ending; switching away mid-turn was never in that picture. Now it lives in a module-level store, seeded on mount and written through on every change. Losing text somebody typed is the worst thing this could do, and it was doing it silently. ── Keyed by panel AND session ── Two panels can both be on a new chat, where the session id is `new` for both. Keying on the session alone would hand one panel's queue to the other — the same class of mistake wearing the opposite face. ── One decision that was not in the report ── A restored queue now DELIVERS on return if the agent is free. The existing drain only fires when a turn ends, and a remount has no such transition to watch, so without this you would come back to your messages sitting in the tray with nothing about to happen — which is the second report I would get. "These go when the agent is free" is the promise; the agent being free is exactly that case. Only on mount, and only when nothing is running: while a turn IS running the ordinary drain owns them, and firing here too would deliver twice. ── Not persisted, deliberately ── Module state, so it dies with the tab. A queue is a promise about the next minute; restoring one on a page loaded tomorrow would fire a message into a conversation whose context has moved on. Surviving a panel switch is the whole requirement. 11 tests, including the two-panels-on-one-conversation and both-on-a-new-chat collisions. tsgo clean, frontend builds, 898 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d9bffe5471 |
the dev proxy was mounted behind the auth middleware
"Unauthorized" in the pane, and that word is the whole diagnosis: this router's own refusal says "Not authorised for the dev proxy. Open it from the Preview panel." The text on screen came from `userMiddleware`, which means the request was rejected before the router ever saw it. I had mounted `/dev-proxy` in the PROTECTED table. Everything there gets `userMiddleware`, which requires an Authorization header — and an iframe navigation is a navigation, not a fetch, so it has none. The document 401'd every time. The cookie work in the last commit was correct and could never have helped: nothing was reaching the code that reads the cookie. Moved to the unprotected mounts, beside `/auth` and `/sidecar`, with the reason written where the list is. It is not open — the router verifies the OWNER's token on `/session` and requires the cookie it issues on everything else. ── Why I did not catch this myself ── My tests mounted `devProxyRouter` on a bare Hono app. That proves the router and nothing else, and the bug was entirely in what sits in front of it. Retested through `buildHonoApp()` — the real assembly, every middleware — where `/session` returns 200 with a cookie, the frame returns 200 carrying the dev server's own HTML and injected script, and an uncredentialled request now gets THIS router's message rather than the middleware's, which is the observable difference that proves the path. A test that constructs its own subset of the system will keep passing while the system is broken. Worth remembering the next time a router is proven in isolation. tsgo clean, frontend builds, 889 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fccabfcea3 |
the preview authorises itself before it mounts the frame
401 on every asset, white pane. The server was never wrong — replaying the exact browser sequence with a minted owner token gave HTML 200, `set-cookie` present, and the sub-resource authorised. So the fault was in how the frame got its credential, and both mechanisms it relied on were guesses. Putting the token on the iframe URL guesses which key the browser stored it under. Letting the frame mount and hoping guesses that a cookie from some earlier load is still there. When either guess misses, the HTML renders, every sub-resource 401s, and the pane is white — which is the shape that makes this hard to read, because the document loading looks like success. So neither is used now. The panel asks `/api/dev-proxy/session` with the ordinary API client — which already knows how to authenticate, because it sends the header on every other call it makes — and mounts the frame only once that returns. The iframe URL carries nothing. `iframeSrc` is deleted rather than left as a fallback: a second path that runs when the first fails is how you get a bug that only appears sometimes. `Authorization` rather than a query parameter, so the token never reaches an address, a log or a Referer. Re-run on every reload, so a preview left open overnight recovers by itself instead of showing a wall of 401s. ── Also fixed: a lie attached to every binary asset ── The non-rewritable branch returned the upstream's `content-encoding` and `content-length` unchanged, but `fetch` had already decompressed the body — so the browser was told to inflate plain bytes, with a length that no longer matched. The rewrite branch had always deleted both. Images, fonts and wasm were affected; nothing had hit it yet because the test app serves none. Verified: `/session` 200 and sets the cookie with a token, 401 without; the frame 200 with the cookie, 401 with nothing. `[open]` A request that escapes the prefix hits the SPA catch-all and returns `200 text/html` instead of 404, so a `<script>` gets HTML, the MIME check refuses it silently, and nothing appears as an error. That masks this entire class of bug and is worth closing separately. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
73eb128fb1 |
pause, resume and restart a background job without asking the agent
Three buttons in the expanded task panel, beside Stop. Not on the chip: the panel is where you decide what to do with a job, and a row of unlabelled glyphs on a pill is where you misclick. ── Pause / Resume ── SIGSTOP and SIGCONT — the only pair that means "stop doing things and keep everything". The process keeps its memory, its open files and its bound port, and picks up mid-instruction. Signals go to every pid holding the job's output file, because for a shell job that is the shell AND what it spawned, and freezing one of them would be worse than freezing neither. Verified by watching a job that writes a line a second: 3 lines, frozen at 3 across three seconds of pause, 6 after resume. Worth knowing before pressing it: a paused server does not REFUSE connections, it stops answering them, so a browser pointed at it hangs rather than erroring. That is what pausing is, not a shortcoming here. ── Restart, and the cost it accepts ── The command is recovered by joining the transcript to itself: the harness records the `tool_use` carrying `command`, and separately its result — the text `Command running in background with ID: <id>`. Neither alone is enough; together they are a join on `tool_use_id`, and that is the only place the command survives. The transcript is found FROM the output path rather than searched for, since a task file at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<id>.output` names both. The respawned process appends to that same output file. That is what keeps everything else working untouched — the log view reads that file, and Stop finds the process by asking which pids hold it open. The real cost, chosen deliberately: the platform becomes a SECOND owner of a task. The harness stops watching when its own child dies, so the platform supervises what it spawned and emits the same `task:started` / `task:notification` pair everything downstream is built on. `emitToSession` is the seam, and it is the only one — nothing else learns that a task can have two kinds of parent. A restart re-announces an id already in the transcript, so `task:started` now RESETS an existing task row rather than appending a second one. Without that the tray showed the job twice, once finished and once running. Restart is offered for a finished job too, which is the case that matters most: a dev server that fell over is exactly what you want back without typing a sentence at an agent that may be mid-turn. Verified end to end against a planted task: command recovered, old process killed, new one spawned, output APPENDED (`first run output` then `RESTARTED-OK`), exit reported through the callback, supervision registry released afterwards. `[open]` A restarted job does not survive an `officer` restart — the platform is its parent now. The harness's own jobs never did either. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d64b0a0d6 |
paste localhost:3000 into the preview and see the server's port
No proxy host, no certificate, no port opened. `/api/dev-proxy/5173/…` forwards to `http://127.0.0.1:5173/…` over the platform's own origin, and the panel maps what you type onto it. ── Why a proxy at all, when the ask was "just point at localhost" ── Neither direct route works from a browser on https://<platform>. `http://localhost:5173` resolves on the VIEWER's machine, not the server's, and `http://<server>:5173` is mixed content inside an https page and is blocked before it is even attempted. Both fail looking exactly like the dev server being down. The bytes have to arrive over an origin the page already trusts, which is the platform. A dev server can therefore stay bound to 127.0.0.1. The workflow this replaces — one NPM proxy host per app — needs it on 0.0.0.0 plus a firewall rule for the docker bridge, so this is both less setup and less exposure. ── What came back from `188b45c1^`, and what did not ── The 46-line HTML/asset rewrite, which is the hard-won part: absolute paths in the document, plus an injected script that intercepts fetch/XHR/WebSocket for the URLs built at runtime. Its JWT injection is deleted — those apps called the PLATFORM's API; a dev server proxied here talks to itself, and handing its process the owner's bearer token would be giving credentials to whatever is on that port. The other 314 lines are not back: spawning, port discovery, idle timers, log collection, a proxyId registry. The port is the id, and the background-job button already starts dev servers. ── HMR is bridged, because "see it" without "follow it" is half the feature ── The injected script rewrites the dev server's socket under the prefix; the relay dials `ws://127.0.0.1:<port>` and forwards frames both ways. Same shape as the vault relay including the non-obvious part — state registered synchronously and frames queued until the upstream opens, because Bun requires the upgrade to be synchronous and an HMR client speaks immediately. An upstream close closes the client, which is what makes a dev-server restart reconnect by itself. ── Access, which was not in the ask and is not optional ── This route reaches any loopback port on the machine, on a host that answers from the public internet. An iframe cannot carry a bearer token — it is a navigation — so the panel puts it in the URL once, the proxy verifies it is the OWNER through `resolveAuthToken`, and issues a short-lived cookie every sub-resource then carries. Anonymous and non-owner requests get 401, verified. Owner-only rather than a permission: reading an arbitrary loopback port is the `execution` power the registry says can never be granted. Both exemptions are registered with reasons, and the boot check earned its keep — it refused to start until the socket provider was claimed too. ── Verified against a real loopback-only server ── Confirmed unreachable from outside, then through the proxy: index served and rewritten, `/_bun/` chunk resolved, JSON passed through untouched, 401 anonymous, 401 with a bad token, "nothing is listening" on a dead port. Websocket: refused without the cookie, target parsed, frames relayed in both directions. One bug found by testing rather than reading: the upstream path was derived by slicing a hardcoded prefix, so every asset request returned the index page. It is found from the `/<port>` segment now. A test asserting `http://localhost:5173` is kept verbatim is now inverted, with the reason written beside it — that was correct when the panel only framed real hostnames. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
83cd7af156 |
a kickoff prompt for the next extraction, and a runbook that is true again
`plugins/EXTRACT-A-PLUGIN-PROMPT.md` is meant to be pasted into a fresh agent with one word changed. It deliberately does not restate the runbook — it orders the reading, says what "done" means, and sets the three behaviours that matter: map the import graph before moving anything, ask rather than assume, and verify on a live server after a purge. ── The runbook needed correcting before it could be handed to anyone ── Its verification table told the reader to run `bun db:push` while the plugin was uninstalled and expect "No changes detected". Following that today would hang on drizzle-kit's interactive rename prompt, or — with `--force` — drop the tables of every plugin not currently on disk. Struck out, with the reasoning, and the property it was checking is now checked by reinstalling and finding the rows still there. Also folded in: installs create tables through `pushPluginSchema` and verify them afterwards; `purge` is the only honest way to test an install twice, because uninstall keeps the directory and the tables on purpose; orphan tables are now reported on screen; and the marketplace is part of the job — a public repo that clones anonymously, a README that renders in both the store and the install pane, a catalogue row, and a `BUNDLED` entry for machines with no internet. Two rules from today that generalise beyond extraction: the platform must not carry the plugin's VOCABULARY, not just its code — ten comments explained what Headscale was months after it left, each correct and in the wrong repository — and nothing in the install path may trust `$PATH`, which cost an install with `Executable not found in $PATH: "bunx"`. The stale "757 pass, 10 failures" is now "record the baseline before you start", which does not go out of date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
74f2e59069 |
the tray stops filling with the agent's own commands
"So the pills don't get polluted with those calls that you're making." First, a correction to what I assumed. The platform is not mis-detecting anything: the SDK emits `task_started` only for real background work, and I checked — a foreground `echo` produces no task event at all. What produces these is the HARNESS auto-backgrounding a foreground command that runs long. Measured against the installed SDK: a 24-second foreground Bash call emits `task_started` with `task_type: 'local_bash'`, identical in shape to one started with `run_in_background: true`. There is no field that separates them, and there should not be — by then they are the same thing. So the chips were real. They were just not anybody's business: a tray of green ticks for greps and typechecks, sitting beside the one job that was started on purpose. The rule is now about ORIGIN rather than state, using the arm-and-claim that already exists for the preview: running shown, whoever started it — it is live and it can be stopped finished, yours kept (up to five), because you asked for it and the outcome is the point finished, the agent's dropped the moment it ends pinned kept regardless Running jobs stay visible deliberately, including mine. A background command of the agent's that has gone wrong is exactly the thing worth seeing and stopping, and the stop button already adopts anything you press it on. Extracted as `visibleTasks`, pure and tested — six cases including the cap, the pin override, and running-before-finished surviving the new filter. `[open]` Origin is process state, so a reload forgets it and a previously-yours finished chip is then treated as the agent's and dropped. Acceptable — after a reload you are not watching that completion — and making it survive means putting origin in the transcript, which is a wire change. tsgo clean, frontend builds, 884 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7fdb09af0a |
speaking mid-turn no longer declares the turn over
Reported as "the output I see with turn complete is very very misleading", and it
was: the transcript printed "Turn finished · no closing message" between a tool
call and an injected message, while the header two inches away still said Working
and a task chip still said 1 RUNNING. The UI stated the opposite of what was
happening.
`turn-rows` treats every user message as a turn boundary. That was right when the
only way to speak was to wait. Delivering a queued message INTO a running turn made
it wrong: the boundary split the live turn, the half before it therefore counted as
`complete`, and having tool calls and no answer it matched exactly the rule that
prints a silent ending. The seam meant for a rare confusing case fired on an
ordinary one, and said something false.
So a user message now carries `injected` when it was sent while a turn was already
running, and only a message that STARTS a turn is a boundary. Read from
`isGeneratingRef` at send time rather than from state — `sendPrompt` is called
from closures built a render earlier, and the question is "was a turn running when
this was sent", which is what the ref holds.
Five tests, including the contrast case: the same messages WITHOUT the flag still
produce the boundary and the ending, because a genuine second turn should end the
first. A turn that really did finish silently still says so — that seam was worth
having and is not what was broken.
Verified live on the way in: the injection reached this session mid-turn, attached
to a tool result, and changed what the turn did.
Still outstanding from the same screenshot, not touched here: foreground Bash calls
render as background tasks ("Running in the background…", and a chip in the tray).
That is a separate misreport.
tsgo clean, frontend builds, 878 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0230e62a71 |
the platform stops explaining what Headscale is
Correcting my own reasoning from the last commit. I left "Headscale's API", "the Headscale admin credential" and similar alone on the grounds that they name the thing the plugin drives, so renaming them would make the sentences false. True as far as it goes, and it misses the point: those sentences are in the PLATFORM. It has no business knowing what Headscale is at all. Correct language in the wrong repository is still a remnant. Ten of them, each replaced with the platform's own vocabulary rather than the plugin's domain: - `crypto.ts` and `secret-store.ts` listed "the Headscale admin credential" among what one key used to protect. It is "the tailnet's admin credential" — the purpose, which is a word this repository owns. - `catalogue.ts` and `availability.ts` explained `alsoServes` — a LIVE mechanism — entirely through a Headscale example that left the repository. Restated as the general shape, with one parenthesis saying which case produced it. - The photos types file explained another plugin's sidecar. - `App.tsx` cited `/headscale/:section` as the example of a route pair; that route does not exist. `/photos/:section` does. - `plugins/router.ts` pointed at `useCompanionLogStream`, a hook that lives in a plugin now. - `registry.ts` said in the present tense that `headscale` is `admin` — the permission is the plugin's and it declares its own, so a member-facing enrolment surface would be the PLUGIN's to add, not this file's. - `proxy.sh` cited `headscale/schema.ts` as a style reference. That file is not in this repository. - machine-setup said "Its Headscale app" — the app is the Offscale plugin. `machine-setup/lib/tailscale.sh` keeps all ten of its mentions: it exists to explain tailscale, headscale and offscale to somebody choosing between them, on a core install, before any plugin exists. That is machine setup's subject, not the plugin's domain leaking upward. What survives elsewhere is only dated history — "was here until 2026-08-15", "It was: music, vault, slskd, headscale, …" — plus the notes about the real `headscale_servers` orphan and the secret-store key removed yesterday. Those record that something called headscale used to be here, which is a different claim from explaining what it does. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
27aa72d923 |
finish the offscale sweep: two real remnants and eleven stale names
Asked for a thorough check that nothing of offscale is left in the platform. The extraction was sound — no router, no permission entry, no app-store entry, no screen, no panel app, no env var anything reads. Two things were genuinely wrong, and both were in setup rather than in the server. ── The secret store minted a key for a plugin ── `officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a key for a PLUGIN, under a name the rename had already replaced, on a machine where the plugin is not installed and nothing would ever ask for it. offscale encrypts under `offscale`, and `getKey` creates on demand — which is precisely what lets core know nothing about plugins. Now core bootstraps `jwt` alone. ── `.env.example` documented variables for a deleted router ── HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving `/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them since. Left as a note saying where the tailnet's credentials live now, because somebody with an older `.env` will otherwise wonder why setting them does nothing. ── Names that outlived the thing ── Eleven comments referred to the plugin's route, panels, console or server registry as "headscale": three sidecar config files describing their own shape by pointing at it, two screens citing `/headscale` as the example of a Workspace/Panel route, the terminal's note about the Console, and the photos types. Also `catalogue.ts`, which still claimed headscale "is CORE now", and `working-on-officer.md`, which told the reader a core install runs six processes including `officer-headscale`. Upstream references are deliberately untouched: "Headscale's API", "the Headscale admin credential", "is this a Headscale server?" all name the thing the plugin drives, and renaming those would make the sentences false. `docs/secret-store.md` argued headscale must be core because the tailnet is the perimeter. Marked superseded rather than deleted, with the distinction that settles it: the perimeter is the tailnet EXISTING, which machine-setup still arranges on a core install — not the platform shipping the screen that administers it. Verified by counting: live code, setup provisioning, env references, permission entries, app-store entries, hono mounts, dock defaults, screens and panel apps all zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the bundled catalogue entry, test fixtures using "offscale" as an example app name, and history. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c0e61a4cba |
the trap keeps its evidence, and says where the procedure is
This bug will not be reproduced on demand. It will arrive as somebody's report, hours later, from a browser whose console is long gone — so a `console.warn` is seen by nobody, and "what did you see?" gets an answer built from memory and a screenshot. So the trap now persists. Up to five occurrences in `localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment of the edit, the first 200 characters that arrived, how many, which file, and when. The question becomes "paste this key", and the answer is complete. Recording a bug must never become one: private mode, a full quota and a disabled storage API all throw at that write, and none of them is worth breaking an edit over. The console line survives either way. `docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four candidates already eliminated and why, the one command to ask a reporter for, how to read each field, what an EMPTY key would mean (either the editor was focused, making it a paste, or the insertion came in chunks under the threshold), and the instruction to delete every piece of this once the cause is known. Linked from TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it without grepping for it. Stated there rather than left implicit: a fix invented without knowing the cause would hide the symptom on the one screen where it is visible while the mechanism carried on doing whatever else it does. The corruption is never saved and reopening the tab clears it, so waiting is cheap and a wrong fix is not. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fc8d02e8e1 |
set a trap for the editor buffer corruption
Not a fix — I could not find the cause by reading, and shipping a guess dressed as
one is worse than saying so.
What reading DID settle, and it removes two of the three candidates:
- The end-of-turn refresh cannot be it. `reloadFile` sets `originalContent` and
clears `isDirty`, so no refresh can produce the dirty dot in the screenshot.
- Nothing in the app types into the page: no `execCommand('insertText')`, no
synthetic keyboard events, no clipboard writes anywhere near the editor.
That leaves Monaco's own change event, which means the model was edited the way a
paste or a programmatic `executeEdits` edits it. Neither is identifiable from a
screenshot taken afterwards, which is why this needs to be caught in the act.
`reportSuspiciousEdit` logs any insertion over 200 characters arriving while the
editor does NOT have focus, with an Error constructed at that moment so the stack
names the caller. A person cannot type 200 characters into an editor they are not
focused on; whatever does will be in that trace.
It prevents nothing and changes no behaviour. Delete it once the cause is known.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
23f2ecb950 |
todo: the marketplace's deferred work, ranked
Split out of docs/marketplace.md so the design document stays about how it works. Ranked by what blocks something rather than by size: submissions first (until a stranger's manifest can be read without executing it, the catalogue is ours only and everything below is cosmetic), then plugin upgrades not migrating, then staleness, then scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |