430346e14bd7183e900cc82b7af0693257221f6f
1421
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|