52f209c69f063c92126b9cbbb33f22312f857e6a
164
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
52f209c69f |
caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own: |
||
|
|
e1f5d01b90 |
a dead watch wakes its commander instead of staying dead
the machine rebooted 2026-08-24 at 12:40 and took the deploy commander's loop with it. a release was tagged the next day and sat there. the only evidence was a word missing from a card. the obvious fix is for the platform to respawn the loop, and docs/command-hq.md settled on 2026-08-18 that it must not: the agent is only notified about a process it started and is tracking, so a platform-spawned watch would run correctly, fire, and reach nobody. that trades a visible failure for an invisible one, which is worse — not watching is at least legible. so the platform notices and hands the turn back. the agent re-arms itself, the way it already does after every fire, and the process it starts is tracked. the owner's two conditions hold by construction: the commander is still the only thing that starts a watch, and the indicator still reads the pid file. DETECTION is the pid file, not watcher_armed. that column is a claim only one path sets and most commanders never take it — both commanders here read false while one was demonstrably watching. a pid file that exists with nothing alive behind it means a watch that was running and is not. which made stop ambiguous, so stopCommanderProcesses now removes the file. without that, a deliberate stop looks exactly like a crash and the reviver would ask the commander to re-arm what the owner just switched off. the commander now keeps WATCHERS.md — what each watch is, the exact command, the interval, what it does when it fires — because when the wake arrives the transcript is long past the turn that armed it and the platform deliberately does not remember the command. a commander predating the file may rebuild from JOB.md, a script in its directory, or the turn that armed it; inventing one it cannot find is forbidden, since a wrong watch looks like a working one. the owner is notified separately from the commander being woken. they are different audiences and only one of them can act when the wake does not work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a9dd264a72 |
a commander's children stay out of the chat list
/chat already hid commanders. it did not hid their spawned agents, and a commander that fires once an hour spawns one per run — each with its own transcript, titled after whatever its first message happened to be. one commander had thirteen sessions under its directory, twelve of them reading "Read /home/.../procedures/classify-t…". a loop's bookkeeping, filling the list you keep conversations in. listCommandHqOwnedSessionIds is the union: commanders plus their children. kept separate from listCommandHqSessionIds rather than replacing it, because a CHILD is not a commander — isCommander has to stay false for one or the composer would offer it the handoff watcher. /chat/live is deliberately untouched. it is the "what is running right now" panel, and a spawned agent that is still going belongs there — hiding it would mean an agent running with nowhere showing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
12a9605b01 |
a commander's bound is a rule, not a list
the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.
so a credential now narrows two ways, and both only subtract:
permissions an ALLOW list. exact, a snapshot. right for a credential
with one job.
excludedKinds kinds it may never reach, resolved against the LIVE
registry on every request. right for a credential whose
job is open-ended.
a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.
verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.
the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e29a4ee380 |
a credential can be narrower than its holder
a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.
api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.
checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.
it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.
POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.
script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.
a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.
default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.
also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
b6d6de7e53 |
commanders: a commander is a directory, not a session
Implements phase 1 of docs/commander-directory.md. The row owns its own identity
and its own directory; session_id becomes a nullable pointer to whichever
conversation is current.
The ordering was the bug. POST /command-hq/commanders took a sessionId the caller
already held, so a chat existed first and was filed as a commander second — and
the working directory is fixed when that session spawns, before any id exists. It
now takes {name, referencePath}, creates the row, its directory and its CLAUDE.md,
and returns the cwd to start the chat in. Nothing waits on the CLI.
commander-block.ts is deleted. Claude Code reads a CLAUDE.md from the working
directory, so what it injected per turn is written once at creation and the
commander extends it as the job is agreed. resolveChatCwd no longer trusts
msg.cwd at all: it looks the row up server-side. The containment reasoning that
made a client-chosen path safe still holds — it just no longer applies, because
the client has nothing left to choose.
Four things found in review, none of which the build had.
Ownership. confineUserTree chowns commanders/ once, at provisioning, so anything
created afterwards belongs to the service user — a MEMBER's commander directory
would be one their own claude cannot write into, failing as an agent that dies on
its first command in a directory it can see. Invisible because it works perfectly
for the owner, who has no osUser. Handed over on creation, best effort, and the
error is returned as well as logged.
getCommanderDir is guarded. Two callers rm -rf what it returns, and createCommander
inserts dir_name as '' before fixing it up from the serial id — an empty name
resolves to the commanders ROOT and would take every commander the account has.
The transaction closes that window; the guard means it does not have to.
The fallback in resolveChatCwd was silent. A commander whose row is not found runs
in the account's home with no CLAUDE.md, which reads as an agent that forgot its
job rather than a lookup that missed. It says so now.
And sv-SE is renamed alongside the other five locales. It did not exist when this
work was written.
Verified: SQL applied against a scratch database first — idempotent, several
commanders can hold a null pointer at once, one-per-transcript still enforced.
Then here: db:push emits no DDL and does not prompt, officer restarts clean, and
POST /commanders returns a real directory holding state/ and a CLAUDE.md.
1428 pass, tsgo clean.
The single existing commander was deleted rather than migrated, per the design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
c38f02afa3 |
roles phase 3: dev tools are a row, not a hardcoded name
rolePermitsDevTools() was `role === 'Developer'` — one line gating two real grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role the owner created at runtime could never have either, which defeats the point of roles being data. Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row. A role that no longer exists reads as neither grant, matching "absence denies" everywhere else. NOT the `provisioningScript` D8 asked for, and that column is removed rather than left dead. A shell script stored on a role row and run at provisioning time is arbitrary root-level code in a database column, and D8's own warning says why that is worse than it looks: `usermod -aG docker` is root by a longer route. So the row CHOOSES from a vocabulary the platform already knows how to carry out — the same shape as `multiarch` on a plugin's osDependencies, for the same reason. The seed re-asserts both flags on every run for system roles, and only for them. Without that, existing rows take the column default of false the moment it lands and Developer silently loses its tools on the next deploy — every host already has these four rows, so an insert-only seed would have shipped that. `description` stays insert-only, because an edit made on one server must survive a deploy. `db:push` could not apply this and said nothing. Dropping a column while adding another makes drizzle-kit ask whether it is a rename; with no terminal it bails and the wrapper still exits 0, leaving the seed to fail against an unchanged schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit every host on the next deploy and no agent driving a rollout has a terminal. Verified here: SQL applied guarded, Developer keeps both grants and no other role gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6e092c32ea |
marketplace: minesweeper in the catalogue
Without an entry the plugin is invisible to every server but this one — the catalogue is what a remote host reads, and the bundled constant is only the fallback when marketplace.officer.dev cannot be reached. Its git remote was the tailnet address, which marketplace-sync would have published verbatim: a clone URL that works on this network and nowhere else, failing at authentication in a way that reads as a broken plugin rather than a wrong catalogue. Repointed at gitea.officer.dev, which is the rule the sync script's own comment already spells out. Synced. 18/18, and the entry is live with the right source, permissions and six osDependencies; the repo answers an anonymous https clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
04c1a6bfc9 |
role CRUD, with a guard the spec did not ask for
Phase 2 part 3 of docs/roles-and-teams.md. Create, rename and delete, each returning a discriminated result rather than throwing, so a router can say which rule refused and why instead of parsing an error string. Written by a Sonnet subagent against the plan; reviewed, exercised against the live database, and amended here. The amendment is the reason a review was worth doing. renameRole changes roles.name, and there is no foreign key yet — part 2 ships ON UPDATE CASCADE as its own release — so a rename would leave every holder pointing at a name that no longer exists. It is unreachable today only by accident: manage-users.ts validates against the USER_ROLES union, so a runtime-created role cannot be assigned and a non-system role has no holders. That evaporates the moment part 4 makes UserRole a plain string, and parts 2 and 4 could land in either order. So rename now refuses while anybody holds it, sharing one holder-count definition with delete so the two cannot disagree about what "in use" means. It comes out when the cascade lands, and then the cascade does the job properly by moving holders rather than refusing. Adding that reason made the router's switch non-exhaustive, which the typechecker caught — the discriminated-result shape earning its keep on its first change. System roles refuse both rename and delete: the platform's own code names them by string, so renaming one is a code change that has not happened. Delete never reassigns holders to a fallback, because that is a permission change nobody asked for. Exercised end to end: duplicate, empty name, system rename, system delete, a real rename, a rename onto an existing name, delete, and delete of something absent. The four seeded roles survive with the owner counted against Super Admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
44ee444cac |
the owner is an identity, not a role
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which was right while roles were a hardcoded union and nothing could write a new one. It stopped being right the moment roles became a table: from then on "add a role" and "become the owner" were the same feature, reachable from whatever UI manages roles. So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and `isSuperAdmin` delegates to it, so no call site changes who it admits — widening any "owner-only" route to "owner or Super Admin" is a separate decision per route, not a side effect of this. Still a database read rather than a bare comparison: a deleted or blocked account must not remain the owner because its id happens to match. `getOwnerUser` becomes a primary-key lookup. It used to select on the role with `order by id` as a tiebreak, because two holders would otherwise make "who owns this server" whatever Postgres returned first — an answer that decides the agent sidecar's identity, vault access and origin scoping. That tiebreak, the write-path refusals and the CHECK all protected an answer that is now a primary key. Several Super Admins are allowed. What the role carries is real and belongs in the UI rather than a refusal here: it provisions an OS account with sudo, which puts the holder outside Officer's permission system and cannot be undone by revoking the role afterwards. That is the operator's decision, not this endpoint's. Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning user 1's role is KEPT — redundant for ownership, but it still forbids the owner being set to Member, which is a nonsense state that costs nothing to prevent. And promotion to Super Admin is allowed while creating an account directly as one is not: promotion happens to an account somebody has looked at, creation is a form filled once, and the role that grants sudo should not be a dropdown default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
500c2f2179 |
name the six over-long foreign keys, so a push can honestly be a no-op
Drizzle generates foreign-key names past Postgres's 63-character identifier limit. Postgres stores the truncated form, drizzle keeps comparing against its own longer one, and every `db:push` on every host drops and re-adds constraints that are already identical. Eight statements, for ever. Documented in src/databases/CLAUDE.md on 2026-08-19 and deferred as "noise rather than a fault". What ended that was last night's deploy plan saying "expect no changes, stop and report if it wants to alter something" — which this made impossible to satisfy on any host, so the deploy agent had to overrule its own instructions and say why. An annoyance had become an instruction nobody could follow. Two of the six are in this repo; the other four are in the atelier and bitcoin-wallet plugin repos and are committed there. Verified the way the doc prescribes: push twice against a scratch database, and the second run went from ten statements to two. The two that remain are `pk_music_now_playing`, and that one is tested rather than assumed. Removing its explicit name produces the identical churn under drizzle's generated name, so it is the named-composite mis-diff and not the truncation one, and a primary key cannot escape to an index form. A push now prints exactly those two lines — which is a far more useful baseline than ten, because anything else really is your change. Naming a foreign key means removing the inline `.references()` or drizzle creates both, and `walletChainCache` had to move to the three-argument `pgTable` form because there was nowhere else to put the declaration. Both noted in the doc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f6b9ddc877 |
a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists its children; opening one gave no way back — no link to the conversation that owns it, and nothing to tell it apart from a chat somebody had started themselves and forgotten. `parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from the other end. So this needed no table and no write path: a commander's children were recorded because deleting one has to take them with it, and that record answers "who is my parent" for free. Nothing had asked. `GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line above the transcript linking back to the commander. Null for almost every session, which is the normal case rather than a miss — a chat somebody opened has no parent — so the bar renders nothing at all rather than saying so. Never fatal: a session must open when this lookup will not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7704867e05 |
roles are a table, seeded, with nothing depending on it yet
Phase 0 of moving roles out of a TypeScript union. The four the code names become rows, marked isSystem because each is referenced by name somewhere: rolePermitsDevTools tests for 'Developer', bootstrap writes 'Super Admin', users.role defaults to 'Member', and 'Admin' is what rolePermitsDevTools deliberately excludes. Renaming or deleting one of those is a code change, so the flag is there for a future delete path to refuse on rather than for the failure to surface later as an account that cannot log in. DELIBERATELY NO FOREIGN KEY on users.role. That change cannot go through db:push: push creates the table empty and adds the constraint in the same run, so every existing account violates it. Verified against a scratch database — push reported the violation, created the table, skipped the constraint, and exited 0. The FK is a separate release, once every host holds these rows. So this phase is additive and cannot fail, which is what makes it safe to hand to an unattended rollout across five hosts. Nothing reads the table yet; USER_ROLES is still the type and authorize.ts is untouched. The seed runs from postdb:push, so a host gets the rows on its next schema push with no extra step in a deploy plan. It never overwrites: a description edited on one server must not be reverted by the next deploy. Verified here the way the deploy doc now requires — by query, not by exit code. Four rows present, seed idempotent on a second run, and a second push is a no-op, which is the proof that the schema and the database agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b94a5e90b8 |
SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push. No staging area, no transfer table, no ack, nothing to expire. Every account already has a home, a file browser over it and a Files app on the phone, so a delivered file is an ordinary file from the moment it arrives. `Documents/SendOff/<machine>/` for something you sent yourself, and `Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually browses — the owner's real login home, a member's managed home — because the two are different directories and writing to the wrong one makes a delivery invisible rather than wrong. CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant answers "may this role send files", and the person who should decide whether somebody may write into your home is you. Absence denies, and a sender who was not allowed does not see the recipient in their picker at all — the endpoint answers 404 for "no such account" and for "did not allow you" alike, so it cannot be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving still needs their say-so. The sender's folder name comes from the token and never from the payload, so it cannot lie about who put a file there. Names are sanitised to one path segment and filenames to their basename — verified: `../../../../etc` becomes a literal folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the sender cannot see the directory and would have no way to know. The push carries the folder name and a file count, and the catalogue renders "3 file(s) from andre" on the device. That relaxes "content does not travel" by a folder name and an integer — the owner's call, on the grounds that the file never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would be content; whose folder it is, is addressing. `kind: 'app'` rather than `confined` even though it writes into a home: the path is one fixed shape chosen by the server from the caller's token, so there is nothing to traverse and nothing to read back. Requiring a Linux user would mean an account without one could not be sent a file, which is most of the point. Backend and verified end to end. The web send form is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
91bc4ac74f |
the platform keeps the commander's name
The name only ever lived in the transcript's summary record, which the harness cannot write until the first turn ends. So the list showed the opening brief until then, while the header beside it showed the name from local state, and every fix for that was a cache-invalidation trick timed against somebody else's write. Three attempts, all of them fixing the symptom. `command_hq_sessions.name` is written when the commander is filed, before the first turn produces anything, and the list and detail routes prefer it. Nullable, so rows filed before the column fall back to the transcript. The rename still happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it just is not the source of truth any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a51a969cd0 |
a file with no turns in it is not a conversation
Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening of commander testing. They are transcripts containing only harness metadata — last-prompt, ai-title, atis-latch — written after a session was deleted or before it ever sent anything. Filtered at the list, not deleted on disk: the files belong to the harness, and a cleanup racing it would be the same class of bug that produced them. Which is also the cause of two of the four. The delete path resolves officer's session key from two in-memory maps, and both answer only for a session younger than the process. After a restart neither does, so no kill is sent, nothing is awaited, and the transcript is removed while the harness is still writing — which writes it back. Same failure as the events cleanup, same durable answer: the event rows carry both ids, so ask them when the maps cannot say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4f590f8879 |
pin both id spaces with a test that fails against the old code
Verified by reverting the fix and watching it go red on exactly the case the live delete hit — the post-restart one, where no caller can supply a session key. A regression test never seen to fail proves nothing. It touches the real database, which the rest of the suite avoids on purpose, and that is the point: this defect was never in a decision, it was in which column a `delete` matched. A test built on a fake store would have passed against the broken version and the fixed one alike. Contained by construction — every id is a synthetic uuid in a block no harness produces, nothing reads live rows, nothing deletes by anything but those ids, and each test removes what it wrote. The third case is the one that will earn its keep later: it asserts a delete leaves a NEIGHBOURING session's events alone. Resolving a key from row payloads is exactly the kind of query that starts matching too much when somebody widens it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c04fa15eb5 |
ask the events table which key a transcript belongs to, instead of a live map
The id-space fix worked only for conversations younger than the process. The caller resolves a session key from an in-memory map filled as sessions announce themselves, so anything created before the last restart resolves to null and the key list arrives empty — which is exactly what the first live test did. Everything else cascaded; the events stayed, silently, for the one reason the code could not report. The answer was already in the rows. Every event carries `claudeSessionId` in its payload: durable, restart-proof, and the one place the two id spaces are written down beside each other. One event carrying that id resolves the key for all of that session's rows — including the majority that carry no id of their own. `sessionKeys` stays as a fast path for a live session and is now a hint rather than the answer. A cleanup that only works for conversations younger than the process is not a cleanup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ac3ca7fe5 |
forgetting a conversation needs both of its ids, not one twice
`chat_session_events` is keyed on officer's SESSION KEY. The other three tables are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same conversation, and `forgetChatSessions` took one list and passed it to all four — so the events delete matched nothing. Not sometimes: by construction, every time. A deleted conversation left its entire durable output behind. It survived two verifications because counting events BY TRANSCRIPT ID also returns zero by construction, and zero read as "cleaned". What settled it was reading an orphaned event instead of counting it — the payload carries both ids side by side and says outright which key the row is filed under. So the parameter is now named for its id space. That is the whole defence and it is the only one available: nothing else distinguishes the two, since both are uuids of the same shape, and a positional `string[]` cannot be got wrong loudly. Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p` outside officer's websocket never had a key and never wrote an event, which is exactly a commander's children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cb51f35cad |
deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in chat_session_events, against 14 live transcripts. Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and the delete path has to be explicit. It was not. Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted conversation is a timer that keeps firing at something that no longer exists. That is what turned this from untidy into worth doing now. deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug at a smaller scale. THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))` and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now. Fifth silently-wrong thing today that a typecheck could not see. Cleared the existing orphans with the same function the delete path now uses, after checking the one thing that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home this scan does not read. There is one account on this machine, so they were all the owner's. Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written independently of the transcript — that is what makes turn output survive officer being down — so a session that has just begun legitimately has events before a transcript exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d6e8a841e |
spawned children are database rows, linked to their commander
The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a child's cost arrives later, from the child, long after the loop wrote its id. So the file becomes an INBOX and command_hq_spawned becomes the record. The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could would put an authenticated writer inside a background job, which is worse than the problem. So the loop appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing; the read is the only moment the answer is wanted. What the rows buy that the file cannot: - the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which is the reason this became important. - queries. Cost per commander, how many agents a trigger spawned, which are still running. - somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends. totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a count beside the total, because a commander whose children's costs are unknown must not display a confident total that happens to be too low — that understatement is the whole reason this exists. Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed to be lossy, the rows are not, but a row never ingested does not exist yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bd15a186b5 |
themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording. ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it". Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the ctrl+shift+space browser, or one teaches an order the other contradicts. FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are directories discovered at request time — so a star can outlive its theme and the reader intersects with the live registry rather than trusting the table. Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer "what else is there". Filtering to favourites only is a separate, explicit toggle. THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line telling you the shortcuts exist was visible for half a second and then gone under two hundred entries — which is the same as not having written it. Header and footer are now siblings of a scroll box instead of passengers in it, and the header names alt+space too. THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500, which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account` permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it away from, and caught that by reading rather than by testing. Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200 so the login screen keeps its colours. 10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in the original survives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d7a80782e |
switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.
ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.
anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).
One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.
What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.
Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.
.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.
Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.
The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.
Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
e37b1a151c |
usernames are required, and unique case-insensitively
two platform fixes that make a plugin's job smaller, both from the owner: downstream services identify a person by name, so the platform has to be the one holding that name to a single meaning. `getUserByUsername` compared with `eq`, so `diogo` and `DIOGO` were two accounts. create-user.ts asks "is this taken?" through that function, which means the check could not see its own collision. it compares on `lower()` now. and the constraint is on `lower(username)`, not the column — a plain unique index is what allowed both to exist. it sits in the schema rather than only in create-user.ts so a second code path, a migration, or someone at a psql prompt cannot get around it. the column is NOT NULL. `validateUsername` has always demanded one, but the column allowed null, so anything skipping validation could write a user nobody could name — and a Linux account or a Jellyfin login then has nothing to work with. both databases were checked before the change: zero null usernames, zero case-collisions on either, so no migration was needed. proved rather than assumed, inside a transaction that was rolled back on the live database: `PROBEUSER` refused against `probeuser` by uq_users_username_lower, and a null username refused by the not-null. no rows written. this also removes a case the jellyfin reconcile had to carry: an officer account with no username could not be matched or created there, because jellyfin authenticates by name and has no email login. |
||
|
|
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. |
||
|
|
18bfff5e74 |
nudger: the due query was invalid sql, and failed silently every tick
`dueNudges` passed a JS Date as a parameter on the left of an interval subtraction. postgres will not infer a type there and refused the whole statement, so the tick threw once a minute and no nudge could ever fire. the symptom was the worst kind: the row sat in the table enabled, sent_count 0, last_fired_at null — indistinguishable from a schedule that was armed and simply had not come due yet. it was found by checking the row after arming it rather than by trusting the log line that said "armed". `now()` in sql instead, which also means the comparison uses the database's clock rather than the platform's. the test asserts only that the query EXECUTES. what broke was the sql being invalid, and no amount of reasoning about interval arithmetic would have caught that — only running it against a real postgres does. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
72d327e360 |
bell: scope the preference to a user, and make every_turn mean every turn
review of
|
||
|
|
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. |
||
|
|
16369f3570 |
secret store holds foreign secrets, and notify reads from it
putSecret/getSecret, `bun run secret:set`, and notify taking its credentials from the store with env as the fallback. this was described two messages ago as a later thing; it is about fifteen lines and it was blocking the honest answer to "where does the .p8 go", so it is done. getKey was the wrong door for anything supplied from outside — it MINTS a random 32-byte value when the purpose is absent, which for a foreign credential means silently authenticating to apple with a fake. getSecret returns null instead, which is what lets the env fallback work. putSecret retires the previous row rather than deleting it: a mistyped paste over a working key should be recoverable, and retiredKeys already exists for that shape. one transaction, because the partial unique index means the retire has to land before the insert. the CLI reads stdin and has deliberately no --value flag: a secret in argv is in shell history, in ps output, and in any audit log that records command lines. offering the flag would make the convenient path the leaky one. it prints a length and the last four characters, never the value. also `bun run notify:test`, which fires officer/core:test at a registered device. the sidecar's port is ephemeral, so it now writes its address to data/notify-server.url on boot and removes it on clean shutdown; the CLI treats an unreachable address as "not running" rather than trusting the file, because a crash leaves one behind. the script says what actually happened, because the day-waster here is that a send with nothing configured returns success and delivers nothing — it refuses up front when channels is empty, and calls out sent:0/failed:0 as "no device registered" rather than letting it read as success. verified end to end with a p-256 key generated locally and then deleted from the store: zero APNS vars in .env, sidecar reported channels: apns, and the send reached the channel. store is back to jwt and offscale only. |
||
|
|
fd7c9becb6 |
notify is core again
built 2026-07-31, switched off 2026-08-13 when it was slated to become a plugin alongside memos and photos. those two were extracted; this one never was, so it spent six days mounted nowhere with its schema commented out and no pm2 entry — complete code that nothing could reach. core rather than a plugin because the producers are the platform's own: the job queue, an agent turn ending, mail arriving. a notifier the owner can uninstall is one those producers cannot rely on. - hono.ts mounts /api/notify again; verified 401 unauthenticated, so the permission gate covers it and assertPermissionTotality accepts the mount - push_devices exported from the core schema and pushed: 10 columns, uq_push_devices_token_bundle, idx_push_devices_user - officer-notify added to CORE_PROCESSES, so gen:ecosystem writes it - credentials read OFFICERDEV_X ?? X, the convention it predates safe as core because it boots with nothing configured: every channel reports isConfigured() false and dispatch resolves empty. verified by importing it with the env stripped — "channels: none configured", no throw. an install with no apns key runs it as a no-op rather than crash-looping. APNS_ENV is dropped from the doc's credential table. it was never read: the host comes from each device's `environment` column, so one server pushes to a testflight build and a release build at once. doc status corrected. the app side is not started — there is no officer-suite repo, no PUSH_NOTIFICATIONS_APP.md, and apps/chat actively strips the ios push entitlement. recorded there rather than here. |
||
|
|
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> |
||
|
|
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> |
||
|
|
8f15f16d1c |
restore dav_app_passwords to the db:push barrel
Missed when calendar and contacts became core in
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
c775e8c7e3 |
re-enable vault — plugin extraction deferred, this is the same code as before
Reverses
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
56fe3b22df |
command hq: data model and the shared commanders directory
first commit of command-hq/command-hq.md's design. two tables plus a many-to-many for tags, no server surface yet — this is data layer only. command_hq_sessions has no name column: /chat never persists a title to postgres, renameClaudeSession appends a summary entry straight into the transcript's own jsonl, and that stays the ONLY place a commander's name can live. no status column either — liveness is derived from chat_session_events the same way the background-job tray already derives it, because a stored status would go stale the moment the harness sidecar restarts without a final task:notification and nothing would ever correct it. category is a plain FK (one per commander, confirmed in spec); tags are the many-to-many case, schema only, no ui planned yet. also: getCommandersDir(email) -> DATA_PATH/<email>/commanders, one shared directory for every commander a user has. the first design was per-commander directories and was dropped same-night: creating a fresh directory timed against the harness's own spawn is a race, and a shared pre-existing directory removes it instead of handling it. deliberately DATA_PATH-rooted even for the owner, unlike ordinary /chat which runs in the owner's real home — that's what keeps commanders out of both the owner's own project transcripts and the /chat list. bunx tsgo clean. no db:push yet — next commit is the router, and the table needs to exist before anything can call it; will note the push requirement clearly when that lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
b63badaf67 |
remove the email residue the extraction left in the platform
a sweep for remnants after 2026-08-18. nothing was broken - no dangling import, symbol, route or permission key anywhere in src/ - but the platform was still speaking the plugin's vocabulary in a dozen places. the one that mattered is live code rather than prose: ChatPanelWrapper hardcodes workspace.id === 'email' to send context: 'email', which is the frontend half of the pair whose backend half (resolveEmailCwd) carries a fifteen-line justification. the two ends now both say so, including that adding a SECOND id there is the signal the platform needs a real way for a plugin to declare a chat context. the rest: two dead imports left by removing the settings section, an empty 'Email' type-section header, a protocol.ts section header with no members naming the plugin's SSE design, and a queue comment pointing at sidecar/email/sync-runner.ts - a path in another repository now. CLAUDE.md, AGENTS.md and working-on-officer.md described email as a platform feature, an app permission and a pm2 process. those are the files a fresh agent reads first, so they were the highest-risk staleness in the tree. TODO.md's whole Email section moved to the plugin's own repository. it held OPEN items pointing at sidecar/email/store.ts, which is exactly how a leftover list sends someone to edit files that do not exist. the delete-account dialog promised it would remove email accounts and playlists. both tables belong to plugins now, so that cascade only happens where the plugin is installed - it now says 'anything an installed plugin keeps for them' instead of naming two it cannot guarantee. imapflow and mailparser STAY in package.json. the plugin has no package.json of its own and resolves from the platform's node_modules, so removing them would break the installed plugin's sidecar. the fix is giving plugins their own dependency manifest, not deleting these. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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 (
|