Commit Graph
505 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 03abdeb246 an Update button, so a server is not a terminal away from current
Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the
other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every
time.

Refuses a dirty tree and a non-fast-forward, the same two refusals as pullPlugin and for the same
reason: this checkout is somebody's working copy on at least one machine, and a button that discards
uncommitted work is worse than no button. Fetches `origin HEAD` rather than a branch name, because a
checkout made before the default branch was renamed is on one the remote no longer has — the trap
plugins/update.ts already hit, and it bites hardest on the installs most in need of updating.

It deliberately does not chain into anything. A pull touching bun.lock means an install, which the
frozen-lockfile rule exists to keep deliberate; one touching a schema means db:push against a live
database. So it REPORTS what the update implies — install, db:push, restart or rebuild — from the file
list, and leaves the decision with whoever pressed it. The commits pulled are shown, because "it
worked" is less useful than knowing what arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 04:22:30 +00:00
pastilhasandClaude Opus 5 e44c0c7665 the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two
panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history,
and nothing regressed, because none of it had been running.

What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches
a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the
browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so
the registry stops describing one thing in the other's words, and it drops the /browser frontend route
it could no longer serve.

Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering
panels through appType placeholders — a plugin supplies its own route and panels through
Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover
from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no
longer had: dead at runtime, invisible to the compiler.

The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where
it lives now. It was written for whoever did this extraction, and this is that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:05:11 +00:00
pastilhasandClaude Opus 5 fcdbf02649 the platform stops knowing what VNC is
Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and
apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem.

sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants
and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four
functions and a dozen message types in the platform, describing a feature that is not the platform's —
the rule CLAUDE.md states most plainly, and its clearest breach.

What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a
command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever
it likes through them and nothing here has to be edited when it adds a message.

The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind
a plugin permission cannot have and never will, because a permission nobody can delegate is a
contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied
to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not
exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the
key from its list.

Also gone: the app store's `vnc` entry, which would have offered an older second way to install the
same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13.

The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request".
It now uses the generic door, which is the better instrument — that file is about the blast radius of
a disconnect and never had anything to do with VNC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:42:19 +00:00
pastilhasandClaude Opus 5 43aeffd9d3 api keys: revoked ones stop filling the screen, and can be forgotten
Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks
like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a
list whose job is "which of my keys still work" answered with a wall of dead ones.

Nobody decided that. Revoke was implemented and the list was never taught the difference.

Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys"
toggle rather than merely dimmed, because on any account in use for a while they outnumber the live
ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an
account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a
bug in the list.

DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not
null` clause is in the statement rather than checked before it, so there is no ordering in which a
live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key
cannot be honoured again either way, since authentication matches a hash and a deleted row has none.
What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it
was killed — which is why it stays a second, deliberate step rather than what revoke does.

Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key
works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:22:29 +00:00
pastilhasandClaude Opus 5 16dd67a3ca plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it
stays off the more certainly it is stale, and enabling would quietly run whatever was current on the
day it was switched off — with every step reporting success, which is exactly how the jellyfin
install on alpha re-applied a two-day-old checkout.

Deleting no longer has that problem, since it takes the directory with it and installing is always a
fresh clone. That left disable/enable as the one remaining way to run old code without being told.

The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is
refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty
working tree — every plugin directory on this machine is one — an unreachable forge, or a directory
copied in rather than cloned. Enable is the reversible half of a pair with disable, and a switch that
will not turn back on because a network is down is worse than one running last week's code. So the
refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails
falls back to the code already on disk, which was running before it was disabled.

Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back
having reported "not refreshed", and the error that surfaces is about not being installed rather than
about git — which is only possible if the refusal was stepped past instead of returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:34:38 +00:00
pastilhasandClaude Opus 5 287cec39d0 plugins: enable, disable, delete — and uninstall is gone
Three states all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-still-
there. Nothing on screen distinguished them, and the one that mattered was invisible — an uninstalled
plugin kept its directory, which silently decided what the next install would run. That is how a
jellyfin install on alpha re-applied a checkout from two days earlier while reporting success at
every step.

So uninstall stops being a verb. What it did survives as the first half of deleting: it stops the
sidecar, removes the install row and the PM2 entry, unpublishes the copies, and revokes grants naming
permissions that are about to stop existing.

Delete is offered only on a plugin that is not running — disable is one reversible click, so a live
plugin is always two deliberate steps from gone. It opens a confirmation carrying a checkbox, "also
delete all its data", unticked and never inherited from last time. Unticked, the directory goes and
the rows stay, and they appear in the orphan list further down the same screen. Ticked, the tables go
with it, which is the only way to a machine that has never had the plugin.

The checkbox picks between two ROUTES, not a body flag on one: a flag can be added to a request by
anything that already knows how to call the safe verb, and the difference here is somebody's rows.

Grants are revoked either way, which is load-bearing until the permissions work tomorrow: a grant
naming a key the platform no longer recognises made the whole permissions screen unsaveable, because
the page submits every grant it loaded.

Also corrected two rationales that the change made false — the runbook's "uninstall never destroys
data" verification table, and npm-deps.ts explaining directory survival in terms of uninstall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:19:32 +00:00
pastilhas a6e61d0c26 user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich
next — needs to know who the platform's users are. it now gets told.

DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles`
arrive on an unauthenticated socket and are believed, so a capability claimed
there would be a plugin granting itself one. the manifest was reviewed when the
owner installed it, and sits beside `permissions` where "this plugin will be
told who your users are" belongs — on the install screen, not discovered later.

PUSHED, not requested. a sidecar has no authenticated way to ask, so a request
could only be answered, never authorised. a push inverts that: the platform
decides who gets it, and a caller registering under a name with no such
manifest gets nothing.

`listProvisionableUsers` is a hand-written projection — id, email, username,
name, role — and NOT `select *`. `users` holds `password` and `os_user`, and
`getUsers()` returns both, so a plugin reaching for the obvious function would
get every account's password hash. naming the columns means adding a secret to
the table cannot silently add it to what plugins receive. pinned by a test that
asserts the key set, not just the absence of today's secrets.

the owner is excluded. they bypass every permission check and already have
whatever upstream account they want; provisioning them is at best a no-op and
at worst a second admin nobody asked for.

sent after the ack rather than inside it, so registration never waits on a
database query — a slow postgres would otherwise stall every sidecar's startup,
and none of them need the list to start.

and re-sent on every account create and delete, because the registration copy
is a snapshot: a plugin still holding a deleted user would keep an upstream
login alive for someone the platform has removed. read once per broadcast, so
two plugins cannot be told different things about the same moment.
2026-08-20 19:18:37 +00:00
pastilhas 6db26ae439 nudger: "not generating" was permission to start a SECOND agent
it fired once, in anger, and put a second claude with bypassed permissions
into a repository another agent was mid-build in. nothing was lost only
because that agent noticed the collision and stopped rather than committing
over the work. it also found a real bug while it was there, which is not a
defence of any of this.

the guard was exactly backwards. it asked one question — "is this session
generating" — and treated NO as permission. but a session the sidecar no
longer holds is also not generating, and `startClaudeTurn` given one of those
does not resume it, it starts a NEW one. so the check that was meant to
prevent interference is the thing that guaranteed a second agent, and the
likeliest state for an overnight target — garbage collected after 30 idle
minutes — is precisely the dangerous one.

three questions now, and only one answer is permission:

  not live at all          refuse AND disarm. nudging it would create an
                           agent, which is not what a schedule attached to a
                           conversation means and never was.
  live but generating      refuse, keep the schedule. it is working.
  any other live session   refuse, keep the schedule. two agents with bypassed
                           permissions in one tree is not something to arrange
                           on purpose.

the third is blunter than it needs to be — the sidecar does not report a
session's directory, so it refuses on ANY other live session rather than on a
comparison it cannot make. that will decline some safe nudges. given what the
precise version cost, the blunt one is the right trade until the sidecar can
say where a session is running.
2026-08-20 03:17:39 +00:00
pastilhas 12c5e51ce4 nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a
turn, and at 3am nobody types the next message. this is the schedule that does.

the door already existed. `startClaudeTurn` takes `ws: null` — extracted that
way so command-hq could drive a turn over http — so this is a second caller of
an existing path rather than a new way into a live session. i had said the
platform lacked that; it did not.

the rule that decides the design: never speak while the agent is working. so
`every_seconds` is a FLOOR, not a metronome. when a nudge comes due and the
session is generating, the row is left alone and reconsidered next tick, which
means it fires as soon as the turn ends rather than sleeping through to the
next hour. a strict alarm would skip an agent that finished four minutes late
and leave it idle for the other fifty-six.

`isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be
mid-turn, and interrupting one is the failure this exists to avoid.

matched on both sessionKey and claudeSessionId: officer's handle and the
transcript's filename are different ids, only the agent holds the mapping, and
a schedule stored against one would silently never match a live row keyed by
the other.

one nudge per (user, session) — a second schedule for one conversation is two
alarms for one sleeper. counted even when the turn fails to start, so a
refusal cannot become a tight loop. disables itself at the ceiling rather than
leaving a spent row armed. the tick starts in server.tsx rather than on first
use, because a schedule armed before a restart has to survive one.
2026-08-20 03:07:28 +00:00
pastilhas 49f7761f53 skills: publish the index, leave the body on disk
the inverse of an affordance, and deliberately so:

  affordance   body always compiled in, description never sent
  skill        description always compiled in, body read on demand

an affordance is a fact about the surface — short, and every conversation
needs it. a skill is reference material that is dead weight in a conversation
about something else. so what reaches the prompt is the index: name, when to
reach for it, and the path. measured on the first one: 608 characters in the
prompt against 6,070 bytes left on disk.

no protocol. progressive disclosure is normally a harness feature — claude
code discovers .claude/skills, opencode has its own conventions, the next one
will have a third — and pointing at a file needs none of that, because every
harness worth running can already read a file. the cost is that the agent has
to CHOOSE to open it, which is what the description is for and why a vague one
makes a skill useless rather than merely verbose.

the first skill is the email database, moved out of tools/email-db/TOOL.md
where NOTHING has been reading it: the loader parses frontmatter and discards
the body, so that schema and its eight queries have never reached any claude
code session. written for pi, inert since pi left.

it carries two things the schema alone gets wrong, both measured against the
real 19,097-message database rather than copied from the old doc: `read` is
populated on 93 rows, so the documented unread query returns essentially the
whole mailbox; and `labels` is a comma-separated string with real values like
`updates,inbox`, so `labels = 'inbox'` misses most of the inbox.

plugins can ship skills, published and unpublished on install like affordances
and notification catalogues. flat namespace unlike those two — a skill's
directory name is the path an agent reads, and `officerdev/gitea/SKILL.md` in
a prompt is noise. a skill has no authority to usurp, so nothing needs
protecting.

wired to claude code. opencode still gets NOTHING — its runner sends no system
prompt at all, and session create takes only a directory. that needs its own
channel and a guess would be worse than the gap.
2026-08-20 01:09:07 +00:00
pastilhas ab214d1a9e rescan: count affordances, and say what a rescan cannot do
the header's rescan walks ITEM_TYPES, and affordances are not one — no
directory per item, no canonical file — so they were invisible to it. write a
new affordance, press the button, and nothing in the response mentions it.

counted by LOADING them rather than counting .md files, so the number means
"affordances the agent will actually receive" and not "markdown files
present": one with no body is skipped by the loader and should not be reported
as though it were working.

counting is as far as this can honestly go, and the toast now says so.
affordanceBlock() is read when a chat session is CREATED, so a new affordance
is already live without a server restart — for the NEXT session. nothing can
retrofit one into a conversation already running, because its system prompt
was assembled when it started. the button's value here is "the file is on disk
and the platform can see it", which was the part genuinely unknowable from the
UI.

also creates the affordances directory, so it is discoverable on a machine
that has never had one, the same way the item dirs already are.
2026-08-19 23:35:11 +00:00
pastilhas bf57201a11 bun run doctor, and provisioning checks its own work
the tenth install in a row with something new wrong, and the pattern behind
them is not the individual faults. every step in provisionOsAccount is
deliberately non-fatal — a member whose account provisioned fine should not be
rolled back because their shell config failed. that is right, and it means
"nothing reported an error" is a far weaker statement than it reads as.

the acl-mask bug was the sharp version: a clean provisioning run, every named
entry correct, and an account that could reach nothing. it was found by a
person three days later, through a file browser message three steps removed
from the cause.

so provisioning now VERIFIES rather than assuming, and it verifies by making
the same readdir the file browser makes. deliberately not by inspecting the
acl: reading getfacl and reasoning about masks is exactly how this was missed,
because the acl looked right. a check that tests something adjacent to the
failure is how you get a green report and a broken server.

a failed check is reported FIRST, ahead of keys and shells — an account that
cannot read its own home is broken in a way that working ssh does not make up
for.

and `bun run doctor` for the nine servers that already exist: same check
across every account, plus the one that is invisible by construction — a
confined grant held by an account with no linux user, which sits in the table,
shows as ticked in the UI, and resolves to nothing.

read-only, so it is safe on a production box. exits 1, so it can gate the end
of a setup run. same posture as assertPermissionTotality, except this reports
instead of refusing to boot: one broken member must not stop the platform
serving everyone else.
2026-08-19 23:07:47 +00:00
pastilhas 069dd4b23f provisioning: re-assert the acl mask, last
on a fresh server an Admin granted files, chat and terminal reached none of
them. the grants were right, the linux account existed, the named acl entries
were on the home — and the file browser still raised EACCES.

a posix acl mask is not independent of the mode bits. chmod recomputes it from
the GROUP bits, so a directory closed to 700 gets `mask::---`, which clamps
every named entry to nothing. the platform's grant survived as
`user:pastilhas:rwx #effective:---`: present, correct-looking, and inert. it
reads as a working acl in every place anyone thought to check.

ensureOsUser already knows this and orders itself correctly — chmod, then
setfacl, with a comment saying why. verified that order is right by running
both ways on the affected host: chmod-then-setfacl gives mask::rwx,
setfacl-then-chmod gives mask::---.

what it cannot account for is the steps AFTER it, each of which creates a
directory and then closes it: .ssh at 700 from the key setup, .config and
.local from the claude installer. each inherits the default acl at creation
and clamps its own mask a moment later. the correlation on the live host was
exact — every 770 directory had mask::rwx and every 700 one had mask::---.

so the mask is re-asserted at the very end of provisionOsAccount, recursively,
and nothing else is touched: the named entries were never wrong, and
re-granting them here would hide it if they ever were.

non-fatal if it fails, like every other side effect in that function. but it
is the difference between an account that works and one that only appears to.

repaired on the affected server by hand first, and confirmed: home readable by
the service user, zero `effective:---` entries left under it.
2026-08-19 23:03:14 +00:00
pastilhas 46d621c91b chat notifications reach the phone, on the same rule as the bell
the browser bell and phone push are now two subscribers to one signal. both
ask shouldNotify(); neither owns the rule.

deliberately a second subscriber rather than a branch inside notify-delivery.
the two channels share only the question "does this account want to hear about
this" — the browser needs an open tab and sends the chat's real title over a
loopback socket on the owner's own tailnet, the phone needs no tab and must
send NO title, because that hop goes through apple. one function doing both
means an `if (isPush)` at every step, and the first thing to go wrong is a
title leaking because the branch was missed in one place.

two codes, officer/core:chat.turn-ended and chat.input-needed. the route is
written out with its scheme rather than derived from the target: the hub has
no chat screen — chat is Butler, a sibling app — so these deep-link OUT over
officer-chat://, and two of the suite's twenty-one schemes already break the
officer-<app> pattern (officer-videos, officer-readaloud). deriving that works
until it silently does not.

`thread` interpolates now, so `chat:{id}` groups per conversation. a static
thread forces a choice between "three pings from one chat collapse" and "two
busy chats stay apart", and the wrong answer is that every chat is the same
chat.

dropped officer/core:agent: chat.turn-ended supersedes it and its route
pointed at officer://chat/{id}, a hub screen that does not exist.

the banner cannot name which conversation. a chat's title is content and
content does not travel; it says what happened, and tapping opens the chat,
which is where the name lives.
2026-08-19 22:00:17 +00:00
pastilhas 72d327e360 bell: scope the preference to a user, and make every_turn mean every turn
review of 73cdc2cb before building the phone half on it. three changes, and
the third is the one that matters for mobile.

1. "every end of turn" was silent when the agent asked you something.
   the two kinds are disjoint at the source: turn-ended fires on
   result/error/stopped, input-needed at a plan or question gate — which PARK
   the turn and never emit a result. matching them one-to-one meant whoever
   picked the most inclusive setting got nothing for the most urgent event.
   every_turn is a superset now.

2. the preference is keyed on (user_id, session_id), not session_id alone.
   the route skipped an ownership check on the reasoning that a preference for
   a session you cannot reach is inert. it was not: delivery targets the
   userId the sidecar reports while the preference was read globally, so any
   account holding `chat` that knew a session uuid could set 'none' on someone
   else's conversation and switch off their notifications, or every_turn to
   make their phone buzz all night. surrogate id plus a unique INDEX rather
   than a composite primary key, which drizzle-kit re-creates on every push.

3. the decision moved out of the browser socket into notify-decision.ts.
   it lived inside notify-delivery.ts, which was fine while the browser was
   the only listener and stops being fine the moment push subscribes to the
   same signal. two copies of the rule drift, and the symptom is a bell and a
   phone disagreeing about one conversation — close to undebuggable from
   either end. both channels ask the same function now, and the rule is pure
   so it is tested without a database.

the rest of the commit reviewed well: the ws upgrade is authenticated AND
permission-gated, there is a registry entry so totality passes, the list query
is batched rather than per-row, reads are defensively caught, and
Notification.requestPermission is requested lazily on opt-in.

alpha was missing the table entirely — reads degraded via .catch but the PATCH
would have 500'd. pushed there too.
2026-08-19 21:53:11 +00:00
pastilhas 5cba1b65a6 notify: only "the app is gone" deletes a device row
reported from the field on the first real push: it failed with BadDeviceToken
and the server deleted the registration. the build was production-signed and
the row said sandbox — a configuration mismatch, with the device and the app
both perfectly fine.

pruning there destroys the evidence. the phone re-registers on next launch,
the row comes back looking healthy, and the next send fails identically; you
can lose a day to that loop without ever seeing what is actually wrong.

so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune —
apple and google are saying the app is uninstalled. BadDeviceToken,
DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and
SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check.
everything else is transient and still counts toward three strikes.

INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed
MESSAGE — including a non-string in `data`, which is the mistake this codebase
is most likely to make and already has a test for. deleting on it meant one
bug in the payload builder would wipe every android device from the registry
on a single send, presenting as "android push stopped working" with nothing
left to explain why.

misconfigured is also not counted as a strike. not deleting is not enough on
its own — a persistent mismatch would reach three failures and delete the row
anyway, which is the same bug with a slower fuse.

the apns log now names the mismatch rather than the reason code, since the
code alone sends people to look at the device: it prints the row's environment
and topic and says to check the SHIPPED artefact rather than the intermediate
build, which is exactly where this one hid.
2026-08-19 21:25:20 +00:00
pastilhasandClaude Opus 5 44a3d5f655 fix(chat): give AskUserQuestion a real answer channel
The harness answered every question itself, instantly, with "The user did not
answer the questions." — before the card had painted, let alone been read. The
agent then reasoned about a refusal that never happened: on 2026-08-19 it asked
the same question three times in a row while the answers sat on the screen.

The web UI already worked around it by rendering the card ungated and sending
the click back as an ordinary chat message. That got an answer through, but the
agent read it as a new instruction arriving from nowhere rather than as the
reply to what it asked, and nothing could distinguish a live question from one
answered last week — every replayed transcript rendered its old questions as
clickable.

A PreToolUse hook is the fix, and it has to be a hook rather than canUseTool:
under bypassPermissions — which is every session here — the SDK shadows the
callback and never calls it. Measured against 0.2.59: a bypassing session's
AskUserQuestion reached the hook and never reached canUseTool. Hooks run
regardless of permission mode.

The hook parks on a promise keyed by the harness's own tool_use_id, and returns
`deny` with the human's answer as the reason — which the harness feeds back as
the tool result verbatim. "Deny" reads wrong and is right: what it denies is the
tool's ability to answer for the user, and what replaces its output is what the
user actually said. Held open for 150s in testing with the turn completing
normally, so a person has time to think.

Keying on tool_use_id rather than an invented id means no new durable event: the
questions are already in the session log as an ordinary tool:start, and the
answer arrives as its tool:result. One source of truth, replay included — which
is what re-enables gating the card on `output`, and with it the staleness fix.
The two tests that pinned the old ungated behaviour asserted the workaround as
the contract; they now pin the reversal, including a pre-fix transcript reading
as settled, which it is.

Unanswered questions settle like plans do: 20 minutes, every session-ending path
draining them first, and wording that tells the agent the question is still on
the user's screen and must not be asked again.

Mobile (Butler) has the same workaround and is not fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:46:38 +01:00
pastilhasandClaude Opus 5 520fad2288 stopping a job that is already gone is a success, not an error
The chip could not be cleared by any means, and the toast could not be silenced
from the call site: useClient raises a GLOBAL onError before it throws, so
handling the failure where Stop is pressed was always going to be too late.

DELETE is idempotent. Asking for a job to stop when it is already gone has
achieved what was asked, so "no such task" and "already stopped" now answer 200
with `gone: true` instead of 400. No error, no toast, and the client clears the
chip on the flag.

A genuine refusal — a malformed id, a subagent with no fd to trace — is still
400.

The previous client-side check stays as a fallback, for a browser talking to a
server that has not been updated yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:47:28 +00:00
pastilhasandClaude Opus 5 177394fb76 Stop becomes Kill when the job ignores it
A background job that ignores SIGTERM could not be stopped from the UI at all.
Stop sends SIGTERM to every pid holding the job's output file, and the second
click sent the same signal again — task-restart.ts already said "that is what
the second click is for", and it was not. Reported after a job hung in another
chat.

The button now escalates instead of a second button appearing beside it. Press
Stop, and if the job is still running three seconds later the same control
becomes Kill in the destructive tone, and sends SIGKILL.

Escalating rather than always offering Kill, because a permanent Kill button is
a second destructive control competing with the first, and people reach for it
by default — losing the graceful shutdown that usually works. The delay is the
same argument: a clean exit takes well under a second, so offering Kill at once
teaches everyone to press it first.

`?force=1` on the existing DELETE rather than a second route: same verb on the
same thing, differing only in how hard it asks. The member path escalates too —
one `kill -KILL` run as them, since fuser and the kill must both be their own
identity.

Stated in the button's own title rather than hidden: SIGKILL gives the process
no chance to release a port or take its children with it. That is the right
trade for a hang and the reason it is not offered first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:02:18 +00:00
pastilhasandClaude Opus 5 0fd89082fe an uninstalled plugin's grants stop breaking the permissions screen
Uninstalling music left its grants in role_permissions. The keys stop existing
when the plugin unmounts, so the settings page — which loads every grant and
submits them all back — failed with "Unknown permission 'music'" for a row
nobody could see, edit or remove through the UI. Nothing could be saved again
without editing the database.

Two changes, because either alone leaves half the problem.

Uninstall now revokes the plugin's grants, across every role rather than one. A
deliberate exception to "uninstall deletes nothing": a grant is the PLATFORM's
record of who may reach the plugin, not anything the plugin stored. Reinstalling
means granting again, which is the honest behaviour — the alternative is access
silently returning.

And the write DROPS an unknown key instead of refusing the request, so every
server already in this state is unblocked without touching its database. That is
what the resolver already does on read, so the write now agrees with it. The
cost is that a typo in a hand-made API call is ignored rather than reported —
worth less than a settings page that cannot be used. The response returns what
was dropped, so it is not silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:18:49 +00:00
pastilhasandClaude Opus 5 b9524ee4d5 prefix the env with OFFICERDEV_, and keep reading the bare names
PORT, PUBLIC_URL and POSTGRES_URL are names half the software in the world
uses. Bun auto-loads .env into EVERY process started in the platform directory,
so a bare PORT reaches the sidecars, the tools they spawn and anything an agent
runs from here — and a bare POSTGRES_URL reaches things with no business
holding a database credential.

Four parts.

officer-setup now writes OFFICERDEV_-prefixed names, with the reasoning in the
generated file so whoever opens it later knows why.

Every consumer reads `OFFICERDEV_X ?? X` — eleven sites across the databases,
the DAV router, officer-url.mjs, os-user-postgres and two scripts. The bare
name is the FALLBACK, never the source, which is what makes this safe to ship
before every machine has been converted.

`bun run fix:env` converts a file in place. Idempotent, preserves comments and
`export`, leaves the value untouched including an `=` inside it, and refuses to
touch names belonging to other software — prefixing PATH or NODE_ENV does not
rename a variable, it deletes it.

And it runs on every restart from the server settings screen. There rather than
at boot because a restart is the one moment the file can be rewritten and take
effect in the same breath: the process about to start is the one that reads it.
Non-fatal, since the fallback means the worst case is that nothing changed.

gen-index reads either spelling out of the file directly, because it is run by
hand as often as by the setup and a bare shell has no .env loaded.

Verified by converting this machine's own .env: PORT, PUBLIC_URL and the
database all still resolve, gen:index still finds its URL, 1152 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:46:00 +00:00
pastilhasandClaude Opus 5 b27410a6fc sign in with an email or a username
Only an address worked, though usernames are already unique and already
validated as /^[a-zA-Z0-9._-]+$/ — which is what makes this safe: no `@`, so
the two namespaces cannot overlap and there is nothing to guess. An `@` decides
which lookup runs, and it decides it exactly.

The field is still called `email` on the wire, because every client sends that
name and renaming it would break them for nothing.

Fixed a latent bug while passing: the JWT took `email` from the REQUEST rather
than the row. Identical while an address was the only way in — and a username
in the `email` claim the moment it was not, leaving the DAV door, the managed
home path and everything else keyed on email looking up a user who does not
exist.

The login field is `type="text"`, not `type="email"`, or the browser refuses to
submit a username and validates someone out of an account they have.
`autoComplete="username"`, which is what password managers store beside a
password for a field that takes either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:10:39 +00:00
pastilhasandClaude Opus 5 d49776307d gitea, extracted into plugins/gitea — the platform half
Eleventh extraction, and the only one that deleted its UI instead of moving it.
Seventeen files under apps/Gitea/ reimplemented a fraction of an interface
Gitea already has; the plugin renders the real one in an iframe over a
same-origin proxy.

Deleting rather than moving removed a platform change nobody now has to build:
useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a
plugin gets two, so an as-is extraction would have required teaching the plugin
system to declare extra route patterns. Nothing inside an iframe needs a route.

Nothing working was lost. The router had been commented out of hono.ts since
2026-08-13 with api: [], and officer-gitea was running nowhere — the screen
rendered and every request 404'd, like soulseek, jellyfin and invoices before
it.

Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS,
the only installable feature that was, on the reasoning that it fronts a remote
instance and installs nothing locally. As a plugin its tile arrives from
assets/icon.png like every other — which is also why the icon added to
public/icons/ this morning was reverted rather than kept.

'gitea' stays in the ServiceName union: the connection lives in core
service_connections, and gitea is the reason that table has its "member rows
carry only a token and inherit the owner's URL" shape at all.

registry.test.ts's grantable-permission test named gitea as "the case this was
built for"; a plugin's permissions are registered at install and are never in
that compile-time list, so it now names a core one. catalogue.test.ts
re-anchored to vnc/notify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:59:10 +00:00
pastilhasandClaude Opus 5 4b2c896523 ship the capabilities repository, and update it from the same button as plugins
capabilities/ is the file-based item store, and most of it belongs to the
owner: the six item directories are created by the platform at boot and
extensions/tool-loader is written from platform source, so neither is tracked
anywhere. What IS tracked is the small set the platform ships — today one
affordance, telling an agent that markdown images render and that the USER'S
DEVICE fetches the URL, which it cannot discover from the filesystem.

That now lives in officerdev/capabilities, public so a fresh install can clone
it without credentials.

Two ways in. The setup script syncs it during the layout step — non-fatal, and
it says what an install without it costs rather than failing: agents go back to
guessing, which is what they did before this existed. And "Update all" on the
marketplace screen carries it alongside the plugins, because it is the same
question — get me what has been published since I installed — and a second
different gesture for it is one nobody remembers.

Three states in both places, and the third is the normal one on an existing
machine: a checkout is pulled, an empty directory is cloned into, and a
directory that already holds the platform's generated items gets a repository
initialised around them. UNTRACKED files never block a pull, because the
owner's own skills and tools living here is the ordinary state — only tracked
changes do.

`cloned` and `adopted` are reported in the UI where `pulled` is not: they mean
this machine had no affordances at all until the button was pressed.

Verified against the real checkout: sync reports pulled, and the loader finds
officer/core/image-rendering afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:34:30 +00:00
pastilhasandClaude Opus 5 3ef078210a update a plugin, or all of them, from the marketplace
There was no update path. fetchPlugin REFUSES an existing directory — cloning
over one is how uncommitted work disappears — so install was a one-way door,
and the answer to "my plugin changed" was uninstall and reinstall, which drops
its tables on the way past.

An update is a git pull and then the ORDINARY install path. installPlugin
already skips the clone when the directory is present and re-runs everything
else: os dependencies, schema push, assets, the ecosystem entry, the sidecar,
the remount and the frontend rebuild. So there is no second implementation of
"apply a plugin" here to drift from the first.

Two refusals, both about not destroying work. A dirty working tree is refused
rather than stashed or force-pulled — every plugin directory on a developer's
machine is a live checkout, and this repository's own are edited in place.
Non-fast-forward is refused for the same reason: a diverged local commit is
somebody's work and merging it automatically is not the platform's decision.

The per-plugin button replaces one that was quietly broken: it appeared only
when `outdated` and called `install`, which cannot fetch, so it re-applied
stale code. Nothing bumps a version today either, so with everything pinned at
1.0.0 that flag never fires and the button never appeared. Now always offered,
and it pulls.

Update-all is a plain POST rather than the streaming door: twelve interleaved
step streams is not a readable log, and the useful answer is the summary. It
runs sequentially — each update pushes schema, republishes assets, rewrites the
ecosystem file and rebuilds the frontend, and two of those at once is a race
over one file each. One failure does not stop the rest, and failures are named
with their reason, because "2 failed" sends someone to the terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:11:42 +00:00
pastilhasandClaude Opus 5 bbb16d6ed3 bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half
Tenth extraction, and the first where the blocker was a permission rather than
a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin
permissions are always kind: 'app'. mount.ts forces that and the manifest has
no field for anything else, deliberately, so a plugin cannot ask to be
restricted. Extraction therefore could not preserve admin.

Raised before moving any code, because turning "the server owner's bitcoin"
into a row in the grants list beside Photos and Music is the kind of change
nobody notices until someone ticks it.

Made anyway, and the objection inverts: every wallet query is scoped by
user_id, taken from X-Officer-User which the platform injects from the session,
so granting the permission gives an account its OWN wallet rather than access
to anyone else's — including to the owner, whose bypass covers permission
checks and not query scoping. kind: 'admin' was single-user thinking.

Named bitcoin-wallet, not wallet: appName drives route, API prefix, table
prefix and install key together, so the name should say what it is. Tables are
bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before
committing to it — no wallet table exists on any server, and db:push would
treat a rename as a drop plus a create.

registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture
and its no-selfService fixture. Now user-admin/server-admin and terminal, with
a note that a plugin's permissions are always `app` — which is exactly why this
one needed a decision rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:18:44 +00:00
pastilhasandClaude Opus 5 f566fda0b2 invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the
permission reduced to api: [], so assertPermissionTotality was satisfied, the
server booted clean, and every panel request 404'd. Closes the same data hazard
as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix,
frontend route, table prefix, sidecar suffix, install key — and before
extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted
apart, which was harmless only while they were separate hardcoded strings. The
route is the part a person sees, and the permission key and label were already
`invoices`, so the API prefix was the odd one out. Cost three call sites and a
table rename to invoices_accounts, which was free only because the table
existed on no server — verified before deciding, not after.

members.test.ts re-anchored again, this time to caldav and memos: services with
no extraction planned. That assertion has now been re-pointed four times by
services leaving for plugins/, which is the whole reason installer.test.ts was
made literal earlier tonight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:47:54 +00:00
pastilhasandClaude Opus 5 aebd6ff0c0 jellyfin, extracted into plugins/jellyfin — the platform half
Eighth extraction, second resurrection. Same shape as soulseek: hono.ts had the
router import and its PROTECTED_MOUNTS entry commented out since 2026-08-13 and
the permission reduced to api: [], so assertPermissionTotality was satisfied,
the server booted clean, and every panel request 404'd. Also closes the same
data hazard — jellyfin_servers was absent from the drizzle barrel, so bun
db:push would have dropped it.

No frontend call site needed repointing: mountPrefix derives /jellyfin from the
appName, which is what the router already hardcoded.

Test fixtures fixed properly rather than re-anchored again. Extracting jellyfin
broke 18 tests, the FOURTH extraction in a row to break the same files — photos
and transmission yesterday, slskd an hour ago, jellyfin now. installer.test.ts
now builds literal CatalogueEntry fixtures and asks the catalogue nothing: it
pins planSteps and runInstall, which are installer logic, and a service leaving
for plugins/ says nothing about whether the installer still plans the right
steps. catalogue.test.ts stays the place real entries are checked well-formed.
members/catalogue/preflight re-anchored to memos, which has jellyfin's exact
shape.

The .prettierignore rule for the vendored hls.mjs moved into the plugin rather
than being left behind naming a deleted path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:17:43 +00:00
pastilhasandClaude Opus 5 b196c9cb55 soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek
had been switched off since 2026-08-13: hono.ts carried the router import and
its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and
registerProxiedPrefix was never called; officer-slskd was not in the ecosystem
at all. Because the permission entry had been reduced to api: [], the totality
check was satisfied and the server booted clean — the UI rendered and every
request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a
permission, and is silent about a feature that mounts nothing.

Also closes a live data hazard: schema.ts had the soulseek export commented
out, so the three tables were absent from the drizzle barrel and bun db:push
would have dropped soulseek_favorites, soulseek_browse_snapshots and
soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts
documented the workaround rather than the problem; deleted with the rest.

The connection stays in core service_connections under service 'slskd' while
the mount is now /api/soulseek. Those differ on purpose.

Test fixtures: slskd was canonical in three app-store tests, the third
extraction running to break them. installer.test.ts now builds a literal
fixture instead of reaching into the catalogue — it pins planSteps, which is
installer logic, and no catalogue entry is both provisioned and members:'none'
any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea.

Unrelated but mine: check:theme caught 5 palette classes I introduced in
CommandHQ earlier tonight against a baseline of 0. Converted to bg-success /
bg-warning. check:theme is not part of bun test, which is why the green suite
never showed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:55:35 +00:00
pastilhasandClaude Opus 5 f8028364f1 transmission, extracted into plugins/transmission — the platform half
Sixth extraction, straight after photos. Everything transmission now lives at
gitea.officer.dev/plugins/transmission.

Deleted, not commented: the sidecar, the api router, the Transmission app (28
files including detail/ and dialogs/), the screen, the App.tsx route pair, the
Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the
officerdev barrel re-exports, the permission registry entry, the hono.ts import
and mount comments, and the app-store catalogue entry.

No database change: transmission owns no tables. Its connection lives in
service_connections, a core table it now depends on from outside — a real
coupling, recorded in the plugin's own PLUGIN.md rather than left to be
discovered.

── Test fixtures, repointed rather than deleted ──

Transmission was the canonical `members: 'none'` fixture — a single-tenant
daemon with no user concept — across three app-store test files. slskd has the
identical shape, so the assertions moved there. Deleting them would have
quietly dropped coverage of the no-members install path along with the plugin,
which is the same trap the vault extraction hit with `members: 'invite'`.

── Vocabulary swept, per the runbook ──

Six screens and two App.tsx comments pointed at `/photos` or `/transmission` as
the worked example of the Workspace/Panel shape. Both are plugins now, so those
pointers dangled at code no longer in the tree. Repointed at /command-hq and
/soulseek, which are core.

bunx tsgo clean. 1080 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:48 +00:00
pastilhasandClaude Opus 5 a327dfc82c photos, extracted into plugins/photos — the platform half
Fifth extraction, and the clean case: no reverse dependency, configuration
already server-side, frontend already panels. Everything photos now lives at
gitea.officer.dev/plugins/photos.

Deleted, not commented: the sidecar, the api router, the db directory, the
Photos app (25 files), the screen, the App.tsx route pair, the Dashboard
barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev
barrel re-exports, the permission registry entry, both database barrels, the
hono.ts import comment, and the app-store catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── Test fixtures, repointed rather than deleted ──

Photos was the canonical app-store fixture across four test files, chosen for
being existing|provisioned with members: 'accounts'. Jellyfin has the identical
shape, so the assertions moved there — they are about the installer, not about
photos, and deleting them would have quietly dropped coverage of the provisioned
install path along with the plugin.

One loss worth naming: Immich's `provisioned` install mode went with the
catalogue entry. Docker provisioning for plugins does not exist yet, so photos
is point-at-your-own-instance until it does.

bunx tsgo clean. 1080 pass / 0 fail, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:52 +00:00
pastilhasandClaude Opus 5 f6a1194905 vault, extracted into plugins/vault — the platform half
Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.

Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── The one new piece of platform: src/servers/account-lifecycle.ts ──

Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.

Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling somewhere worse — core would depend on a plugin's
SCHEMA, so renaming a column would silently stop the wipe. A security property
that fails silently on an unrelated change is the failure mode to design
against. (docs/email-plugin-extraction.md took that shape for READING a
plugin's data, where a stale answer is a missing feature rather than a lock
left open. The stakes decide it, not the pattern.)

Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.

── Two things deleted rather than carried, both dark since 2026-08-13 ──

The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.

The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.

── Tests ──

Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.

bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.

Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:43:32 +00:00
pastilhasandClaude Opus 5 f77a41d26a command hq: delete a commander, from the grid card and the detail bar
DELETE /command-hq/commanders/:sessionId — deletes the transcript itself via
deleteClaudeSession (the same function DELETE /chat/sessions/:id calls, so a
/clear chain goes with it, every part), then the command_hq_sessions row via
deleteCommandHqSession (existing, exported, never previously called from a
route — its docstring said as much: "no product path to this yet").

Deliberately real deletion, not an unfile-back-to-/chat path — there still
isn't one of those, and this doesn't create one. Documented next to "The
/chat <-> /command-hq relationship" so it doesn't read as contradicting the
"a session never moves the other direction" rule directly above it.

Frontend: both the grid card and the detail bar's meta row get an inline
confirm (trash icon -> check/cancel), same shape as ChatHistory's own
SessionRow delete. The grid card's delete control is a sibling of the card's
<Link>, not nested inside it, per docs/navigation-audit.md — the card is a
stretched full-cover Link (absolute inset-0, default stacking) with the
delete control in its own stacking context (z-10) on top of it, so the card
stays click-anywhere except where the trash icon sits. Deleting the commander
currently open in the detail panel navigates back to the bare list rather
than leaving it pointed at a 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:46:31 +00:00
pastilhasandClaude Opus 5 963c965056 command hq: auto-name a commander from its watcher's own task description
The initial title (the mechanics preamble, truncated, when a background job is
the very first message) is expected — that's just Claude Code naming the
session from its first message, same as always. But by the time the watcher's
task actually starts, the harness has already handed it a real description
("Watch google.com reachability" for a ping watcher), and that reads far
better than the preamble ever will.

Hooked into the exact moment pendingArmWatcher is consumed — the FIRST arm,
never a self-re-arm, which the watcher-loop code already had to distinguish
for its own reasons. Resolves a fresh ChatIdentity via resolveHomeDir and
calls renameClaudeSession with msg.description. Best-effort: a failure is
logged and does not touch watcherArmed.

Known gap, noted in the doc rather than fixed: the detail panel's title bar
is fetched once on mount and won't live-refresh when this fires while it's
open. The grid's cards don't have this problem since GET /commanders already
polls every 15s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:18:17 +00:00
pastilhasandClaude Opus 5 764ba23206 command hq: per-turn cost ledger, watcher fires as their own table
The owner asked for individual turn token/dollar costs, a running total, cost
over time, and cost per trigger — derived, not stored, following the same
rule `running` already uses. Two new tables carry it:

- command_hq_watcher_fires: one row per trigger (sessionRowId, taskId,
  firedAt). Replaces the single watcherLastFiredAt column, which lost the
  previous fire's timestamp every time a new one landed — fine for "when did
  it last fire", useless for "how many times, and what did each one cost".
- command_hq_turns: one row per completed turn (sessionRowId, fireId
  nullable, inputTokens, outputTokens, totalUsd). Nothing pre-summed —
  running total, cost-over-time and cost-per-trigger are all queries over
  this table. Turn count needed no column either: COUNT(*) already answers
  it, confirmed explicitly with the owner rather than assumed.

command_hq_sessions gains watcherArmedAt/watcherStoppedAt in place of any
"last fired" cache — set only on the false<->true transitions, so duration
(current run, or the last completed one) is computable without a full
transition history table.

Two new read routes, GET /commanders/:sessionId/turns and .../fires, expose
the raw series behind the cost summary and watcherLastFiredAt (now computed
via MAX(fired_at), not read off a column — the field survives in the JSON
response under the same name).

Bug found and fixed while wiring this up, recorded in docs/command-hq.md:
recordCommandHqWatcherFired's signature changed from (userId, sessionId) to
(sessionRowId, taskId) and one call site kept the old argument order —
tsgo stayed clean because number/string still matched structurally. Caught
by re-reading every call site by hand, not by any tool.

Needs bun db:push before any route answers with real data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:03:52 +00:00
pastilhasandClaude Opus 5 624d67ed52 watcher loop: the agent re-arms itself, same turn — final mechanism, confirmed against real usage
second correction to the re-arm design tonight, and this one came from the
owner, not from me. my plan (platform sends a synthetic follow-up turn once
the acting turn's result lands, instructing re-arm) was technically sound —
it keeps the agent inside the sdk's own Monitor tracking, which is the thing
the FIRST wrong plan (silent respawn) broke — but it costs a real extra turn
every cycle. pushed back on with hard evidence: the mobile dev's own
watcher, on this repo, this session, had been running with THEM re-arming
themselves, same turn, for hours already — zero failures. that beats a
reliability concern I raised without having tested it.

so: handoffContract now instructs the agent to re-arm itself, as the last
action of the SAME turn that handles the notification — no synthetic turn,
no platform spawn, no extra cost. exitContract's handoff branch corrected to
match (was still saying "the harness re-arms a firing on its own", exactly
backwards now).

server side simplifies to match: createMessageHandler's task:started hook
now handles both cases with one rule — pendingArmWatcher (session-object
flag, not closure-captured, since arming happens turns after the closure
was built) claims the FIRST task:started as the watcher; every SUBSEQUENT
task:started while already armed is read as the agent's own self-re-arm and
just updates watcherTaskId to match. task:notification records the fire
when it matches the tracked id. the platform never drives anything — it
only notices what the agent already did.

pulled COMMS/command-hq/mobile-3.md, which answered the PREVIOUS (synthetic
turn) version — crossed in transit. their transcript-bloat concern is moot
now (no synthetic turn exists to bloat anything). their `watcherArmed &&
!running` detectable-failure pair stands regardless of which mechanism won
and is exactly right — kept as-is, matches their own three-state dot
(running/armed-but-not/neither). replied in platform.md.

all four proposal items now built: prompt+dialog, stopped-vs-died, the
re-arm mechanism (this commit), arming state+plumbing.

bunx tsgo clean, suite unchanged (1038 pass, same 7 pre-existing baseline
failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:33:47 +00:00
pastilhasandClaude Opus 5 f369b4ca18 watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it
item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.

command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).

armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.

the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.

so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.

docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.

bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:05:21 +00:00
pastilhasandClaude Opus 5 f810bdc48e command hq: displayText, caught by the mobile side before either of us ran this
mobile-2.md found it: a commander created to become a background job sends
buildBackgroundJobPrompt's full mechanics as `prompt` — a page of preamble,
the owner's actual sentence only at the end — and command_hq_sessions has no
name column, so the title comes straight from `(displayText ?? prompt).slice(0,100)`.
without it every such commander titles itself with the mechanics text, and
the grid becomes a column of identical unreadable cards. same precedence the
websocket path already uses on both clients (sendWithHiddenPrefix on web,
useSessionView.ts on mobile) — now the same on this endpoint too.

fixed before either side had run this against a real server, so there's
nothing to clean up on a live grid.

also confirmed rather than assumed, per mobile's other note: cwd cannot be
talked into a different one on this path. resolveChatCwd's command-hq branch
ignores msg.cwd unconditionally and this endpoint never sets it.

replied in COMMS/command-hq/platform.md. also read watcher-loop-proposal.md
(exit/act/drain/re-arm as harness infrastructure, "Andre's call" to build it
before first tests) — not starting it off a COMMS note alone, taking it to
the owner directly since it's a real scope addition beyond what tonight's
command hq build was scoped to.

bunx tsgo clean, suite at baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:33:09 +00:00
pastilhasandClaude Opus 5 7ad64445ee command hq: POST /commanders/new, for a client that cannot open the websocket
mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.

the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.

getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.

the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.

said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.

docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:29:57 +00:00
pastilhasandClaude Opus 5 e9b1ce5e94 command hq: the web creation path, and a member could not have used it
`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.

found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.

the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.

pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).

docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:58:23 +00:00
pastilhasandClaude Opus 5 eb94af87f4 command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission
(personal: ['/'], no grant to hold — every query is scoped to the caller's
own user_id at the sql layer regardless). registered in the permission
registry and PROTECTED_MOUNTS together, so totality holds by construction.

GET /commanders is the grid endpoint: everything /chat/sessions already
returns per session, plus categoryId/tagIds/running. `running` is derived
live off collectRunningTasks (now exported from websocket.ts) folded over
chat_session_events — not a stored column, on purpose, matching the design
doc's stance that a status field would go stale exactly when a sidecar
restart silently kills a watcher.

GET /commanders/:sessionId deliberately does NOT duplicate the transcript —
that stays GET /chat/sessions/:id, unchanged. a client calls both.

/chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds,
.catch()'d so a hiccup on this feature can't take the whole chat list down)
— the other half of the one predicate this whole design turns on.

there is deliberately no POST /commanders/new: creation is an ordinary chat
turn in the commanders directory, which is the next commit.

docs/command-hq.md gains an API reference table for the mobile dev, and a
status line at the top saying plainly what is real and what is not yet:
needs `bun db:push` before any route returns real data, frontend not
started.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all
registry/totality drift and the pty socket test — none touch this router).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:10:50 +00:00
pastilhasandClaude Opus 5 3cc9df27d7 a failed usage write must not discard the reading
the panel rendered "Not signed in" over an account that was signed in fine.
cause: `snapshotClaudeUsage` had the read and the write in ONE try block, so
on a machine that had not run `db:push` the upsert threw on a missing
`ai_provider_usage`, the catch returned null, and a perfectly good reading
went in the bin. the route then merged null into an empty stored list and
answered with no accounts at all.

which makes the claim in docs/ai-usage-panel.md — "degrades to no tabs
rather than a 500" — false as written. the stored half is an ENHANCEMENT: it
buys tabs for accounts that cannot be measured, and an enhancement that
fails must cost only itself.

so the write gets its own try and the reading is returned either way.

observed on this machine rather than reasoned about: to_regclass came back
null, and the regression test fails against the old shape (verified by
reverting it, not by assuming).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:08:48 +00:00
pastilhasandClaude Opus 5 9745f54fa1 cache usage per ai account, so a switched-away account still answers
half a usage reading cannot be taken for an account you are not signed into.
the limits come from anthropic's oauth endpoint using the credential the cli
holds for the CURRENT login, and the tokens come from that login's
transcripts — switch accounts and the previous one is not slow to read, it
is unreadable.

so: `ai_provider_usage`, keyed (user_id, ai_provider, email_account), with a
snapshot taken at the end of every turn. fire-and-forget off the `result`
message, owner-gated, capped by the reader's own 60s cache so a burst of
short turns is not a burst of 150MB scans.

the panel gains tabs, one per account. GET /chat/usage now answers from
postgres alone — instant, and it carries the accounts that cannot be
measured — and ?refresh=1 re-measures the signed-in one and writes it back.
the client fires both and swaps.

the reason a stale row is worth showing rather than apologising for is that
`resetsAt` is an ABSOLUTE instant. a row from six hours ago has an as-of
percentage and an exactly correct "resets in 14h 20m", ticked client-side
with no network. and where a window has rolled over since, `currentLimit`
reads it as 0% — the stored figure is not merely stale there, it is wrong in
the direction that matters, since the account is actually free. weekly rolls
forward to its next boundary (the length is in the kind, so that is
arithmetic); session does not, because the five-hour window starts on first
use rather than on a grid and there is no clock to count down.

fetchedAt is now the older of the two halves rather than a stamp at the end,
so a cached reading does not report itself as current.

needs `bun db:push`. degrades to no tabs without it rather than a 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:46:31 +00:00
pastilhasandClaude Opus 5 dc68a289ff usage panel on the claude code pill
clicking the harness pill opens what claude code's own usage display shows:
session/weekly/scoped limits with reset countdowns, tokens by day for the
last week, tokens by model.

officer has none of these numbers and could not derive them. per-session
cost is in-memory in websocket.ts and dies on restart, and the rate-limit
windows belong to the ANTHROPIC ACCOUNT — spent by every `claude` on the
box, not only by this chat. so both halves come from the cli's own record:
the oauth usage endpoint for the windows, ~/.claude/projects for the tokens,
~/.claude.json for which account is signed in.

shape and the non-obvious correctness details are lifted from omarchy's
agents collector (basecamp/omarchy quattro, via pastilhas.agents which adds
the account email): dedup by message id, the four token counters, the
per-payload percent-vs-fraction scale, and reading the model-scoped window
out of limits[] rather than the flat buckets — every seven_day_* bucket is
null on this account while limits[] carries a real fable window. verified
against the live payload today; limitsFromPayload is tested against it.

owner-only in the handler, not the registry: `chat` is confined, so a member
holding it reaches every route here, and these are someone else's windows.
the pill falls back to the plain label it always was for anyone else and for
opencode.

442ms cold, 1ms warm on 87MB of transcripts; cached 60s server-side, and the
query only runs when the popover opens.

not exercised through http — the route needs an officer restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:23:12 +00:00
pastilhasandClaude Opus 5 77f90a91f0 show the model that answered instead of the bare harness string
the picker rendered the literal `claude-code` for any chat that named no
tier. that is DEFAULT_MODEL — the bare harness — and it routes correctly
(no --model is passed, so the cli picks for itself) but names no catalogue
entry, so radix matched no item and the fallback from 773f0e1c printed the
raw string's last segment.

officer cannot answer this because it deliberately did not choose. the
harness does, in its system.init `model` field, which the parser was
discarding. so: emit it as a `model:resolved` chat event, translate it to an
officer id in turn-stream, and forward it to the client only when the turn
named no tier.

that last condition is the load-bearing one. the alias map is lossy in one
direction — opus[1m] and opusplan both answer as claude-opus-5 — so
forwarding unconditionally would silently relabel a user's own choice as
plain opus.

display only, and three ways: not durable (like session:claude), never
recorded to chat_session_models, and never written into the client's
selectedModel or the reattach ref. storing it would pin a session to
whatever the cli defaulted to on its first turn, which is the freedom the
bare harness exists to preserve.

untested against a live turn — the parser and translator have unit tests,
the websocket gate does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:58:35 +00:00
pastilhasandClaude Opus 5 98c37c4ce9 email leaves the platform for plugins/email
the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.

three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (fbb6d6c7).

both survivors now read the DISK instead. findEmailAccountName() in
data-path.ts picks the newest emails.db under an account's email_accounts/
directory - no import, no query, and nothing to throw when the plugin is not
installed. the layout was already the platform's, so this costs no coupling.
the agent keeps its email_db tool and an email-context chat still opens in the
right mailbox.

the google oauth callback no longer appends /email to the dock: a plugin's
route is the plugin's business.

app-store/catalogue.ts entry deleted, and that is the whole point rather than
tidying. permissionAvailability derives from sidecar_installs and a plugin only
ever gets a plugin_installs row, so a leftover entry makes the permission
permanently unavailable - denied route, no dock tile - on a machine where the
plugin is installed and healthy. it has bitten headscale and nearly music.

two app-store tests used email as their config-only fixture; repointed at
notify, which is the same shape.

google-auth stays in core and the plugin imports it via @@/. that direction is
allowed and google-auth knows nothing about mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:28:13 +00:00
pastilhasandClaude Opus 5 1b811dfa28 make plan mode reachable in chat
opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.

verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.

canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.

sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.

plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.

not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:50:24 +00:00
pastilhas 773f0e1c8b never store a model the picker cannot render
The selected model went blank after a turn: visible only by opening the dropdown.

`officerModel` was coming back as `claude-code` — the bare harness. That is `DEFAULT_MODEL`, used when the
client sends no model and the user has no default. It ROUTES fine (isClaudeModel is a prefix test, and
split('/')[1] is undefined so the CLI picks its own), but it names no catalogue entry, so Radix's
`SelectValue` matched no item and rendered nothing.

It fed itself. A blank picker sends no model, so the next turn fell back to the bare harness again and
overwrote the real value — it destroyed a correctly backfilled `claude-code/opus` within one turn. My own
write path did that, added this afternoon.

Two changes. The server refuses to record a model with no tier: a bare harness means "no explicit choice",
and the honest response is to leave the previous answer alone rather than overwrite it with a non-answer.
And the trigger no longer trusts that its value names an item — an unmatchable value renders as its own
string instead of as an empty control, which also covers a retired model or a vanished OpenCode provider.

Repaired the one corrupted row in place by deleting it, so the transcript family fallback answers for that
session until its next real turn; it now reports claude-code/opus again.
2026-08-17 18:06:08 +00:00
pastilhas d59bc0d152 one sessions panel with three views
The /chat sidebar was two panels in a vertical 25/75 split — Live above, Sessions below — plus a directory
dropdown in the list header. Three problems that compounded: the Live panel was empty almost always, so a
quarter of the sidebar was permanently blank; the two panels had entirely disjoint indicators, so a
conversation with a turn running looked ordinary in the history; and the dropdown showed one directory at a
time, which turned "where was I working on that" into a search.

Now one panel, three tabs. Tree answers "what have I got, by project". Latest answers "what was I just
doing". Live answers "what is running". Same rows, same indicators, one place.

Tree is an accordion over /chat/pwds, each header carrying the full path from ~, the session count, and a
live count — plus New Chat here, since the tree only lists directories that already have history. A
COLLAPSED NODE ISSUES NO REQUEST: sessions are read by scanning transcripts off disk, and eager mounting
would be one scan per directory for rows nobody asked to see. `useClaudeSessions(cwd, {enabled})` is the
whole mechanism. The per-directory live count comes from the poll's own `cwd`, never from
`liveSessionTitle`, which reads a whole transcript and whose own comment warns against calling it from
anywhere hotter than a ten-second poll.

Latest needs a new server mode, `GET /chat/sessions?all=1` → `listAllClaudeSessions`. It loops directories
rather than scanning flat, because /clear chains are merged per group and a flat scan would let two
unrelated chains in different directories collapse into each other. Fetched only while the tab is showing.

Rows now carry the model, from the batched `getChatSessionModels` — one query per page, which is what that
query was added for.

THE MIGRATION IS THE DANGEROUS PART AND IT IS INVERTED. Every persisted layout still names `chat-live`, an
appType that no longer exists; `normalizeLayout` would collapse it onto the fallback and WRITE THAT BACK,
which is what made /offscale render three server pickers this morning (ed3806f9). The existing guard adopted
layouts LACKING the panel, so it had to flip to adopting layouts that HAVE it. defaultLayout.test.ts pins
both halves: the replacement must not contain the appType the guard tests for, or the effect fires on its
own output forever.

Deletes LiveSessions.tsx and PwdSelector.tsx — both replaced. Fixes the inherited bug in passing: the
"generating" Loader2 never had `animate-spin`, so the one indicator meaning "work is happening right now"
was a static icon.

View and expanded-node state go in localStorage, not the URL (a tab is a preference, and every session link
would carry it), not usePanelConfig (a NO-OP setter in a locked workspace, and /chat is locked), and not
useDashboardState (its PATCH only accepts layout-shaped objects). /chat/g/<path> still works and opens that
directory in the tree.

Untested against a running server: officer has not been restarted since these changes, and I am not
restarting it mid-conversation. `?all=1` and the model field are verified only by typecheck.
2026-08-17 17:30:44 +00:00
pastilhas fc67973876 record a resumed session's model when it switches, not when the turn ends
`createMessageHandler` is built per turn, so `transcriptId` started null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was nothing to key the row on. A resumed conversation therefore recorded its model only on the closing
`result`, and a turn that died took the switch with it.

Observed rather than reasoned: the sidecar log said `claude-code/fable -> claude-code/opus[1m]` while the
stored row still said `claude-code/opus`.

Seeded from the resume id, which the Claude path already has in scope for the spawn. A brand-new
conversation still waits for `session:claude` — it has no id yet by definition — but resumed is the case
where switching actually happens.

Also lands docs/chat-sessions-list.md: the plan of record for the sessions-list revamp, agreed with the owner
today. Six numbered targets, four decisions with their reasoning, and the landmines — chiefly that merging
the two left-column panels strands `chat-live` in every persisted layout, which is the same stale-appType
shape that made /offscale render three server pickers this morning (ed3806f9). Written down because the shape
came out of a conversation, not out of the code.
2026-08-17 17:17:52 +00:00