dc420ba5a27a7ce9815a299cd7bb529f651a72da
1397
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dc420ba5a2 |
stop assuming bunx is on the service's PATH
`Executable not found in $PATH: "bunx"` — installing music died there, after the
clone and after apt had installed ffmpeg. My bug, one commit old: the schema step
spawned `bunx drizzle-kit`.
PM2 starts the platform with a PATH that has `bun` and not `bunx`. That is a
perfectly normal way for a service to be started — a symlink into /usr/local/bin
for the runtime and nothing else — and an install has no business depending on
which of a runtime's shims someone happened to link.
So neither name is looked up now. `process.execPath` is the interpreter already
running this code, so it cannot be missing, and drizzle-kit is invoked by path
through `node_modules/.bin/drizzle-kit` rather than by resolution. A missing CLI
there is reported as "run bun install" instead of as a PATH mystery.
Verified the way the claim demands: re-ran the schema push under
`env -i PATH=/usr/bin:/bin`, where `Bun.which('bunx')` and `Bun.which('bun')` both
return nothing. It works. The previous run proved nothing, because bunx was still
there.
Worth noting for the next spawn added to this path: `git`, `pm2` and `sudo` are
still PATH lookups, and they are the ones that were already working on the
machines we have. Anything new should follow this pattern instead.
tsgo clean, frontend builds, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e0d3f59b9b |
name the tables nothing on this machine owns
The marketplace screen now lists tables in the database that no code here claims, with a row estimate and, where the prefix says so, which absent plugin they look like they belong to. This is the information that cost an evening. `bun db:push` saw tables the schema did not declare, asked an interactive question no non-TTY caller could answer, applied nothing and exited 0 — and the install reported success while the plugin was unusable. The cause was two tables from before an extraction, found by reading `\dt` by hand. Nothing in the platform would ever have mentioned them. ── What counts as an owner ── The core schema, or a plugin DIRECTORY — not a plugin INSTALL. A plugin that is present but uninstalled still owns its tables, because that is the promise uninstall makes: keep every row so a reinstall puts you back where you were. So this reports only what no code on this machine describes at all, which on a healthy install is nothing, and the block does not render. A plugin whose schema will not import has its prefix treated as claimed rather than having every one of its tables reported as unowned — a broken manifest should not read as data with no home. ── No delete button, deliberately ── Dropping a table is not something to offer beside a list of plugins, and the right action differs per row: `music_favorites` wants the music plugin installed, `headscale_servers` wants dropping. Naming them is the part that was missing. Verified by planting `headscale_servers` and `music_playlists_old` and reading them back through the same call the route makes: the first attributed to nothing, the second to music, which is not on this machine. Row counts come from `pg_class.reltuples` — an exact count on a large table is not worth blocking a page render for a number that only answers "is there anything in here". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8d8fbe2903 |
delete the global push from the server, keep the warning
`pushSchema()` had no callers left once installs stopped using it, and it is not the kind of dead code worth keeping around: it is a one-line way to run the exact command that stopped applying anything on a real server, and dropped tables when forced. The doc comment stays in its place, because the useful artefact is the reason rather than the function. `bun db:push` remains correct in exactly one situation — `officer-setup`'s build step, on a machine with no tables yet, where "make the database match this schema" is an instruction with no ambiguity to resolve. Every other caller now has a scoped path. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
221e370442 |
a plugin install creates its own tables and cannot touch anything else
The logs from the last commit showed what was actually wrong, and it was worse
than a failed push.
── What the install was doing ──
`bun db:push` hands drizzle-kit the whole schema and the whole database and asks
it to make them match. On a machine holding tables the current schema does not
declare — a plugin that is not on disk, anything left from before an extraction —
it sees a new table appearing while others are due to be dropped, and asks:
Is offscale_servers table created or renamed from another table?
❯ + offscale_servers create table
~ headscale_servers › offscale_servers rename table
~ music_favorites › offscale_servers rename table
That prompt needs a TTY. `--force` does not answer it, `< /dev/null` does not
answer it, and piping newlines does not either — measured: it hangs until killed.
Without a terminal the push applies NOTHING and exits 0, which is exactly how an
install reported success on a machine where its table was never created.
And `--force` would have been the wrong fix. Force means "run the destructive
statements without asking", and the destructive statements there are `drop table`
for every plugin whose directory is absent. Installing one plugin could delete
another's data.
── `tablesFilter` was tried, and is not enough ──
Scoping the push to the plugin's tables stops it dropping other tables and it
still diffs sequences. Measured on a scratch database: installing offscale issued
`drop sequence passkey_challenges_id_seq` — a CORE platform sequence — because the
plugin's schema does not declare it. Postgres refused only because a column
default depended on it. A sequence without dependents would have gone.
── So: generate the SQL, then apply it ──
`drizzle-kit generate` against the plugin's schema alone, into an empty directory,
emits exactly the statements that build that schema from nothing. It diffs against
the migration journal — empty — not against the database, so it cannot emit a DROP
of anything, of any object type. There is no destructive statement to guard.
Applying is idempotent by exception: an object that exists raises 42P07/42710 and
is skipped, which is the normal case for a reinstall, since uninstall keeps data
on purpose.
`[open]` This creates; it does not migrate. A plugin version that adds a column
will not get it. Plugin upgrades need real migrations — a separate feature. The
old push appeared to handle that case, at the price of being able to drop half the
database.
── Verified ──
Fresh install: 4 statements applied, table verified, 761ms. Reinstall over
surviving tables: 0 applied, 4 already present. Core sequences before and after:
18. Orphan tables on a scratch database shaped like the failing server
(headscale_servers, music_favorites): untouched.
The reinstall case failed on the first attempt — drizzle wraps driver errors, so
the SQLSTATE saying "duplicate table" is on `cause`, and checking only the top
level made every second install fail on its own CREATE TABLE. Found by testing the
second install rather than stopping at the first.
tsgo clean, frontend builds, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c98b5643aa |
show what the commands are actually saying
Install, uninstall and purge narrated what the platform was doing and hid what it was running. Every spawn captured stdout and stderr, held them, and surfaced the last 600 characters only if the exit code was non-zero. That is enough to explain a crash and useless for the two things people want while watching an install: knowing a slow step is still moving, and seeing the warning a command prints on its way to exiting 0. The second is not hypothetical — `db:push` reported success on a machine where it created no tables. Its output was captured, discarded because the exit code was fine, and the failure surfaced minutes later as 42P01 in a sidecar log. Whatever drizzle said about that push, nobody ever read it. `runLogged` streams both pipes as they arrive, so they interleave the way they do in a terminal. Following only stdout would have been silent for exactly the commands worth watching: git writes everything to stderr, drizzle-kit writes progress there and results to stdout. Two pieces of noise control, because a log nobody can read is not an improvement: `git clone` gets `--progress` instead of `--quiet` (git says nothing when it thinks it has no terminal, and that is the step where a slow link looks like a hang) with per-percent rewrites collapsed to the last fragment per chunk; and drizzle's spinner, which is eight lines saying the same thing with a different braille glyph, collapses to the first — otherwise "Changes applied" scrolls past underneath it. Steps and output share ONE ordered list rather than two. Their order relative to each other is the whole information: "which step was this output from" is the only question the log exists to answer. Output renders dimmer and indented under the step that produced it. Verified by running a real purge and reinstall: the clone's object counts, the barrel generation naming both plugins, drizzle's push, then "schema: applied in 2029ms (1 table verified)". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c03e61c5e |
delete a plugin and everything it owns, and stop trusting exit codes
Two halves of the same problem: a plugin installed onto a fresh machine, reported success at every step, mounted, started its sidecar, registered — and then failed its first query with 42P01, because `offscale_servers` had never been created. Nothing said so, and there was no way back to a clean machine to try again. ── The install now asks Postgres ── `pushSchema` only checked the exit code, and `db:push` is a compound command: generate the barrel, cd, run drizzle-kit. It can exit 0 having created nothing. So the install now reads the plugin's schema for the tables it declares and verifies they exist, failing the install with a message naming what is missing instead of leaving a stack trace to appear in a sidecar log minutes later. Tables that are NOT prefixed with the app name are reported as a warning rather than a failure — they still get created, but `purge` will not remove them and a reinstall would find them already there. ── `purge`, next to an uninstall that deliberately keeps everything ── Uninstall is careful on purpose: every table and row survives so a reinstall puts you back where you were. That is what makes it safe to try, and it left no way to get to a CLEAN machine. A second install finds the directory (so the catalogue hides the plugin) and the tables (so push has nothing to do, and a broken schema stays broken). So a different verb, a different word, its own confirmation. It reads the table list from the schema BEFORE deleting the source, uninstalls (stopping the sidecar — nothing may be running against tables about to vanish), drops, deletes the directory, then regenerates the barrel unconditionally, since a barrel naming a directory that no longer exists is what stopped a server booting yesterday. The table list comes from the plugin's own schema, not from `like '<app>_%'` — that query eventually drops somebody else's table, because the prefix is a convention nothing validates and `music` is a prefix of `music-videos`. The prefix is used as a GUARD instead: a schema module could export `users`, and anything outside the plugin's own namespace is refused rather than dropped. ── Verified as a full loop on this machine ── purge offscale → table dropped, directory deleted, barrel regenerated → the marketplace offers it again → install → cloned, ssh checked, "schema: applied in 2072ms (1 table verified)", dock pinned, mounted, sidecar started. I left offscale INSTALLED here rather than restoring the half-state it was in (no install row, table present, sidecar running). tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ad5a5563e |
clicking a plugin works again, and offscale gets its icon
Two regressions and one long-standing hole, all found by actually opening the page. ── Clicking a row did nothing. That one is mine ── Rows link to `?selected=<app>`, and they still pointed at `/plugins`. My redirect was `<Navigate to="/marketplace" replace />`, which DROPS the query string — so a click went to `/plugins?selected=music`, redirected to a bare `/marketplace`, and the detail pane sat on "Select a plugin." Both halves worked; the redirect threw away what the link had just set. Rows now point at `/marketplace` directly. The redirect also preserves `search`, because old bookmarks carry the same param and losing it is exactly what made this wrong the first time. ── Offscale's icon 404'd since it shipped one ── Assets were published to `public/plugins/<app>/` at INSTALL and nowhere else. Offscale was installed before it had an `assets/icon.png`, so the directory never existed and the tile drew a 404 permanently — nothing short of uninstalling and reinstalling would have fixed it. "Reinstall the plugin to see its new icon" is the same bad answer as "restart the server to see it", which the route right next to this already refuses to accept. Publishing now happens on every mount. It is a copy of files that still exist in the plugin's own source, so re-running it is free and cannot lose anything — the property that already makes unpublish the one thing uninstall may delete. A failure is logged, never thrown: an icon is not worth refusing to mount over. Published offscale's by hand too, so the fix is visible without waiting for a restart. Verified 200 through the running server for both plugins. ── The third report was not a bug ── "No connection to the marketplace" is the catalogue working correctly: all three plugins are already on disk here, and `router.ts` drops catalogue entries whose plugin is installed, because offering to install something installed is worse than showing nothing. With nothing left to offer, a working store and a broken one look identical on that screen. Nothing in the logs, which is what a reachable store looks like — the client only speaks when it cannot reach one. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bfb20b6916 |
the /offscale route comment, and the store learns about ssh
The platform's own reference to offscale was one stale comment naming /headscale as an example route pair. Everything else that matches "offscale" in src/ is a test fixture using it as an example app name, or the bundled catalogue entry. Re-synced the marketplace so the catalogue advertises the ssh dependency the manifest now declares — which is the point of the sync being cheap to re-run: a plugin's manifest changing is not a marketplace deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba20155a09 |
the store's README shows in the platform, not just on the website
A plugin's page in the Marketplace screen now renders the README the marketplace holds — the same text the public site shows, in the pane where somebody actually decides whether to install it. Fetched by the SERVER and proxied, though CORS would have allowed the browser to ask directly. An Officer client is routinely somewhere its server is not: a phone on the tailnet, a laptop with the store blocked. The server already had to reach the marketplace for the catalogue, so routing through it means the README appears in exactly the cases the plugin list did — rather than in a strictly smaller set, which is the kind of difference nobody reports and everybody notices. Separate request from the list, because the list endpoint deliberately carries no READMEs: three of them is ~38KB of markdown on the request that draws the tiles. This one fires when a plugin is opened. Absence renders nothing at all — no error, no empty state. A plugin without a README is normal and a store that is down must not put a failure message on a page whose job is installing things. Under `/plugins` so the existing permission covers it: reading the store and installing from it are the same privilege, and a second key would be a second thing to grant for no separate decision. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
53742a256f |
marketplace.officer.dev is live, and the plugin repos have READMEs
The site is up on HTTPS with a Let's Encrypt certificate, proxied to :9100, and serving the three plugins out of `officer_marketplace`. An Officer install with no configuration now reads its catalogue from it. ── What is running ── PM2 entry added to THIS machine's ecosystem only. Deliberately not in officer-setup's CORE_PROCESSES: the marketplace is one website officer.dev hosts, not something every install should run — a copy on every machine would serve a stale catalogue on a port nobody asked to open. nginx (NPM host id 7) → 172.20.0.1:9100, with a ufw rule for the docker bridge. That rule is not optional and its absence does not look like a firewall problem: without it nginx HANGS until proxy_connect_timeout rather than being refused, which is how the apptest hosts wasted an hour a few days ago. ── READMEs ── `music`, `offscale` and `example` now have proper READMEs, written against their own code and pushed to their own repos. They render on the plugin's page in the store and are what a self-hoster reads before installing. The example's is a tutorial as much as a shop window — it is the document a plugin author reads first, and every convention in it was verified in `discover.ts` rather than restated from memory. `music`'s remote was HTTPS with no credentials on this box, so it could not be pushed; switched to SSH like the other two. The catalogue still advertises HTTPS — `marketplace-sync` converts — which is the property that matters, since an `ssh://` clone URL only works on the machine that published it. ── Writing them was the cheapest audit available ── Reading each plugin against its own documentation turned up ~20 real defects, all recorded in TODO.md and none fixed here. The pattern worth naming: offscale is still branded "headscale" in user-visible panel names and in a persisted terminal session id, and its PLUGIN.md states outright that the ACL assistant is deliberately unwired — while `sidecar/assist.ts`, `claude-proxy.ts` and `PolicyAssistant.tsx` all ship. Documentation that confidently describes the opposite of the code is worse than none. Also fixed the docs on the frozen lockfile: both CLAUDE.md and bunfig.toml named `bun install --no-frozen-lockfile` as the escape hatch, and bun 1.3.14 has no such flag — it is ignored and the install fails anyway. tsgo clean, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b65b83f1f |
the plugins screen becomes /marketplace
`/plugins` redirects rather than 404s: the word is in the dock, in the docs and in whatever anyone bookmarked, and a route that worked yesterday returning nothing reads as the feature having been removed. The API keeps its name — only the screen moved — so the permission entry lists both paths, because a client-side redirect is still a route that has to resolve before it can send anyone anywhere. Also: the fetch stubs in marketplace.test.ts needed `as unknown as typeof fetch`. Bun's `typeof fetch` carries `preconnect`, so a bare stub is not assignable — tests passed while tsgo did not, which is the wrong way round to find out. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
921fbff9fe |
marketplace.officer.dev: the catalogue becomes a service
The plugins page read a TypeScript constant. It now reads a public API backed by its own database, and falls back to that constant when it cannot. ── Four pieces ── `src/databases/marketplace_db` — a second Postgres database, `officer_marketplace`. Two tables, split by owner and lifetime: `plugins` is the registry (app name, developer, clone URL) and is authoritative; `plugin_metadata` is a CACHE of what each repository says about itself, so the website can render without cloning three repos per page load. Keeping them apart is what makes the cache safe to be wrong — a refresh can never lose the fact that a plugin exists. `scripts/marketplace-sync.ts` — fills it from the plugin repositories. Reads each `manifest.ts` (imported, since a manifest is TypeScript), derives `has` from the directory rather than trusting a declaration, and rewrites SSH remotes to HTTPS: `example` and `offscale` had `git@gitea…` origins, which would have handed every installer a URL that only works on the machine that published it. `src/servers/marketplace/server.tsx` — its own process, its own port, two GET routes and no others. Not part of the platform server: it has no users, no sessions and no request it answers differently per caller, and mounting a public unauthenticated surface inside the process holding mail, a wallet and passkeys means writing permission-gate exemptions that later turn out to be too wide. `src/apps/marketplace` — the public site. A window, deliberately: nothing here installs anything, because installing means cloning a repo and starting a process on someone else's machine. Every plugin page ends at "install it from your own Officer". ── The platform side is the part that had to be careful ── `fetchCatalogue()` now answers live → cached → bundled, in that order, and never throws. The plugins page is where an owner manages plugins they ALREADY have, so the store being down must not take that page with it. Network responses are validated per entry — not against a hostile marketplace, which has already won if the owner installs what it lists, but against a wrong one: a proxy serving HTML, a half-deployed API, a field renamed on one side. A non-https `source` is refused outright because that string reaches `git clone`. 8 tests on the degradation path. Verified end to end against the running service: live catalogue, cached answer surviving the store dying, bundled fallback. ── One thing found on the way ── `bunfig.toml` and CLAUDE.md both document `bun install --no-frozen-lockfile` as the way to change a dependency. Bun 1.3.14 has no such flag — only `--frozen-lockfile` — so the documented escape hatch does not work. Sidestepped by not making marketplace_db a workspace member (relative imports, zero lockfile change), and `bun install` is frozen-clean again. Worth fixing the docs. tsgo clean, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c66042e42 |
todo: background job logs appeared inside an open file in the editor
Reported on the production host, as a member, with a `bun dev` job streaming: the runner's output rendered into the buffer of a file open in the code editor. The file on disk was intact and nothing was saved, so it is a display-layer mixing rather than a write path. Not reproduced and not diagnosed — logged with the two places the streams can meet (`CodeSurface` is shared between the task panel and the editor; the editor keys by path while panel stores key by panelId through useGlobal/usePanelMemory) and a first move that distinguishes them: whether the buffer is polluted or only the render is, which Cmd+S and a tab reopen answer immediately. Recorded rather than guessed at further. An editor that can show content the file does not contain is a correctness problem even when it never writes, because the instinct on seeing it is to save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bf44a4cfab |
resolving a member's task symlink needs their identity too
One layer below yesterday's fix, and it looked identical on screen: the job was
found, then declared to have written nothing.
`loadBackgroundTask` resolved the located path with `realpathSync`, as the
platform. Resolving a link needs `x` on every directory above it, and
`/tmp/claude-<uid>` is `drwx------` — so it threw EACCES on an INTACT link, and
the catch below it reported that as a dangling symlink and returned null. The
tray polls, gets `{kind:'pending'}`, and shows "Nothing written yet" forever.
The lookup fix could not have exposed this: it made the file findable, which is
precisely what let execution reach the line that then threw it away.
`realpath -e` rather than `readlink -f`, because `-e` requires every component to
exist — that keeps a genuinely dangling link reporting as one instead of
resolving to a path that is not there. Verified against symlink, plain file,
dangling link and missing path: `realpath -e` and `realpathSync` agree on all
four, so the owner path is unchanged.
Audited the rest of claude-sessions.ts for the same shape. Every remaining
mention of `statSync`/`readdirSync`/`Bun.Glob` in that file is a comment
explaining why it is NOT used.
Live coverage extended: the member resolves their own link, the platform gets
null. Skips without OFFICER_TEST_MEMBER.
tsgo clean, frontend builds, 865 pass / 7 fail unchanged, 7 skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6bee6b856e |
a member's background job can be read and stopped
A member started `bun dev`, watched it run, and got no logs at all — then
`{"stopped":false,"reason":"no such task"}` from the Stop button. Two symptoms,
one cause, and it is the same one `read-as-user.ts` was written for.
`findTaskOutput` globbed `/tmp/claude-*/…` as the platform's own uid.
`/tmp/claude-<uid>` is `drwx------` (checked, not assumed), so for a member that
directory cannot even be traversed. A glob that cannot traverse does not throw —
it returns an empty iterator. Both callers read that as "no such task": the log
view showed "Nothing written yet" forever, and Stop refused an id that was
perfectly real.
Every other filesystem access in that module already goes through the as-user
helpers. This one path never did, and it was invisible for as long as only the
owner used chat.
So the lookup moves into `findPathAs`, and Stop runs as the member too — both
halves of it. `fuser` reads `/proc/<pid>/fd`, which only the process's own user
and root may open, so as the platform it would have found no pids and reported
the job already stopped; the kill after it would have been cross-user EPERM,
swallowed by a catch. Neither would have said anything.
── The exit code is ignored, and that is the point ──
The first version of this fix located the file and threw it away. `find` exits 1
when it cannot descend into something, and searching `/tmp` from ANY identity
hits another account's `drwx------` — it prints the match on stdout first, then
returns 1. Treating that as failure reproduced the original bug through a new
mechanism. Caught by testing the owner path against a real task file rather than
assuming the refactor was behaviour-preserving; a missing root still exits 1 with
empty stdout, so absence still reads as absence.
Owner and member now take the same `find`, deliberately: one set of matching
semantics, rather than a glob for us and a find for them, which is how a bug ends
up reproducing on one account and not the other.
Verified on the owner path here against a live task file. The member path is
covered by `read-as-user.live.test.ts` — same opt-in shape as
`spawn-as-member.live.test.ts`, asserting the two identities DISAGREE, which a
world-readable tree would not satisfy. It cannot run on a machine with one
account, so it skips here and needs OFFICER_TEST_MEMBER on the host.
tsgo clean, frontend builds, 865 pass / 7 fail unchanged, 3 new skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
23bfacf412 |
a missing plugin can no longer stop the platform booting
A server died on `Cannot find module '../../../../plugins/music/db/schema'` before a line of its own code ran. The pull that broke it was the one untracking `plugins/` — git deleted those directories from the working tree, and the gitignored `plugin-schemas.gen.ts` still named them. The barrel being wrong is NORMAL. It is written by `db:push` and by installing a plugin, and nothing rewrites it when a directory goes away by other means: a pull, `git clean -xfd`, `rm`. It describes a machine, not the project, which is why it is gitignored in the first place. The defect was not the stale file. It was that a file allowed to be wrong sat in the import graph of the process that must always start — via `db.ts` → `schema.ts` → the barrel. An import graph is resolved before `main`, so this was not catchable, loggable or survivable. Everywhere else the plugin system already holds the line: `refreshPluginMounts` isolates a bad plugin, `discoverPlugins` reports a broken manifest instead of throwing. This was the one remaining path that could take the whole server down, and it took it down. So the two consumers get their own entry points. `schema.ts` keeps the core tables and is what the runtime imports. `drizzle-schema.ts` re-exports it, adds the generated barrel, and is read only by drizzle-kit — which regenerates the barrel before every run and can afford to fail loudly on a genuinely broken plugin. `drizzle.config.ts` points at the new one. Verified by reproducing it: with the barrel naming a plugin directory that does not exist, importing the server's db module now succeeds while importing `drizzle-schema.ts` still fails with that exact error — so the runtime is immune and drizzle-kit's strictness is intact. First attempt at that test was worthless, since I "poisoned" the barrel with a path that existed here; redone with an absent one. `bun db:gen` then loaded the new entry and emitted both core and plugin tables (music_*, offscale_servers), which is what proves `db:push` still works. Pulling this is enough to boot a server in that state — the stale barrel stops being loaded rather than needing to be repaired. tsgo clean, frontend builds, 865 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
08d725bfb3 |
the background job button wears the amber a running job wears
Not white. `toneText.warning` — hsl(43 96% 56%), the same amber a running task chip shows two rows up, so the control and the thing it produces read as one feature rather than two. Taken from `toneText` rather than typed as `text-warning`, so it cannot drift from the chips it is matching. The name is misleading in this one place: nothing here is a warning, it is just where that colour lives in the palette. Dialog title icon follows, same reason as the glyph did. tsgo clean, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a78541a341 |
circle-play for the background job button, at full strength
White rather than muted, 18px rather than 16. Its neighbours on that row are pickers — they report what is already selected — and this is the only control there that does something, so it should not be wearing the same recessive grey as a label. The glyph was a radio tower, chosen because a play button implies one-shot. That reasoning was too clever: most of what gets started here is a long-running process, and a watcher is still something you press start on. Recognisable beats precise at this size. The dialog's own title icon changes with it. A button and the thing it opens wearing different glyphs is a small lie about them being the same feature. tsgo clean, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f580d845ac |
running jobs sit ahead of finished ones in the tray
The tray was newest-first, full stop. So: ask for a long-lived job, talk for a while, and every short-lived job an agent starts along the way pushes it one place further right — until the one thing still running is the one thing off the end of a strip that scrolls. Exactly backwards. What falls off the end should be what you are not watching. A finished chip is a receipt: it exists so a notification that landed while you were reading something else is still there when you look up. A running chip is a live thing you may need to open or stop. Ordering them together by age treats those as the same kind of object, and the strip's overflow turns that into the receipts slowly evicting the job. So `orderTasks`: running first, newest-first preserved within each group. Two passes rather than a comparator, so it does not lean on sort stability and reads as the partition it is. Applied after the FINISHED_KEPT cap, not before. The cap asks which finished chips are still worth keeping, which is a question about age; where they then sit is a different question. Pinned chips inherit the same order, so a pinned running job leads its row too. Cosmetic, as asked — no behaviour changes, nothing new is hidden or dropped. 5 tests, tsgo clean, frontend builds, 865 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2b70a5700e |
the code editor stops underlining correct code
Not spell check — Monaco's own TypeScript worker, misconfigured in two
independent ways. Both are visible in `monaco.contribution.js:272`, where the
defaults are `{ allowNonTsExtensions: true, target: Latest }` and
`{ noSemanticValidation: false }`.
── 1. It was reading JSX as TypeScript ──
`tsWorker.js:93` decides a file's script kind from the SUFFIX of its model URI:
`ts`/`tsx`/`js`/`jsx`, and otherwise falls back to TS. `<Editor>` was given no
`path`, so its model was `inmemory://model/1` — no dot, no suffix, TS. JSX is
illegal in a `.ts` file, so every tag in the file was a syntax error. That is
the wall-to-wall squiggling in the screenshot.
Passing the real path gives the model a `.tsx` URI. `jsx: ReactJSX` is set too;
both are needed, since the extension alone still errors without the option.
── 2. It was type-checking against a file system it cannot see ──
Semantic validation is now off, and this is the part worth stating plainly.
Monaco runs in the browser; the files are on the server behind the file API.
There is no `node_modules` to resolve `react` from, no `tsconfig.json` to read
paths out of, no way to follow a relative import. So every import resolves to
nothing — which is where "Cannot find module './logo.svg'" came from. Not a
mistake in the file: a checker with no file system.
Wrong errors are worse than none. They teach you to ignore the colour, and then
the one real problem underlined the same way goes unread. Monaco's own authors
already made this call for JavaScript — `javascriptDefaults` ships with
`noSemanticValidation: true`. This extends it to TypeScript for the same reason.
Syntactic validation stays ON: an unclosed brace needs only the buffer in front
of it, and with the extension finally right it is answered correctly.
Real semantic checking would mean shipping the project's dependency types into
the browser — a language-server-over-websocket feature, not a config change.
tsgo clean, frontend builds, 860 pass / 7 fail unchanged. Not verified in a
browser: I cannot see the editor render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e2f7a3f091 |
the preview reloads for jobs you started, not jobs the agent started
Yesterday's change bumped `background-job:changed` on every `task:started`. That was wrong in a way the commit message admitted and then waved through: agents start background jobs constantly while working — a test run, a build, a long grep — so the reload trigger had moved from "every edit" to "every incidental job" without getting meaningfully rarer. Same churn, new cause. The jobs worth reacting to are the ones started from the background-job dialog, because that is where somebody says "run the dev server". ── Arm and claim, not an id ── The dialog cannot name the task it is about to create: it sends a prompt, the agent works out the command, and the harness assigns the id. Nothing links the two but sequence. So the dialog ARMS, the next `task:started` claims the arm and records its id, and from then on completions are matched by id — however long the job runs. One arm, one claim: a second job in the same turn is not ours. The arm expires after 30 minutes, because the preamble tells the agent to ask before arming anything ambiguous, so a legitimate claim can be minutes late — but an abandoned conversation would otherwise leave the arm sitting there to be taken by whatever an agent started next. A job stopped by hand is adopted regardless of who started it. That is the one place the rule bends, and it bends towards the user having clicked something. Module state rather than a channel: it is read inside the websocket handler to decide whether to bump, and nothing renders from it. `createBackgroundJobOrigin` is exported so tests get a fresh instance instead of a reset that production code could also call. Still over-triggers within that set — any dialog-started job reloads every preview. Nothing observable links a preview's URL to a job, since the parent cannot see inside a cross-origin iframe. 9 new tests, tsgo clean, frontend builds, 860 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ced2983a6 |
the preview reloads when a job restarts, not when a file changes
Restarting the dev server leaves the iframe holding a dead HMR socket. The page keeps rendering and looks completely alive — it has simply stopped receiving updates. So the agent edits a file, nothing moves, and there is no way to tell whether the edit failed or the connection did. Reported after exactly that sequence: stop, start, ask for a change, watch nothing happen. A stale preview that is indistinguishable from a working one is worse than an obviously broken one, because you debug the wrong thing. ── Which signal, and why not the obvious one ── `preview:refresh` already exists and is bumped on every turn that touched a file. Subscribing to it would fix this and ruin the feature: a reload throws away scroll position, form state, and whatever route you had navigated to inside the app — on every edit. HMR exists precisely so that does not happen. So a new channel, bumped on `task:started` and on task completion. A job coming up may be a dev server coming back; a job going down may be it going away. Both are moments a preview pointed at it is now lying. Neither fires on an edit — this is a few times a session, not once a keystroke. Stated rather than hidden: it OVER-triggers. Any job starting reloads any preview, not only the one serving it. That costs one reload on an unrelated watcher, which is the cheaper of the two errors. The parent process cannot see inside a cross-origin iframe, so there is no way to ask whether its socket is alive. The job lifecycle is the closest observable proxy, and it is exact for the case that prompted this. tsgo clean, frontend builds, 851 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b21d4b8bd |
duration is one checkbox, because two of the three options were the same thing
"This session" meant nothing. The backend deliberately holds the agent process alive while a background job is running, so closing the browser does not end one — "this session" and "until stopped" were one behaviour wearing two labels, and offering both implied a difference that does not exist. So there is one real choice, and it is a yes/no: bound it by time, or run until stopped. [ ] Stop after [60] minutes Unchecked is the default and the common case. Checked exits when the time is up whether or not it fired, which is the only thing a clock can usefully mean here. This also removes a note that had become false. The dialog used to say "all of these end if the session does" — written from the process tree, where the job is a descendant of the agent process. True as far as it went, and wrong in practice because of the keep-alive. It now says what each choice actually does instead of what the ancestry suggests. Still true, and unstated because it belongs somewhere else: `pm2 restart officer-claude-code` ends every running job. That is a deploy-time fact, not something to put in a composer. tsgo clean, frontend builds, 851 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a9b162615 |
a stopped job stays stopped, and Stop is a real button when expanded
Two things from stopping one by hand. ── It restarted itself ── Pressing stop produced the exit notification, and the agent read that as a crash: it brought the server back up, then wrote three paragraphs theorising that "something outside this conversation sent that SIGTERM" and counting it as the third external touch on the repo today. It was not wrong about the signal. It could not have been right about the cause, because the stop button did not exist when the preamble was written, and the preamble said only "report" — leaving what to do with an exit entirely open. So it now says: on exit, one line, and STOP. Do not restart. Do not diagnose. Do not theorise about what else on the machine might have touched it. A job I stopped that comes back on its own is the opposite of a stop button — and an exit is at least as likely to be me as a failure now that pressing a button is how you end one. Deliberately not written as "ignore exit code 143": the phrasing is about intent, not signal numbers, so it holds whether the job dies by SIGTERM, SIGKILL or its own hand. ── Stop, labelled, in the expanded panel ── The chip keeps its icon; the panel gets a bordered button that says "Stop". The panel is where you go to decide whether something should keep running, and the control that ends it should not be a glyph you have to hover to identify. Both appear only while the task is running. tsgo clean, frontend builds, 851 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
048e718b21 |
a stop button on running background jobs
Killing a job meant asking the agent in words — a whole turn to say "please stop
that", and only possible while the agent is responsive. Now it is a button on the
chip.
── Finding the process ──
The platform does not own these processes. Claude Code starts them and nothing
records a pid. What it has is the file the job streams into, and the job is still
holding that file open — so `fuser` gives the pid, and the file is resolved
through the SAME scoped lookup the read path already uses: the id is validated
against TASK_ID_RE, then matched only under this caller's own directories. An id
that resolves to nothing kills nothing.
Verified end to end rather than reasoned about: a stand-in job holding its output
file open, `fuser` finding it, `{"stopped":true}`, process gone.
SIGTERM, not SIGKILL — a dev server with children should get the chance to take
them down with it. SIGKILL orphans them and leaves the port bound, which is worse
than a stop that takes a second.
── Two things it deliberately does not do ──
It does not dismiss the chip or write a `stopped` status locally. The real end
arrives the way every other end does — the harness sees the exit and the
transcript gets the row — so faking it would be a guess that usually agrees with
reality and sometimes does not. A stop that fails leaves the chip running, which
is the truth.
It does not offer to stop a SUBAGENT. Those write a `.jsonl` transcript rather
than holding a log open, so there is no fd to trace; it says so instead of
pretending.
`Square`, not `X`. X on the same chip means "close this", and confusing "hide the
chip" with "kill the process" is the one mistake worth designing out — which is
why stop appears only on running tasks and dismiss only on finished ones.
tsgo clean, frontend builds, 851 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3512b06d5e |
background jobs: start silently, say one line
Asking for `bun dev` produced a preface ("Starting it exactly as specified…"), the
call, a read-back of the output to confirm, a paragraph explaining what resolved
to what, and then two unsolicited notes. Five kinds of output for a request whose
whole appeal is that there is nothing to discuss.
The preamble already said "tell me in one line" and was talked past, because it
described the ending without forbidding the rest. Now it names each thing:
- nothing before starting — no preface, no restating the ask, no announcing
that the instructions are about to be followed
- one line after: what is running, its id, how to stop it. Not notes, not
observations, not what was chosen or why, not anything noticed in passing
- a question is the ONLY thing allowed to come first
And it no longer reads the output back to check the job started. That was work
the harness already does: if it fails, the exit arrives on its own — which is the
entire reason for using the tracked mechanism. Verifying by hand adds noise to
the one case where nothing is wrong, and adds nothing to the case where something
is.
Silence stays conditional, not absolute: an unclear condition must still be
settled in conversation before anything is armed.
tsgo clean, frontend builds, 851 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f0c93fa716 |
a background-job button in the chat composer
A `Radio` button between the harness and model selectors opens a small composer:
you write one sentence, the agent receives it under a preamble of mechanics that
never appears in your bubble.
── Four orthogonal choices, not modes ──
who writes the command a literal one, or an intent the agent works out
is there a condition always PROSE — a condition worth watching is a sentence
and worth arguing about before it arms, not a predicate
typed into a box
does firing end it exit-is-the-notification, or act-and-keep-going
how long this session / N minutes / until stopped
Ticking the watcher forces prompt mode rather than allowing a combination that is
then rejected on submit.
── The rule that inverts, which is why the checkbox exists ──
"Stay silent while waiting" is right when output is DEFERRED to exit — it all
lands in context at once. For a watcher that never exits, output streams to the
pinned pane and never reaches context, so a line per event is the point rather
than a cost. The preamble says the opposite thing in each case; with one rule it
would have suppressed the only signal a persistent watcher gives.
── Hidden without hacks ──
`sendWithHiddenPrefix` reuses the display-text argument `sendPrompt` already
takes: the agent gets preamble + text, the transcript shows text. A NEW
`hiddenPrefix` field rather than the existing per-prompt `prefix`, which carries
attachment references — those are part of what you said and belong in the bubble.
── What it says out loud ──
"If what I asked for is not completely clear, ASK ME FIRST." A wrong condition
fails silently — never fires, or fires constantly — and is discovered days later.
Arming it late costs a minute.
And the duration note does not promise what it cannot keep. Traced the process
tree: the job is a great-grandchild of the claude process, so `pm2 restart
officer-claude-code` kills every watcher on the box. "Until stopped" therefore
reads "all of these end if the session does" rather than "forever". A job that
must outlive deploys belongs under PM2, not under a chat.
cwd comes from the panel, so nothing has to be typed — the dashboard rooting from
earlier today.
tsgo clean, frontend builds, 851 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5c40d4423d |
app preview: a panel that points an iframe at a running app
A dashboard can now hold chat, the code editor, the file browser and the app itself — the last piece of working on a project without leaving Officer. ── Why this is ~200 lines and not 560 ── The version deleted on 2026-07-31 spawned `bun dev` in a project directory, found a free port and proxied it under `/api/dev-server-proxy/<id>/`. That prefix is what cost the money: HTML, JS and CSS rewritten; a script injected into the page to monkey-patch fetch/XHR/WebSocket so runtime URLs got the prefix too; a Referer fallback for chunks that resolved at the wrong depth; a websocket bridge for HMR. All of it faking an origin. On a machine with a proxy in front — nginx on edge.pertento.ai, wildcard subdomains — the app is ALREADY served at the root of its own origin, and the browser is already on the tailnet. So there is nothing to fake and no server side at all: no route, no socket, and therefore no permission-registry entry. The old code is not obsolete and is not being thrown away. An install with no proxy in front still has an app on localhost that the browser cannot reach, and that is exactly what it solves. It is at `188b45c1^`; the note is in TODO.md. ── What it does ── Holds a URL per panel, renders it, reloads it, opens it in a real tab. The URL lives in dashboard state so it survives a reload, and is shared LIVE between header and body — the header writes and the body reads, so claim-once would mean typing a URL changed the header and nothing else. The reload counter is deliberately not persisted: it is a signal. Reload remounts the iframe by key rather than calling `contentWindow.location .reload()`, which throws cross-origin. Replacing the element re-requests the URL, which is the same thing from the outside and the only move the parent is allowed. `allow-same-origin` is set and has to be: without it the framed app gets an opaque origin and no localStorage, and it authenticates with a JWT it keeps there. `normalizeUrl` accepts a bare host as https and refuses javascript:, data: and blob: — an iframe src with `javascript:` executes in the PARENT's context. The owner typing in their own panel is not an attacker; it costs one line and removes the category. 5 tests. ── Two iframe facts worth knowing before testing ── Storage is PARTITIONED by top-level site, so the login inside the panel is separate from the login in a tab: expect to sign in once, in the panel. A page can refuse to be framed (X-Frame-Options, CSP frame-ancestors) and renders blank with only a console error. If that happens it is nginx's config, not this code — which is why "open in a new tab" is in the header rather than left to copying the URL out. tsgo clean, frontend builds, 851 pass / 7 fail — +5 new, same 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
20aa0cc880 |
a deleted file stays open, says so, and can be saved back
Keeping the tab when an agent deletes the file was deliberate — closing it would
let an `rm -rf` of a build directory take unsaved work with it — but it was only
half the idea, and the missing half made it worse than useless:
the tab looked identical to a live file, and
you could not SAVE it, because the editor thought nothing had changed
So the buffer was the only copy left, and the one action that would rescue it was
locked until you typed a throwaway character to force the dirty flag. Reported
from a real session.
VS Code's answer, and the right one: a file that has left the disk is DIRTY,
because the buffer now differs from what is there (nothing).
markMissing missing: true + isDirty: true — save works immediately
the tab name struck through, amber icon instead of the dirty dot
markSaved clears `missing`, because saving PUT IT BACK — leaving the warning
up would make the fix look like a failure
reloadFile clears it too, for a file that reappears from elsewhere
That makes "save recreates the file" an informed recovery rather than the accident
it was: the tab says the file is gone before you press it.
Not a bug, and checked rather than assumed: the report that a tab showed "my
previous content on top of the agent's" was the file genuinely containing both.
On disk it reads `- Sidecasadasrs assumed up` — `Sidecars` with `asadas` typed
into the middle of it. The buffer had been saved over the deleted file, the agent
then edited that file, and the editor was showing the truth.
tsgo clean, frontend builds, 846 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f534b818b9 |
resuming a conversation needs its transcript, not just its id
Switching to an existing chat showed the right title and an empty pane. The session was correct; none of what was said in it was there. Passing the id as `initialSessionId` seeds the socket's resume handshake, so the AGENT continues the right conversation — but nothing ever fetches the messages, so the UI has none to draw. The two halves disagreed silently: send a turn and it would have carried on correctly above a history you could not see. The /chat screen has always done it properly, and this now does the same: `resumeSessionId` + `initialMessages` + `paginate`, from `GET /chat/sessions/:id?limit=20`. Only the tail is loaded — transcripts get very long — and `total`/`offset` tell the chat where that window sits so scrolling up pages the rest in. ── Fetched BEFORE the chat mounts, not after ── `useChat` seeds its state from the options given on its first render, so loading afterwards is too late: the messages would arrive at a hook that has already decided it has none. The panel therefore holds off rendering while a transcript is in flight, which also avoids showing an empty conversation for a moment and then replacing it — that reads as history being lost and then found. A stale id (transcript deleted or pruned) opens a NEW conversation rather than an empty pane, because an empty pane is indistinguishable from a conversation whose history vanished. This is the same class as the cwd bug an hour ago: the id was right, the wiring was right, and the thing that makes it visible was never asked for. tsgo clean, frontend builds, 846 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5998492dcd |
a chat panel opens on the last conversation, not a blank one
A panel that has never chosen now continues the newest conversation in its directory. Opening a dashboard onto a folder that already has history and starting a blank chat is the wrong default — the history is why you are there. It is also the case that prompted this: the conversation existed before the panel did. ── Three states, because two collapse wrongly ── undefined never chosen -> the newest conversation in this directory null "New chat" pressed -> a fresh one string that conversation `undefined` and `null` were the same value until now. Left that way, pressing New chat would set null, the default would read it as "nothing chosen", and the panel would snap straight back to the newest conversation — the button would look broken. ── Two races, both closed ── The panel does not mount until the session list has answered. Otherwise it starts a NEW conversation, the list arrives, the default applies, and the chat remounts onto something else — discarding anything typed or sent in the gap. And the id recorded on mount is only ever a REAL one. `chat.sessionId` is null for a moment while a resumed session connects, and writing that null would turn "never chosen" into "asked for a new chat", so the NEXT reload would open blank — a bug that only appears one reload later, which is the kind that gets blamed on something else. Asking for a new conversation is something the header says explicitly; this effect only reports what the harness decided. Once the default is applied the harness reports its id and it is recorded, so it stops being derived and becomes this panel's choice — "latest" the first time, "last used" thereafter. tsgo clean, frontend builds, 846 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8de6088546 |
the session list was asking for a directory that does not exist
Your conversation was there the whole time. The dropdown was empty because I sent
the wrong kind of path.
what the header sent -Tests-platform no such directory
what is on disk -home-pastilhas-Tests-platform 1 transcript
Two conventions live next to each other and I used the wrong one. The file
endpoints take a HOME-RELATIVE path (`/Tests/platform`) — that is what `cwdToPath`
produces and what the file browser and editor pass. `/chat/sessions` takes the
ABSOLUTE one, because Claude's folder name is
`cwd.replace(/[^a-zA-Z0-9]/g, '-')` applied to the directory a turn actually ran
in, and a turn runs in a real directory.
`cwdOf` uses the query value verbatim (`query('cwd')?.trim() || home`), so nothing
rejected the relative form — it just resolved to a slug for a directory that has
never existed, and answered an empty list rather than an error. The failure had no
symptom beyond "no conversations", which is also what a genuinely empty directory
looks like.
Joined against `useChatPwds().defaultCwd` — the caller's home as the SERVER sees
it, and the same value `cwdOf` falls back to — rather than guessing at where home
is a second time.
Verified end to end: `~/Tests/platform` now resolves to
`/home/pastilhas/Tests/platform`, whose slug is `-home-pastilhas-Tests-platform`,
which is a directory that exists and holds the transcript.
tsgo clean, frontend builds, 846 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
465435ccf1 |
a chat panel picks from the conversations in its dashboard's directory
A dashboard rooted in ~/Tests/platform now has a session picker in its chat panel's header: every conversation started in that directory, newest first, plus New chat, rename and delete. ── Almost all of this already existed ── `GET /chat/sessions?cwd=` returns conversations for a working directory, merged across Claude and OpenCode. It is not a registry Officer maintains: Claude writes 每 transcript to `~/.claude/projects/<cwd-slug>/`, so "the conversations belonging to this dashboard" is how they already sit on disk — the same ones the terminal `claude` sees. `useClaudeSessions` already wrapped list, rename and delete for the /chat screen. The panel header is new; nothing under it is. ── The one design correction ── Which session a panel is on is NOT `usePanelMemory`. That hook claims its saved value once and then ignores the bag, because the editor and file browser are the only writers of their own state and hearing it echo back would tell them to reopen what they just closed. Here the header writes and the body reads — two components with no props between them — so the value has to stay LIVE. Claim-once would have meant picking a session changed the label and nothing else. It is the same dashboard-state bag, read live, so it still survives a reload. Switching remounts the chat on its key, which is what the existing comment calls "the honest way to say this is now a different conversation": `useChat` seeds from the first `initialSessionId` it sees and the resume handshake sends whatever is in that state on connect. A NEW chat is `null` until the harness names it — only the harness can — and the id is recorded on arrival, so "new chat, say something, reload" returns to that conversation rather than a third one. Deleting the conversation on screen points the panel at a new one rather than at a transcript that no longer exists. ── Left alone, and one thing to know ── `agent_panels` is untouched: a named agent still wins the session choice outright, since it is one conversation forever by definition. The picker is for the ordinary case. The email screen mounts ChatPanelWrapper too, so its chat now also remembers its session instead of starting fresh each visit. That is a behaviour change to a screen this work was not aimed at — better in my view, but say if not, and it scopes to dashboards in one line. tsgo clean, frontend builds, 846 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a871c055bf |
fix: one empty write and the editor could never save its tabs again
Reported not working, and the database said exactly why:
{"editor":{"p-1786832482923-77":{"open":[],"active":null}}}
The write worked. It wrote EMPTY, on the first mount before any file was open,
and from then on the panel was locked:
- an empty saved value is truthy
- the restore effect bailed on `open.length === 0` WITHOUT marking itself done
- the write guard was `if (restored && !didRestore.current) return`
so every later save was blocked by a restore that had already decided not to run.
Self-inflicted, and the guard was mine from two commits ago.
Underneath it was a second ordering bug that would have bitten even with the first
fixed: the restore is ASYNC — it reads each file — while the write effect runs on
the same mount, sees an empty `files`, and persists over the list it is in the
middle of reopening. Marking the restore done synchronously would just have moved
the failure.
Both are now one flag. `restoreDone` is state, set when the restore COMPLETES —
including when it completes by having nothing to do — and the write waits on it.
Nothing is persisted until the restore is genuinely finished.
Cleared the poisoned `editor` bag from the live row so the next test starts clean
rather than against state the old code had already wedged.
Worth naming the pattern: the guard conflated "there is saved state" with "a
restore is pending". Those differ exactly when the saved state is empty, which is
the state every panel starts in — so the bug was guaranteed rather than unlucky.
tsgo clean, frontend builds, 846 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
74495c25ab |
a file browser panel reopens where you left it
Navigate into a subfolder of a dashboard's directory, refresh, and you were back
at the top. The /files screen keeps its folder in the URL and never had this; a
panel had nowhere to put it.
── Made shared, because chat is next ──
The editor's tab memory from the last commit was one panel's version of a pattern
the terminal had already invented. Rather than write a third, `usePanelMemory` now
holds the mechanics — claim once, key per panel, drop on real close — and each
panel supplies only a payload. The editor moved onto it and lost 50 lines; the
file browser stores `{ path }`; chat will store whatever chat needs.
Restoring is SYNCHRONOUS, in the initial state rather than an effect, and can be:
`WorkspaceView` renders nothing until dashboard state has loaded, so the
remembered folder is already there on the first render. An effect would list the
base folder first and visibly jump to the right one.
── Two ways it could strand you, both closed ──
A remembered folder OUTSIDE the panel's base is discarded on read. A dashboard
scoped to ~/Tests must not reopen at ~/other because it was rescoped or the folder
moved. The existing guard catches that after the fact; this stops it being
requested.
A remembered folder that no longer EXISTS was the new failure this feature
introduced — the listing throws, a toast appears, and every reload lands in the
same dead folder, because that is what was remembered. The first listing now falls
back to the base and rewrites the memory. Deliberately first-listing-only: later
in a session a failure is far more likely transient, and bouncing someone out of a
deep folder over one bad response is worse than the error they can already read.
`files` registered in PANEL_STATE_PREFIXES, without which the PATCH is a 400.
tsgo clean, frontend builds, 846 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
95b83fb29b |
a code editor panel remembers its tabs across a reload
Refreshing the page emptied it. You came back to "Open a file from the explorer to
start editing" with no trace of what you had been doing — two tabs open, one of
them the notes you were writing in.
The terminal solved this shape years ago: a `panelId -> sessionId` map kept in
dashboard state, keyed `ws-{prefix}-{dashboardId}`. This is that map with a
different payload, deliberately the same shape so there is one pattern rather than
two to compare. `editor` is registered in PANEL_STATE_PREFIXES — an unmatched key
is a 400, not a silent 200, which is the branch that exists precisely so a new
prefix cannot vanish quietly.
── Paths, not contents ──
The disk is the truth. Restoring a remembered BODY would show a version that may
be hours stale, or resurrect a file something has since deleted. Each path is read
fresh on restore, in the order you left them, and one that no longer reads is
dropped rather than opened as an empty buffer named after a missing file.
The cost, stated rather than hidden: unsaved edits still do not survive a reload.
They did not before either — the whole tab went. Keeping them means persisting
buffers, which has its own hard question (the disk AND the buffer have both
changed — now what?) and is not smuggled in here.
── The two ways this goes wrong, both guarded ──
Reading the saved set more than once would fight the user: every tab they open
writes into that same bag, so a later read arrives as an update telling them to
reopen what they just closed. It is claimed once, on the first render that has it.
Writing before restoring would persist an empty list over the state it is about to
read — first render, no tabs yet. The write is guarded on the restore having run.
Also: a panel closed for good drops its entry, via `usePanelClose`, which fires on
a real close and not on a drag, a swap or a mobile panel switch.
Per PANEL, not per dashboard, so two editors side by side keep separate strips.
Not wired on the /code-editor screen: `?file=` already survives a reload there.
tsgo clean, frontend builds, 846 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
dc3841a877 |
the code editor refreshes itself, and Cmd+S works
The file browser has updated itself at the end of an agent turn for a long time,
because it listens to `files:refresh-signal`. The editor did not, and sat showing
whatever it read when you opened it.
Now it does, plus a button in the Explorer header for a change made somewhere the
signal does not reach — a task, a terminal, another machine. Two triggers, one
behaviour:
the tree re-lists, including every EXPANDED folder. A collapsed one has
nothing on screen to be stale, and re-listing all of them on every
turn is a burst of requests with nothing to show for it.
open files re-read, and only those with no unsaved edits.
── What a refresh must never do ──
Overwrite unsaved work. A dirty tab is skipped entirely: it keeps its edits and
its dot, and does not participate. "Reload everything" is the obvious
implementation and it would silently destroy the thing you were in the middle of.
A DELETED file keeps its tab too, rather than closing underneath you — content
intact, and saving writes it back. An agent doing `rm -rf` on a build directory
must not take your unsaved notes with it.
Content arrives through `executeEdits` with a full-range replace (checked in
@monaco-editor/react rather than assumed), so a refresh keeps your cursor and your
undo history. `setValue` would have reset both.
── The bug that made all of that pointless ──
Cmd+S did nothing. Monaco keeps the callback it was handed at mount, and `<Editor>`
only remounts when the file PATH changes — so the closure was built on the render
where the file was OPENED, with `isDirty: false`, and `handleSave` returns early on
a clean file. Open, type, save: no toast, no error, dirty dot stays. It reads
through a ref now.
That one matters here specifically: a file you cannot save stays dirty forever, and
a dirty file is skipped by every refresh. The feature above would have appeared to
work and then quietly stopped for the file you were actually editing.
Inferred from the code, not observed — worth confirming by hand.
tsgo clean, frontend builds, 846 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a7046704d5 |
a code editor panel opens at its dashboard's folder
A dashboard scoped to ~/Tests/platform showed exactly that in the editor's header, above an explorer listing the HOME directory. The two halves of one panel disagreed because only one of them asked: `CodeEditorHeader` reads `cwd` from the workspace, and `CodeEditorPanelWrapper` — three lines — never touched it. It now passes `cwdToPath(cwd)` as `initialPath`, which is what `FileTree` lists from. Still no `urlState`: a dashboard can hold two editors and one `?file=` would drive both. That reasoning, and the shape of the fix, come from FileBrowserPanelWrapper, which had it right already — which is why the file browser was correct in the same dashboard. ── One conversion instead of three ── `cwd` → path existed twice, spelled differently, and the two disagreed: cwd.slice(1) '' -> '' '/abs' -> 'abs' both wrong cwd.replace(/^~\//, '/') '' -> '/' '/abs' -> '/abs' both right An empty cwd gave the file endpoints an empty string, which lists nothing rather than failing — the quiet kind of wrong. Writing a THIRD copy for the editor is what prompted collapsing them: `cwdToPath` now lives beside `useWorkspace`, with 6 tests including the tilde-inside-a-name case that a careless regex would rewrite. Swept the rest rather than assuming this was the only one: all three panel wrappers now read cwd, and the terminal already did. tsgo clean, frontend builds, 846 pass / 7 fail — +6 new, same 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
15cc74bb1e |
creating a dashboard raced itself into a 500
"Could not save that change — ws-layout-platform was not written — Internal Server Error", on a dashboard that had in fact been created. `upsertDashboard` was update-then-insert, which is a read-then-write race, and creating a dashboard walks straight into it: the client sends `workspaces` (the list) and `ws-layout-<id>` (the layout) as two SEPARATE PATCHes, and both call this. Both found no row, both INSERTed, and the loser hit the primary key. The surviving row and the failing insert were stamped one millisecond apart in the log, which is what gave it away — this was never a name collision. One statement now, `ON CONFLICT DO UPDATE`. `set` carries only the fields the caller passed, so whichever request lands second writes its own column and leaves the other's alone. The two orderings become equivalent instead of one of them fatal. Proved against the real table rather than reasoned about: two plain inserts, concurrent fulfilled, rejected <- the reported 500 two upserts, concurrent fulfilled, fulfilled The `where` on the DO UPDATE is not decoration. `dashboards.id` is a GLOBAL primary key fed by a slug, so two accounts naming a dashboard the same thing collide, and an unguarded upsert would let the second silently take over the first's row. Verified on a scratch table that a non-owner's upsert is DROPPED rather than applied or thrown — the row is left untouched. This machine has one account, so that path could not be exercised end to end; the SQL semantics were. That is still the wrong data model — the real fix is a composite key — but it now fails by refusing rather than by handing someone another account's dashboard. tsgo clean, 840 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9700959c9e |
todo: files routing — what landed, and the half that did not
The URL model is done; rows are still opaque-click divs, which was the original finding and is now easier to fix than it was. Recorded honestly rather than leaving an entry that reads as untouched work. Also recorded the pattern behind the four bugs this produced: the list of overlay params has an owner, the acts that clear it do not, and every site deciding for itself is what let the breadcrumb ship wrong an hour after the rule was written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7359af2f55 |
the breadcrumb closes the pane too
Half a fix shipped last commit. Navigating by double-clicking a folder row closed
the open pane; navigating by clicking a BREADCRUMB did not — so the browser moved
and the viewer stayed, showing a file from a folder you had left. It failed to
load, because the name is now resolved against the folder in the path and the file
was not in this one.
The two do not share a code path. A row goes through `setCurrentPath`, which I
stripped. A crumb is a `<Link to={hrefForPath(...)}>` and never calls it — and
`hrefForPath` still carried `location.search` verbatim. Same navigation, two
outcomes, decided by which control you used.
Both strip now. Audited every site that builds a folder URL:
setCurrentPath strips — rows, search results, Show location
hrefForPath strips — breadcrumb links
legacy redirect KEEPS, deliberately: `?path=X&view=Y` is one old link meaning
"this folder, with that file open", and honouring it is the
whole point of the redirect
widget openFile sets its own view; the search it discards belongs to the screen
it is navigating away from
This is the third bug in a row from the same root — the query string having more
than one writer. The list of overlay params has an owner as of the last commit;
the ACTS that clear it still do not, which is why a `<Link>` could quietly opt out
of a rule the function next to it enforced.
tsgo clean, frontend builds, 840 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0dc88b5119 |
leaving a folder closes what was open in it
A pane shows a file from the folder you were in. Walking into another folder kept it open, so the listing said one place and the pane said another — and the URL carried a `?view=` that no longer belonged to its own pathname. Navigating now drops the overlay params. Only those: anything else in the query is left alone, because `useGlobalQueryString` puts real state there and a wholesale reset takes it with it. ── The actual fix is that the list has an owner now ── Which params belong to the overlay was a private array inside `useFileViewerPanels`, so every other site that touched the query worked from memory. That is exactly how `onCloseViewer` came to wipe the folder along with the pane, and it is the thing TODO.md flagged as the reason a fifth handler would get it wrong the same way. `VIEWER_PARAMS` and `withoutViewerParams` now live in `files-route.ts` — a leaf with no imports, so the hook and the browser can both depend on it without depending on each other. Three readers, one list. Writing this caught a fourth site immediately: `setViewerParams` had been REPLACING the whole search since the folder left the query string, so opening a file would have discarded global UI state. It clears the overlay and keeps the rest, like everything else now does. 3 more tests, including that every key is covered rather than the obvious two. tsgo clean, frontend builds, 840 pass / 7 fail — +3 new, same 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7e31564c15 |
?view= survives a page load — it never has
Opening or refreshing a URL with `?view=` gave you a bare file browser and a URL
that had quietly rewritten itself to drop the param. Reported as "has never
worked", and it never had.
`useFileViewerPanels` deleted EVERY ephemeral key in a mount effect, filed under
"clear stale ephemeral params". So the page came up, the effect stripped `view`,
and the pane never opened. It arrived incidentally in
|
||
|
|
98ba604aaf |
?view= is a name, not a second copy of the path
before /files/archive/Tests/platform/docs?view=%2Farchive%2FTests%2Fplatform%2Fdocs%2Fagent-coordination.md after /files/archive/Tests/platform/docs?view=agent-coordination.md The pathname already says which folder you are in. Repeating it in the query said the same thing twice, and the encoded copy was the unreadable half. ── Absolute is still read, just never written ── `resolveInFolder` takes either form. That is not politeness, it covers three real cases: links made before this change, `?ephemeral=` pointing at a generated file that may not live beside the original, and anything genuinely outside the folder being browsed. `relativeToFolder` only shortens a DIRECT child. Something nested deeper stays absolute rather than becoming `sub/b.md`, because a relative value containing a slash reads like a path when it is meant to be a name, and there is nothing to gain from the ambiguity. A near-miss is handled too — /docs2 is not under /docs. ── The one that would have broken ── The widget's Recent and Pinned lists opened `/files?view=<absolute>` and let the screen work it out. With the folder in the pathname that now means "open this file while sitting in home", which is not where the file is. It navigates to the file's folder and names it from there. All three providers resolve, not just the viewer: `ephemeral` and `ephemeral2` are paths too, and leaving them literal would have made the short form apply to whichever panel happened to be first. 6 more tests on the round trip, including the near-miss and the nested case. tsgo clean, frontend builds, 837 pass / 7 fail — +6 new, same 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
559560de48 |
the folder is the URL: /files/Tests/test folder
`?path=/Tests/test_folder` becomes `/files/Tests/test%20folder`. Directories only —
an open file stays `?view=`, because it is what is on top of you rather than where
you are, and that split is what keeps closing a pane from moving you.
── The landmine, found before building on it ──
`server.tsx` 404s any path ending .js/.css/.map/.png/.svg/.ico/.webmanifest/.woff2
that is not in `build/`, so it never reaches the SPA. A folder named `logo.png` is
unusual but legal, and it would have deep-linked to a 404 that reads as a broken
link rather than a rule.
Narrowed to SINGLE-SEGMENT paths, which is safe on evidence rather than by
loosening a rule: the bundler emits exactly three files, all at the root of
`build/`, so a real asset never has a second slash; and multi-segment assets
(`public/**`, `/plugins/<app>/icon.png`) are served by Bun's `routes` table before
this handler runs.
── Encoding is the part that breaks quietly ──
Per SEGMENT, never the whole path — encodeURIComponent would eat the separators.
14 tests over the round trip, because a filename may carry anything but `/` and
NUL. The ones that matter: `#` truncates a URL at the fragment if unescaped, a
literal `%` is mistaken for an escape, and malformed input must not throw
(decodeURIComponent('%zz') raises URIError, and that would take the screen down).
Parentheses stay readable — encodeURIComponent leaves them alone — so
`Kanban (copy 2)` survives as `Kanban%20(copy%202)`.
── The rest ──
- `/files` → `/files/*`. The bare route still matches with an empty splat, so every
existing link, the dock entry and DEFAULT_DOCK_PATHS keep working.
- Breadcrumbs take a `To` instead of a query string and are still real links.
- Old `?path=` URLs redirect once, in place, with `replace` so it is not a
back-button step. A bookmark from this morning still lands where it meant to.
- `setViewerParams` stopped copying `path` across, because there is nothing to
copy — the folder is the pathname and is untouched by a search-param write.
Checked and needing nothing: the page-title rule is `startsWith('/files')`, and the
dock uses react-router NavLink without `end`, so `/files/Tests` keeps the tile lit.
tsgo clean, frontend builds, 831 pass / 7 fail — +14 new, same 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4874c21dfd |
search results take you to a thing, they do not open it
Clicking a file result navigated to its folder AND opened a viewer pane in one gesture. Search is how you find where something is; opening it is a separate decision — so a search for a name you were merely locating left a pane you then had to close. file → its containing folder, with the row selected directory → inside it, which is what opening a folder means The file is selected rather than merely listed, so the thing you searched for is picked out of whatever else is in that folder. That makes clicking a FILE identical to Show location — one behaviour reached two ways — so the menu stops offering it twice under two names. "Open folder" now appears only on a directory result, which is the only kind that has somewhere to go into; a file gets Show location alone. `ExternalLink` went with it, since nothing was left importing it for. `handleShowLocation` is unchanged and still reveals a DIRECTORY's parent, so the two menu items on a folder are genuinely different: Open folder goes in, Show location goes to where it sits. tsgo clean, frontend builds, 817 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a84eedd126 |
zip from the folder, extract on double-click, show location
Three things from testing. ── The download zip carried the path to itself ── Selecting files in ~/Tests/test_folder and downloading produced an archive whose contents were `Tests/test_folder/…`. `POST /download` spawned zip with `cwd: rootDir` and root-relative paths, so the whole chain from home went in. Nobody selecting three files is asking for the route to that folder to be part of what they downloaded. Now it zips from the items' own folder using bare names. A MIXED selection keeps the old form, because two `notes.txt` from different folders would collide without the paths keeping them apart — the UI only selects within one folder, so that branch is a safety net rather than the case. `/compress` already did it this way; this is the same fix on the endpoint that predates it. Verified against real zip: `Tests/a.txt` → `a.txt`, `Tests/test_folder/b.txt` → `test_folder/b.txt`. ── Double-clicking an archive extracts it ── It opened a pane that rendered "Archive file" and the name you could already see in the listing. That is a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a second double-click makes a sibling rather than overwriting. The menu's Extract stays — a double-click is not a discoverable way to learn what will happen. ── Show location ── A search result answers "here is the file"; its path underneath raises "where does this live", and clicking only ever answered the first. This goes to the containing folder, clears the search and SELECTS the row, so a folder of forty files does not leave you hunting. A directory hit reveals its parent — where is this means the folder it sits in, not the folder it is. tsgo clean, frontend builds, 817 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5ef83ef0d0 |
say WHY a file could not be opened, and offer the thing that still works
Opening a large text file showed "Failed to read file" and nothing else. The
server had already said exactly what was wrong — "File too large to read
(max 5 MB)" — and the viewer threw it away: `.catch(() => setError('Failed to
read file'))` ignored its argument.
`hono.ts:297` renders a CustomError as `ctx.text(error.message)`, so that sentence
arrives verbatim as `ApiError.message`. It is shown now.
Guarded rather than trusted, because error paths are where surprises live. A body
is only displayed when it looks like prose: an HTML page from a proxy, a JSON
envelope from a route that sets `returnValue`, an empty string or something
300 characters long all fall back to the generic line. Checked all seven cases.
The error state also stopped being a dead end. Being told a file is too big, with
nothing to do about it, leaves the reader stuck — but the bytes are still there
and only the UTF-8 READ is capped; `/raw` has no limit. So it offers "Download
instead", which is the action that still works.
This is the same class as the empty-file bug found earlier today: the viewer knew
the answer and did not pass it on.
tsgo clean, frontend builds, 817 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8e77b9198d |
todo: the files app needs a routing pass
Recorded next to the code-editor entry, since the owner wants both after the current test pass. The value is the verified state rather than the request: rows and search results are opaque-click divs, the breadcrumb is the one surface that already does it right and is the model to copy, a panel's folder is deliberately not addressable, and the ephemeral-vs-location param split has no single owner — which is what let onCloseViewer wipe the location along with the overlay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31d8d72527 |
closing a file no longer sends you home
Open ~/Tests, double-click a file, edit it, save, close the pane — and the browser
was back at ~. Reported from a real session, not found by reading.
`onCloseViewer` was `setSearchParams({})`, which wipes the WHOLE query string. The
folder lives in `?path=`, so closing the viewer deleted where you were along with
what was on top of it. It also discarded anything `useGlobalQueryString` had put
in the URL.
The rule was already written down one file away, in
`useFileBrowserApp.setViewerParams`: "`path` is not one of them — it is where you
are, not what is on top of it — so it survives." Only the OPEN path honoured it.
Close now deletes the ephemeral keys by name, which is exactly what
`onCloseEphemeral`, `onCloseEphemeral2` and `onCloseChat` directly below it have
always done — it was the single outlier among four handlers.
Checked the other exits before calling it fixed: there is one `onClose` wired to
the viewer, every other handler already used the targeted form, and the
mount-cleanup effect deletes the same keys off `prev` rather than replacing the
string. This was the only wholesale wipe in the repo.
tsgo clean, frontend builds, 817 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|