78896793aa467d1233e91418f8eac3bdebd4e0b3
305
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
5e4f3fe29f |
fix all seven failing tests — every one was a stale assertion, not a live bug
The suite has been carried at "1038 pass / 7 fail, same as baseline" for long
enough that the count became the thing being checked instead of the tests. All
seven turned out to pin a contract that changed on 2026-08-13, when a batch of
features were switched off pending plugin extraction. None was a defect in
running code; two were tests that could no longer detect what they were written
to detect.
── Five in registry.test.ts, one root cause ──
Switching a feature off comments out its `api:` line but keeps the permission
(the frontend route still needs gating, and existing role grants have to keep
meaning something). Eleven permissions therefore declare no API prefix now.
• `REAL_WS` still listed 'desktop', which server.tsx stopped serving. The
file's own header says it mirrors that map by hand and that this is "the
drift this file keeps catching" — it caught it, and the failure was read as
baseline for five days.
• 'refuses a claim on a router that no longer exists' withdrew /gitea, which
claims nothing any more, so nothing threw. The test could not fail for the
right reason OR pass. Re-pointed at /tasks, which is core and mounted.
• Two path→permission tests asserted /api/gitea and /api/caldav resolve.
Re-pointed at /tasks and at /user vs /users — the latter is a live overlap
AND a real privilege boundary (resolving /api/users to 'account' would hand
the ordinary-account permission the user-administration API), where the old
/dav vs /caldav pair mapped to one permission and never showed the property.
• 'every permission declares at least one api prefix' stopped being true by
design. Replaced with what it was protecting — a permission must gate
SOMETHING reachable, api or route, since one gating nothing can be granted
and revoked to no effect while reading as a control. `notify` is named as
the one genuine exception rather than silently tolerated: no API, and no
screen by design.
── plugins/music/cliamp: a real ordering fix, pushed separately ──
openPlayer checked for the cliamp binary BEFORE validating the path, so a path
escaping the owner's home answered "cliamp not found on host" on any machine
without it. Fixed there (3f4ee82): validate the request, then inspect the host.
── The pty timeout: an env var that had done nothing for five days ──
The test pinned `HOME_DIR: '/tmp'`. `HOME_DIR` was removed from the platform on
2026-08-13 when the install root became derived, and sessions.mjs resolves `~`
with node's `homedir()` — which reads `$HOME`. So the shell opened in the real
home, `pwd` never printed /tmp, and the test spent its full 15s timeout waiting
for output that could not arrive. One character class of a rename, five days
ago, costing 15s of every suite run since.
1044 pass, 0 fail, 85 files (unchanged). Suite is ~15s quicker for the obvious
reason.
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>
|
||
|
|
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
|
||
|
|
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 (
|
||
|
|
56ddb6bebf |
close the anthropic proxy secret, and re-close it on every boot
data/sidecar/claude-state.json holds proxySecret, and that string IS the owner's Claude Max subscription: officer-anthropic-proxy accepts it as x-api-key, swaps it for the owner's OAuth bearer and forwards to api.anthropic.com. it listens on 127.0.0.1, which every local account can reach. it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and Bun.write at ubuntu's default umask of 002. confirmed by reading the live secret as 'nobody', an account with no relationship to the platform, and by confirming the proxy 401s without the key so the secret is the only control. this does NOT mean members shared the subscription. spawnClaudeAsMember runs their claude through setpriv --reset-env with a six-name allowlist, so ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns go straight to anthropic on their own OAuth. that design is intact - it could just be walked around by anyone with a shell. fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the authoritative fix, and on every write rather than at creation because writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets() sweeps at boot because officer-anthropic-proxy is a separate pm2 process that can recreate the file hours after officer started, and because every existing install has the old mode until something repairs it. it repairs rather than refusing to boot, unlike assertSecretsClosed beside it. the line is who wrote the file: .env is the installer's and we must not guess at it; this is our own output at the machine's umask, so refusing to start would punish the user for our bug. the per-account copies are swept too. no live secret in them today, but they map session keys to transcript uuids for named accounts. tests verified to bite: removing the chmod while still reporting success fails four of them. a chmod does not undo exposure - the secret still needs rotating on the production host. TODO.md has the sequence, including that officer-claude-code caches it and must be restarted alongside the proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b811dfa28 |
make plan mode reachable in chat
opusplan has been in the picker since
|
||
|
|
4c24df93c1 |
a chat can change model mid-conversation
the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected. |
||
|
|
690a726f55 |
a member's turn ran as the owner after any idle gap
This is the cause. Everything before it was symptom.
`handleChat` had two spawn call sites. The first, for a new session, passed `member`.
The second — "session already live" — was a separate object literal that did not.
That branch is taken whenever OFFICER still holds the session, and officer holds it
long after the SIDECAR has let go: the sidecar reaps a session after 30 minutes idle,
`_claudeKill` stays set here regardless. So a gap sent a turn with no identity to a
sidecar with nothing to push it onto, and it created a fresh session from those
params. No member means the owner.
[claude:spawn:…] resume=49527b33… cwd=…/data/jg@pertento.ai/home as=owner
green is a Developer with their own Linux account, their own `claude` and their own
login. Every turn after a break ran as the OWNER: owner's HOME, owner's Anthropic
credential, owner's MCP config — which carries a JWT that signs as the owner. The
entire per-member boundary, undone by an omitted line in the branch nobody reads.
It surfaced as breakage rather than as silent escalation only by luck. The owner's
`claude` was handed the member's cwd — a 700 home it cannot enter — and the member's
transcript id, so it exited 1 instead of quietly working. Reported as "he went for a
walk and it stopped working", which is the idle timeout to the minute.
── The fix, and what it then found ──
Both paths now build from one `turnParams`, so they cannot drift again.
Then `member` was made REQUIRED-though-possibly-undefined in the protocol, and the
compiler immediately found three more sites that had left it out:
- `agent-handoff/deliver.ts` — the same bug, worse. No member AND a cwd from
`getOwnerHomeDir`, which ignores the email it is given. A member's agent panel ran
as the owner in the owner's home. Now resolves identity like chat does.
- `agents/agent-runner.ts`, `tasks/pipeline-executor.ts` — correct as they were:
both are `kind: 'execution'`, owner-only and never grantable. They now say
`member: undefined` explicitly, because a field you must fill in is a decision and
a field you may omit is an accident.
`resolveTurnIdentity` is exported so "whose turn is this" has one implementation
rather than three.
── What I got wrong on the way ──
Three wrong answers first — the credential, the working directory, then a recovery
whose condition could never fire — each from reading code and reasoning instead of
making the system say what it was doing. The log line that found this took five
minutes to write and should have been the first thing I did, not the fourth.
Also: `--resume` by hand appeared to work twice, which is what sent me down the wrong
paths. It proved nothing. The id I tested was not the id the platform passed.
tsgo clean, 950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9203472be8 |
the recovery could never have fired — fix its condition
Shipped
|
||
|
|
e642205207 |
a chat that cannot be resumed stops being permanently dead
A member's conversation answered "Claude Code process exited with code 1" to every message, four times identically, and could never recover. A brand new chat worked throughout. Diagnosing it took an evening and produced two wrong answers — the credential, then the working directory — both disproved by hand on the server, because the one input most likely to be at fault appeared in no log anywhere. Three changes, and the first is the one that matters most next time. ── Say what is being run ── `[claude:spawn:<key>] resume=… cwd=… as=… model=…`, before the spawn. The resume id is NOT the id in the URL — it comes from a stored map, so the two can disagree, and nothing could previously show that they had. Every hypothesis about it had to be tested by hand. ── Carry the child's reason to the person who hit it ── stderr was logged and then dropped, so the client got a number. The tail is now kept on the session and travels with the exit, both to the log and to the user. When the child says nothing at all — which is what happened here — the sentence still reads, rather than trailing a dangling colon. ── Stop retrying a resume that cannot work ── `sawOutput` records whether the child ever produced a single message. False, on a turn that was resuming, means it never got as far as loading the conversation — and since a fresh chat works with the same process, account, credential and binary, `--resume` is the only input that differs. So the stored id is dropped and the next message starts clean. The history is lost to the agent, which is a real cost, and strictly better than a thread that can only ever return an error. Nothing reconsidered that id before, which is why four identical failures in a row was the floor rather than a phase. Deliberately NOT done when the child spoke first: that is a turn that broke halfway, the transcript is fine, and clearing it would trade one bad turn for an amnesiac conversation. That negative case is the second test. `diagnoseExit` is its own module because everything around it needs the SDK to construct — which is how the old single sentence went unexamined for so long. 8 tests. This does NOT explain why the resume failed; it makes the next occurrence say so instead of costing an evening. The cause is still open. tsgo clean, 945 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
27aa72d923 |
finish the offscale sweep: two real remnants and eleven stale names
Asked for a thorough check that nothing of offscale is left in the platform. The extraction was sound — no router, no permission entry, no app-store entry, no screen, no panel app, no env var anything reads. Two things were genuinely wrong, and both were in setup rather than in the server. ── The secret store minted a key for a plugin ── `officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a key for a PLUGIN, under a name the rename had already replaced, on a machine where the plugin is not installed and nothing would ever ask for it. offscale encrypts under `offscale`, and `getKey` creates on demand — which is precisely what lets core know nothing about plugins. Now core bootstraps `jwt` alone. ── `.env.example` documented variables for a deleted router ── HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving `/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them since. Left as a note saying where the tailnet's credentials live now, because somebody with an older `.env` will otherwise wonder why setting them does nothing. ── Names that outlived the thing ── Eleven comments referred to the plugin's route, panels, console or server registry as "headscale": three sidecar config files describing their own shape by pointing at it, two screens citing `/headscale` as the example of a Workspace/Panel route, the terminal's note about the Console, and the photos types. Also `catalogue.ts`, which still claimed headscale "is CORE now", and `working-on-officer.md`, which told the reader a core install runs six processes including `officer-headscale`. Upstream references are deliberately untouched: "Headscale's API", "the Headscale admin credential", "is this a Headscale server?" all name the thing the plugin drives, and renaming those would make the sentences false. `docs/secret-store.md` argued headscale must be core because the tailnet is the perimeter. Marked superseded rather than deleted, with the distinction that settles it: the perimeter is the tailnet EXISTING, which machine-setup still arranges on a core install — not the platform shipping the screen that administers it. Verified by counting: live code, setup provisioning, env references, permission entries, app-store entries, hono mounts, dock defaults, screens and panel apps all zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the bundled catalogue entry, test fixtures using "offscale" as an example app name, and history. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
027b10bd6e |
step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
|
||
|
|
5afa2d832e |
step 3/4: sidecar routing keys become handles — NOT YET VERIFIED LIVE
Committed before restarting, deliberately: this changes the registration wire, and the restart that proves it also bounces officer-claude-code, which is what the session doing the work runs on. An uncommitted 25-file protocol change is worse to inherit than one marked unverified. `capabilities: ['music']` → `handles: ['music']` on the registration message, across 20 sidecars, both plugins, the connector, the registry and the protocol type. findSidecarByCapability → findSidecarHandling, waitForCapability → waitForHandler. The name: a sidecar already has `handleCommand`, so the list is literally what it handles. `provider` was rejected — 390 existing uses and it already means websocket door. `serves` was rejected — collides with HTTP serving, which sidecars also do. THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting `handles` reads `undefined` from a sidecar sending `capabilities`, registers it with an empty list, and every sendCommand finds nobody — chat, terminal and music all fail with "No sidecar handling X is connected". So the whole estate has to restart together; there is no rolling upgrade. If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is in-memory and nothing about this touches the database, so a revert is complete. Found a fifth meaning of the word on the way, correctly named and untouched: the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences). That is now five — permissions, the item store, routing keys, Lightning wallet features, and terminfo. tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar. |
||
|
|
e11e0b6475 |
one port announcement instead of sixteen, so a plugin can name itself
protocol.ts declared a separate SidecarEvent member per sidecar — music, vault,
slskd, headscale, transmission, invoiceshelf, jellyfin, photos, memos, gitea,
caldav, wallet, pty, email, notify, opencode — each an identical one-liner. Two
of them were for plugins that had already left the repository, which is the
tell: the platform was declaring event types on behalf of code it no longer
contains.
The cost was that a plugin could not announce its own port. `connection.send`
takes a SidecarEvent, so `{ type: 'notes:server', port }` did not typecheck
until someone added a line to the platform. A third-party plugin needed a pull
request against Officer before its sidecar would compile — for the one message
every HTTP sidecar has to send.
Now:
export type SidecarPortAnnouncement = { type: `${string}:server`; port: number };
Nothing was lost at the point of use. The only consumer is
`sidecar.on(`${name}:server`, …)` in create-proxy.ts, which already cast to
read `.port`, because a handler typed `(event: SidecarEvent) => void` cannot
narrow from a computed template string.
The proof is a test that already existed: create-proxy.test.ts announces
`testcar:server` — a name never in the union — and now compiles without its
`as never`.
Removing the two casts that escaped the old type found something else. They
were not hiding union membership, they were hiding `port: number | undefined`:
Bun types Server.port as optional because a unix-socket server has none. So
`as SidecarEvent` on the whole object was suppressing a real nullability
warning, and would have suppressed a genuinely wrong event shape too. Replaced
with a narrow assertion on the port alone. Two further `as SidecarEvent` casts
in the same file turned out to be unnecessary altogether and are gone.
[open] The typo check is genuinely gone: a sidecar sending `muzik:server` while
its proxy listens for `music:server` now compiles, and the symptom is every
request answering 503 forever. The fix is not to restore the list — it is for
createSidecarConnector to announce the port itself from the `name` it already
holds, so the string is written once and the typo becomes unrepresentable.
Recorded in protocol.ts.
Verified live: officer and officer-music restarted, `[music] sidecar registered
on port 38803`, /api/music/manifest and /api/offscale/_health both 200.
tsgo clean, 797 tests, 787 pass, same 7.
|
||
|
|
a9bf51407e |
cliamp moves into the plugin, and the platform loses its last music file
The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.
The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.
Everything cliamp now lives in plugins/music/cliamp/:
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
api/cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx
src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.
Two of the things that moved were live, not inert.
The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.
That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.
The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.
Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.
Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.
bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
|
||
|
|
de3340398c |
music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.
Three things stayed, each on purpose.
cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.
The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.
api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.
Two bugs found on the way, neither visible from reading.
The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.
[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.
registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.
music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.
Not yet verified on the live server — that is next.
|
||
|
|
e13128846b |
offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
02e049cae8 |
terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not natural". ── the replay typed into the shell ── the pty buffer was stored raw and replayed verbatim on every re-attach. anything in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP, the OSC colour queries — got asked again, and xterm answered correctly by writing the reply to its input. the pty receives that as a keystroke nobody typed. stripped on the way IN, since the buffer is the thing that gets replayed and a live client already answered them once when they were legitimately asked. only questions are removed; everything that draws is untouched. where a control shares its final byte with one that draws, the parameter is enumerated rather than wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and stripping the second would change what a replay renders. 36 tests, both directions, because both fail silently. ── two sockets on one session ── handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus whenever readyState was CLOSED — which is exactly what a pending timer leaves. both ran. every keystroke went twice, two replay frames fought over the screen, and only one socket was ever cleaned up because __terminalCleanup is overwritten by whichever connect ran last. connect() is now the single guard, and a stale socket's close no longer speaks for the session. ── the keyboard ── alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D (verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing bound it. so it broke on a dependency bump, with no shell config changed. bound in zsh rather than translated in the browser, deliberately: tmux.conf claims M-Left/M-Right for pane switching, and a client-side rewrite would send ^[b to tmux and break it. the real sequence lets tmux handle it inside a session and zsh outside. ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is assembled in machine-setup and never got them. the owner had a strictly worse shell than the accounts they provision. confirmed with `zsh -i -c bindkey` before and after. also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and every Escape, which is most of what "not natural" felt like. applied to this host by hand — setup only runs at install. cmd+arrow is left alone: xterm emits nothing for it, so there is no sequence to bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
88a44ec4a7 |
delete /api/vpn
it had no caller. verified three ways before removing: nothing in the mobile monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and the one app rendering VpnScreen never passes embedded), nothing in the officer web app references it, and the live database holds no vpn grants. the companion was checked separately by its own author — zero references there either. and it will not come back. offscale is permanently standalone: the thing that gets you to the platform cannot itself need the platform, or a broken tailnet locks you out of both. gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps a comment where the capability was, because its removal has a cost worth recording — headscale is admin-only, so no member-grantable headscale surface remains, and reintroducing one is a deliberate act rather than an oversight. kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now unreachable, but the file is also the dispatcher for /enroll/invites, which is live and fundamental. the header comment now says so, so nobody deletes it looking for dead code. also records the third component in the doc. two of the three have an "enroll" surface and only one is ours: /api/v1/enroll/* belongs to the companion, is where the phone actually goes, and must not be collapsed into /api/offscale/*. capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the 8 are pre-existing, in totality and path-to-capability, which is precisely the machinery dynamic mounting will rework. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fe0012635a |
stop leaking the parent claude session into the one we spawn
pm2 inherits the environment of whoever ran pm2 start, so restarting this sidecar from inside a claude code terminal — which is how it is restarted most of the time — bakes that terminal's session into the daemon. right now this process is carrying CLAUDE_CODE_MESSAGING_SOCKET for an unrelated pid that has been alive for an hour and a half. three of these were already stripped; the rest arrived with 2.x and were never added. this is hygiene, not the fix for today's hang — a spawn was verified to succeed with the whole set present — but a child attaching to a stranger's ipc socket is not a failure anyone would recognise from the symptom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
547662842b |
a dead claude process no longer hangs the chat forever
the sdk runs two independent tasks per session: the consumer loop (`for await (const msg of q)`) and an input pump that writes the queue to the child's stdin. the consumer loop's `finally` is what removes a session from the map — but when the CHILD dies it is the input pump that fails, with `ProcessTransport is not ready for writing`, and that rejection neither ends the consumer loop nor is caught anywhere. so the loop stayed parked on a stream with no writer, `finally` never ran, the session stayed in the map, and spawnClaudeStreaming handed every later turn to the same corpse. each one pushed a message onto a queue nobody drained: no error, no result, no timeout. the client spun forever and the only trace was one unhandledRejection line in the sidecar log. observed on the host today; the only cure was pm2 restart officer-claude-code. a member's turn already supplied its own spawn function because it has to go through setpriv. the owner had none, and therefore no place to observe the child — which is exactly why its death was invisible. so give the owner one too, and wrap both in watchChild: on exit or error, drop the session from the map and, if a turn was in flight, tell the client. emitting only while generating is deliberate. a child that exits between turns is invisible to the user, and an error bubble arriving in a chat nobody is looking at would be noise — dropping the map entry is the whole repair there, because the next turn builds a fresh session and resumes the transcript by id. the stall timer now tears the session down as well. it used to keep it — "it may still be working, and the next turn resumes it" — which is right for a slow agent and wrong for a wedged one: the session stayed broken, so every later turn hung the same way and "send again to continue" was a lie. sessions with background tasks outstanding are still left alone, since a job can be silent far longer than ten minutes and still land its notification. verified live against the real manager: killed the child mid-turn, saw the error surface and the next turn rebuild the session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fbb6d6c78c |
the agent sidecar crashed on boot without the email plugin
user-instance.ts read `getEmailAccounts` at module level, unguarded, in a
top-level await. Commenting the email schema out earlier tonight means
`email_accounts` does not exist on a core install, so that query throws, the
rejection escapes, and officer-agent exits — into the PM2 restart loop, taking
chat with it. Chat is core; the agent sidecar is what spawns `claude`.
The same file's header documents this exact failure being fixed once already, for
`resolveOwner`: "A query that THROWS — Postgres restarting, or not up yet —
escaped this function, rejected the top-level await, and exited the process into
exactly the PM2 restart loop the comment below says it exists to avoid." That cost
a session on 2026-08-10. This line has the identical shape and was never covered,
because until tonight the table always existed.
Guarded now: no accounts, a warning, and the email_db tool sits idle. An agent
must start without email — it is one tool, not a prerequisite for chat.
Checked the rest while there: the only other top-level awaits in the core
processes are resolveOwner (already hardened), sign (now backed by the secret
store, which creates on demand) and assertSecretsClosed (throws on purpose).
The three vault calls in core auth — signout, revoke, panic — are
`clearVaultTokens(...).catch(() => {})`, fire-and-forget, so a missing table is
swallowed. They are fine as they stand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
68f2c55ecf |
one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8207824a81 |
build the secret store: one key per purpose, none in .env
.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in $OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the service user, created on first use. The design doc planned to move ONE at-rest key into the store. What shipped splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all seven, so one leak opened all of them — and it was named after whichever plugin needed it first, which is why it read as safe to change if you did not run a vault. A core install bootstraps two, jwt and headscale; the rest appear when their plugin first asks. The file IS the secret. No second key unlocks it, because a key beside the store it opens buys nothing. The gain was never secrecy, it is blast radius: bun auto-loads .env into all twenty pm2 processes, so a key there is readable from /proc/<pid>/environ of twenty processes — officer-music held the key that decrypts wallet seed envelopes. Two defects found by testing the store rather than reading it, both of which would have shipped: The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than inheriting the database's mode, and a freshly written key lives in the WAL before checkpoint — so the 0600 on the database was decorative. The 0700 directory covered it, but only until someone loosened the directory. PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set AFTER it. With twelve concurrent openers, six died on that line with SQLITE_BUSY. Every sidecar opens this store at boot, so they open it simultaneously by definition: most of them would have failed to start on a cold boot and none on a warm one. Fixed by ordering the pragmas; re-tested with twelve racing processes, one key, one row. crypto.ts takes a purpose as its first argument now, which the design doc had explicitly promised would not happen — 32 call sites across seven query modules. That promise is corrected in the doc rather than quietly dropped. Also live, not just comments: wallet/upstream.ts gated wallet storage on process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It asks the store now, and the question it answers changed — not "did somebody set a variable" but "can this process open the store", since the key is created on demand. assertSecretsClosed covers the store, its directory and its WAL. The jwt key mints owner tokens, so a member's shell reading it is strictly worse than the .env leak that check was written for. Not typechecked: node_modules is empty and installs are frozen, so the officerdb/secret-store subpath could not be resolved at runtime here — verified that officerdb/types fails identically, so it is the empty tree and not the new export. The store module itself was tested directly: creation, idempotence across processes, hasKey not creating, permissions, and the twelve-way race. Every changed file parses; the setup section runs and degrades correctly when the import is unavailable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
571d0a62ff |
delete the bug-report discord webhook, and the last dead HOME_DIR reads
DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports still land in DATA_PATH/bug-reports — the disk write always happened first and the webhook was only a ping about it, so nothing about the report is lost. It was a personal notification channel living in deployment config, on a platform whose owner is the only person who files reports. It was also never in .env.example: the setup script wrote a variable nothing documented, which is the same drift as PORT, in the other direction. Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own channel — and is untouched. Then a parity sweep of setup / .env.example / what the code reads, which turned up two leftovers from earlier today: HOME_DIR was still read in six files, each with its own `?? homedir()` fallback. Dead since nothing sets it, but a dead read is worse than none — it reads as a supported override. They take homedir() directly now. user-instance.ts gets a comment on why its line stays where it is: it sits above `process.env.HOME = homeDir`, and homedir() reads $HOME, so a read moved below that assignment would return whichever member was last spawned into. Two of the six had fallback chains ending in process.cwd() and '' — the second would have silently disabled whatever consumed it rather than failing. VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes, though it is a plugin variable setup has never written. Commented out with the other plugin entries. The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which are required by code and deliberately unwritten until the secret store lands. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes three variables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c5adb4aa08 |
the anthropic proxy binds PORT + 1
ANTHROPIC_PROXY_PORT is gone. It was 5051 hardcoded in four files: proxy.ts, which binds it, and three others that guessed the same constant to find it. It is the only sidecar that binds a fixed port, and that part is a real constraint rather than an oversight. Every other one binds `port: 0`, lets the kernel choose and reports back over the registration socket — which works because their consumer is the platform. The proxy's consumer is `claude`, spawned by a different pm2 process that needs ANTHROPIC_BASE_URL at spawn time and has no channel to ask what port the proxy landed on. Two processes with nothing between them have to agree in advance. So the number must be predictable, but it need not be 5051 — a value chosen against nothing, in the registered range, free to collide with anything the owner installs later. The symptom of that collision would have been chat failing while the rest of the platform looked healthy. PORT + 1 keeps the predictability and drops both the constant and the variable. Nothing to set, no second number to keep in agreement with the first, and the pair moves together when the install moves. Also corrects .env.example, which said the proxy "holds the API credential, which lives in the host env". It does not. The upstream credential is the OAuth token claude writes to ~/.claude/.credentials.json, and the ANTHROPIC_API_KEY the agent presents is the proxy's own generated secret. Verified the derivation at PORT=9000 and PORT=10000; all four consumers now import it; every edited file parses. Still not typechecked — empty node_modules, frozen installs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3c7f52ab77 |
PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.
It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.
It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.
It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.
server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.
Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e72cae4830 |
one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one question, each defensible where it was written and none of them visible from the others: server.tsx and 19 sidecars 5000 a default from before there was an installer user-instance.ts 9010 what scripts/setup-old/setup.sh really wrote .env.example 9000 what we told people to write 5000 goes first because macOS binds it — AirPlay Receiver has owned it since Monterey, so a dev server there fails to bind or gets shadowed by something that answers. All 22 sites moved together, which is the point. Changing the app alone would have turned a consistent-but-wrong default into a split one: the app on 9000 while nineteen sidecars still dialled 5000. 9010 was the interesting one. It was the only value that ever matched a real machine, because it is what the old installer wrote — and it was in the single file whose disagreement would have broken chat alone, with nothing else looking wrong. Its own comment records the same bug being fixed once already, within the file, by a change that left it disagreeing with everything outside it. Note what these defaults actually are: the sidecars bind nothing. user-instance.ts has no listener at all — it builds ws:// and http:// URLs that both address the app's single listener. So every one of these numbers is a guess at where the app is, for a value that .env always supplies. Worth removing rather than aligning, which is a separate change. Not typechecked (empty node_modules, frozen installs). Every edited file parses under `bun build --no-bundle`; the pm2 profile loads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f071c0b24 |
the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and VAULT_STORE_KEY are no longer written by the setup script. data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/, capabilities/ and dockers/ as fixed names under it. The direction used to run the other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in app-store/paths.ts — which meant three environment variables that had to agree with each other and with the tree on disk. Eight files re-read process.env.DATA_PATH independently, each with its own `?? cwd()/data` fallback. They import the one value now, which is what made removing it safe: otherwise each would have derived its own and drifted. Three things this turned up. The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a comment asserting "__dirname is the repo root — this file sits beside ecosystem.config.cjs", which stopped being true when these files moved into ecosystem-files/. It walks up to the platform's package.json now, which holds wherever the file lives. That was a live bug before this change and a load-bearing one after it, since cwd now decides where the install is. assertInstallLayout joins the other two boot assertions. A wrong cwd does not error — it computes a plausible root somewhere else and writes managed homes and agent runs into it, so the install looks empty and the data looks lost with nothing naming the cause. It throws before serve(), first of the three, because a wrong answer there makes the other two check the wrong files. getOwnerHomeDir captures homedir() once at module load rather than per call. Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME when set rather than reading passwd, and user-instance.ts assigns process.env.HOME on its way to spawning an agent. A lazy read would have returned the owner's home on the first call and a member's afterwards. data-path.ts imports only node builtins, so it is evaluated before any of that runs. JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script does not boot — jwt.ts throws at module load without one. That is the agreed sequencing: they move to the SQLite store (docs/secret-store.md), and writing them here meanwhile would create a second origin for a secret the store then has to be reconciled with. Said plainly in .env.example and in lib/env.sh rather than left to be discovered. Not typechecked: node_modules is empty here and installs are frozen. Every edited file parses under `bun build --no-bundle`; the profile loads and pins the right cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx is not the pinned resolution and reformatted unrelated unions and line wraps in six files, so those were reverted and the edits re-applied by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c5522700ff |
compare the binary as a string, and prove the uid by file ownership
host ran the live test against green. Two results. SETPRIV WORKS. The privilege drop lands on the member and the SDK spawn survives it, so the design does not change shape and everything layered on the hook stays. That was the last question that could have moved the architecture. THE BINARY CHECK REFUSED A BYTE-IDENTICAL PATH. sameFile used realpathSync, which has to stat inside a 700 home the platform is `other` to, so it threw EACCES and the catch turned that into "not their binary" — every member turn refused forever, the moment the gates moved. Failing closed was the right direction and it made the feature impossible rather than unsafe. Now resolve(command) !== claudeBinIn(run.home). Both operands are computed by the platform from the same function, so string equality establishes exactly what the check is for and needs no access to their home. sameFile and its tests are deleted: a helper kept for a case that cannot arise is a trap for the next reader. The realpath version was defending against an upstream that normalises paths, and there is no such upstream — the platform controls both ends. The fact that decides this is that the check runs in the PLATFORM process, not the member's, and neither of us stated it until EACCES did. THE TEST WATCHED THE WRONG PROCESS. child.pid is sudo, whose real uid is legitimately the service user's until it execs down through setpriv, so asserting on it fails on a working drop. Split: --version through the hook proves their binary ran, and a second spawn creates a file in their home whose owner the test stats. Nothing self-reports, and a process cannot forge the uid that owns a file it created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9bab67aca5 |
test the privilege drop without lifting a gate
host caught a circularity I had written twice: do not lift the chat gates until a member turn has been watched running, but a member turn goes through chat and chat refuses non-owners. With the gates up there is nothing to watch; with them down the thing we wanted proven has already shipped. spawn-as-member.live.test.ts calls spawnClaudeAsMember directly against a real provisioned account — no gate, no chat, no SDK. The child's uid is read from /proc/<pid>/status, so it is the kernel's answer rather than anything the child chose to say, and it asserts >=1000 and not this process's uid: a failed privilege drop cannot pass by running as the service user. It also asserts the binary exited 0 having printed a version, which proves their install ran rather than merely being spawned, plus a negative that /bin/sh through the same hook throws. Opt-in via OFFICER_TEST_MEMBER and OFFICER_TEST_MEMBER_HOME, because it needs a provisioned member with claude installed — which exists on the production host and on no developer machine. A run without them skips loudly rather than reporting an empty file as a pass. Also adopted host's NO REPLY NEEDED terminator: "reply to everything" had no exit condition and cost the owner two agents being polite at each other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c4f150c15 |
stop one conversation's transport hiccup from killing every session
host found this while we were elsewhere: four agent-sidecar crashes tonight, one
truncating the owner's turn mid-sentence.
error: ProcessTransport is not ready for writing
at write (…/claude-agent-sdk/sdk.mjs) <- no frames from our code
A floating rejection inside the SDK's own input pump, so no await of ours could
have caught it. With no handler anywhere in src/servers it reached the top
level, Bun exited, PM2 restarted, and every live session on the machine died —
not just the one whose transport failed.
That is
|
||
|
|
d59adbf1f2 |
scope the six sessionKey commands to their caller
The control surface half of
|
||
|
|
7cb402b25a |
chat sessions record whose they are, and refuse a mismatched caller
host found this reading 10: chat sessions carry no identity at all. state.ts
held a flat sessionKey -> transcript uuid map, the in-memory sessions Map was
keyed the same way, and websocket.ts takes sessionKey and resumeSessionId
straight off the client message.
|
||
|
|
3e0daee611 |
chmod the mcp config, because writeFileSync's mode never fires on it
host measured what
|
||
|
|
df4503180f |
stop handing a member's turn the owner's mcp config, and close the file
host found a live credential exposure while answering my question about what else the member branch missed. It was outside the diff, and predates all of it. MCP-CONFIG WAS NOT BRANCHED. mcpHostPath is module-level, written once at the owner's bootstrap, and was applied to every turn. Its env block carries OFFICER_AUTH_TOKEN, a 30-day JWT signing as the owner — so a member's turn would have spawned their MCP server holding it. Now inside the params.member ternary alongside the binary and the spawn, for the reason already written there: these values say whose turn this is and have to move together. A member gets none. What they should get instead is undecided, and undefined beats the owner's. THE FILE WAS 0644. Written with a bare writeFileSync into a 755 directory, on a host where `terminal` is granted to every role by default — so any member could cat it and hold owner-level API access on loopback. host verified that as a real member on the production host rather than reasoning about it. Now 0600. The mode is the only half of that which is code. The token has been world-readable and stays compromised until rotated, the directory chain above it is still 755, and neither is fixable from a commit. Both written up for the owner in COMMS 05, along with why I am stopping here rather than continuing: the next commit should be the rotation, not more feature work stacked on top of an open exposure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fbabc22ed7 |
wire the member branch into the SDK spawn, still unreachable
ClaudeSpawnStreamingParams takes an optional member {osUser, home}; createSession
branches on it, using their binary and spawnClaudeAsMember together, or the
owner's CLAUDE_BIN as before.
THE BINARY AND THE PRIVILEGE DROP ARE ONE BRANCH ON PURPOSE. settingSources
makes ~/.claude authoritative for settings and ~ is whatever HOME the process
gets, so pointing the SDK at a member's binary while spawning as the service
user would read the OWNER'S settings and credential while running the member's
code — and it would look like it worked.
cwd defaults to member.home before HOST_HOME for the same reason: HOST_HOME is
this process's home, so a member would start in a directory they cannot read and
the failure would present as a broken agent rather than a wrong cwd.
Nothing populates `member`. Both gates refuse non-owners before any of this is
reached, so the delta is that spawnClaudeAsMember now has two importers instead
of one, and neither path a user can take changes. Verified rather than assumed,
since host made it a condition: both gates intact, 84 tests pass.
Not authorization: host gave an opinion on wire-first and deferred to the owner,
who has not ruled. Corrected in COMMS, where 01 had overstated it. The gates
come off on the owner's word alone; this reverts as one commit if the answer is
no.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6b7aad91db |
make both env guards able to fire, and follow the symlink
host was right twice, including about his own advice. Two dead guards had shipped here, both for the same reason: written inside the spawn closure, where the only way to reach them is to spawn — and the passing path spawns sudo. So nothing ever demonstrated either one firing. THE SUBSET CHECK WAS ALSO TAUTOLOGICAL. `permitted` came from the same constants memberEnv builds childEnv from, so it was empty under every edit where that holds — the exact criticism that retired NEVER_ENV. Worse, it lost the one live trigger the denylist had: a credential added to ALLOWED_ENV used to throw, and under the subset check widened the permitted set in the same motion and passed silently. That is the realistic future edit and it was the one left unguarded. Now both, and the denylist tests the LIST rather than the instance, so it fires on exactly that edit. Extracted as `assertEnvSafe` so a test can pass a poisoned allowlist — the guards being untestable in place is why they were decorative twice. THE BINARY CHECK WOULD HAVE THROWN ON EVERY TURN. Anthropic's installer puts a symlink at ~/.local/bin/claude into a versioned directory; resolve() does not follow symlinks, so the string compare matched only while `command` arrived as the symlink spelling, and would have failed the moment anything upstream normalised it — at exactly the point the hook gets wired. Compared through realpathSync on both sides now, per turn and never cached, since `claude update` moves the target. Nine tests pin all of it: a poisoned allowlist, a stray key, each NEVER_ENV name, and the symlink/target/missing-path cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6a85ca5d1f |
the binary check that was a comment, and a guard that could fire
Four fixes from the live server's review. One was a real defect.
THE BINARY WAS NEVER CHECKED. spawn-as-member passed `command` from the SDK
through untouched while a comment claimed the member's own install was what ran.
Since claude-manager resolves the OWNER'S CLAUDE_BIN at module load, wiring the
hook would have exec'd the owner's binary as the member — the precise confusion
this file exists to prevent, asserted in prose and enforced nowhere. Now throws
unless the command resolves to claudeBinIn(run.home).
NEVER_ENV COULD NOT FIRE. It tested an environment that memberEnv builds from
ALLOWED_ENV, so a denied name was already impossible; it was also missing six
credential variables the installed SDK reads. Replaced with the subset check the
reviewer proposed: anything not in ALLOWED_ENV or {HOME, CLAUDE_CONFIG_DIR} is a
leak whatever it is called. Complete by construction, and it cannot rot as the
SDK grows variables — which the denylist provably had already.
Also: one derivation of the binary path instead of two (install resolved from
the email, exec from the home — fine until they disagree), and the constraint
that ALLOWED_ENV may never hold a secret written at the list itself, since
`env K=V` in the argv is visible in /proc/<pid>/cmdline to every account.
Not acted on, and said so in COMMS: their finding that the 711 in
|
||
|
|
62e98dff2e |
a member's claude is their own binary and their own login
First half of per-user Claude. Provisioning and the privilege drop, not yet
wired to a turn — the chat gates stay up and behaviour is unchanged for
everyone. Committed unfinished on purpose so the reasoning is on the record
before the server agent runs any of it; the state is written up in
COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md.
THE CLAIM THAT CHANGED. docs/per-user-linux-accounts.md:226-229 says the Agent
SDK "has nowhere to put a uid", so a member's turn has to become its own
process — a change of shape rather than a flag. It is a flag:
sdk.d.ts:951 exposes spawnClaudeCodeProcess, documented for exactly this ("run
Claude Code in VMs, containers, or remote environments"), and node's spawn
already satisfies the SpawnedProcess shape it wants. So no second sidecar, no
PM2 entry, no inverted transport, and none of the registry rework a second
instance would have forced (registration is name-keyed and evicts its
namesake; the nine claude verbs resolve by capability with no selector).
THE PLATFORM NEVER RUNS AS A MEMBER. The tempting reading of "each member runs
their own Claude" is a second officer-agent under their uid, and it is wrong:
that sidecar needs POSTGRES_URL and the JWT signing secret, so a member-uid
process holding them could read every account and sign a token as the owner —
strictly more than their shell can do, and already forbidden by the .env boot
check. The harness stays the service user's; the thing that runs the member's
code and holds the member's credential is theirs. That is the pty sidecar's
shape, not a new one.
PER-MEMBER BINARY, deliberately, over one shared /usr/local/bin/claude. The
private part is the credential, not the executable — but claude updates itself,
and a root-owned binary is one a member cannot update, which turns "my agent is
a version behind" into a request to the owner. Same installer the owner's own
install uses, run as them, in their home. Idempotent by skipping when present
rather than re-running: the retry button reprovisions on every press.
ALLOWLIST, NOT A FILTER, for the child's environment. At the moment of the call
the calling process holds POSTGRES_URL, the JWT secret and the owner's
ANTHROPIC_API_KEY; setpriv --reset-env means nothing crosses unless written
into the argv, so an allowlist is the complete answer to what a turn can see,
and a denylist would have to be right about every variable added later.
NEVER_ENV throws rather than leaks if someone widens it.
Login is the member's own act against their own account. The platform cannot do
it for them and must not try — the alternative is lending them the owner's
credential. claudeLoginState only reports whether the credential has appeared,
and reads it as the member, so a true answer means their process can reach it.
NOT VERIFIED: any of it at runtime. tsgo passes; nothing has been provisioned
and the spawn hook has never been called. If it turns out setpriv breaks how
the SDK reaches the process, this approach is wrong and the fallback is the
earlier plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
71589aee99 |
a member's terminal looks like the owner's
A new Linux account opens a shell with nothing: useradd copies /etc/skel, which on Ubuntu is a bash rc, and the account's shell is zsh — so it got no prompt, no history, no completion, no colour. "Their own account" should not mean a worse terminal than the owner's. src/servers/shell-skel/zshrc is the template, and scripts/starship.toml is reused rather than copied: setup.sh already deploys it for the owner, so one file serves both audiences and they cannot drift. Seeded by provisionOsAccount, which means the retry button applies it to accounts that already exist — no delete-and-recreate. The template depends on nothing but zsh. Starship, eza, nvim, bun, deno and cargo are each used only if present, and every path is $HOME-relative — the owner's own .zshrc has three absolute /home/pastilhas paths in it, which is exactly what a template must not inherit. Without starship it falls back to a zsh prompt showing the same information, because a shell that opens with a broken prompt reads as a broken machine. Never overwrites: written only when the file is ABSENT. ~/.zshrc.local is sourced last and never written, so there is somewhere to put your own config that no future template can reach. Three fixes found by running it: - install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came out root:root — readable but not writable by its owner, which would have surfaced weeks later as one tool mysteriously failing. The parent is now created explicitly. - useradd took its shell from process.env.SHELL, which under PM2 is whatever PM2 was launched from. A member's shell depended on how the server happened to be started. Now chosen from what is installed: zsh, else bash. - the pty sidecar spawned ITS $SHELL for a member, not theirs. It now execs their passwd shell via sh -c, so the login shell in /etc/passwd is the one they get. starship moves out of the light-profile skip. The light profile exists to serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is what every member gets. Leaving starship out meant the fallback prompt on exactly the installs most likely to have members. oh-my-zsh, eza and lazygit stay full-only. Verified in a real member shell: zsh from passwd, HISTFILE in their own home, eza-backed ll, starship active, EDITOR=nvim, and an edit to .zshrc surviving a reprovision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e4acf19a35 | Merge remote-tracking branch 'gitea/master' into sidecar-app-store | ||
|
|
4d4a253f72 |
terminal runs as the member; chat is grantable and still refused
TERMINAL is confined now, and the shell is genuinely theirs. The pty sidecar spawns it through sudo setpriv as their own account, in their own home, with the platform's environment cleared. Verified end to end against the sidecar's own socket: id -u 1001, not 1000 file the shell wrote owned by ptyprobe ps -o user=,args= ptyprobe /bin/zsh -i env | grep -c POSTGRES 0 osUser and home are resolved in upgradeWs from the authenticated account, and whatever the browser sent under those names is DELETED first. The bridge forwards the query string to the sidecar untouched and the sidecar starts a shell from what it finds there, so trusting the client for either would let a member ask for the owner's uid in a query parameter. node-pty does support uid/gid, unlike Bun.spawn, and they are deliberately unused: they set the ids without applying the account's groups or resetting the environment, so the shell would keep the owner's groups and everything Bun loaded from .env. Also closes the pty identity blindness in TODO.md. Sessions record whose they are, list and kill scope to the caller, and re-attaching to a session belonging to another account is refused — otherwise a member resumes someone else's shell by guessing an id that travels in a query string. Measured: member killing the owner's session -> ok:false, owner killing it -> ok:true. CHAT is confined so the owner can grant it and the route resolves, and both execution doors refuse a non-owner: the router wholesale, and the socket in server.tsx. The agent has not moved — the SDK spawns claude itself with nowhere to put a uid, and every transcript path resolves through the owner's home, so a member would read the owner's session list and run an agent as the owner. Reads are refused too, because listClaudePwds returns the names of the owner's projects. A deliberate, temporary gap at the owner's request: permission and route now, function when a turn can be spawned under runAs with the member's own HOME. Both guards say so, and the registry test names them so a future edit cannot move one without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a3dbda7d3b |
phase D: delete the subprocess path
The serve is now the only way an opencode turn runs. runner.ts and its tests are gone, and so is the OPENCODE_TURNS switch — there is no fallback engine any more, and the recovery for a bad day is git rather than a config flag. Deliberate, and cheap right now precisely because nothing depends on opencode yet. What goes with it: mapRunLine and its NDJSON fixtures, the temp-file spill for --file image attachments, the supersede-and-kill dance, the process watchdogs, the pidfile-adjacent child tracking, and stopAllOpenCodeTurns. All of it existed to work around stdin being /dev/null. Verified after deletion, with no env var set at all: tool call, tool result, 5 streaming deltas, text and cost, through the real chat socket. Also corrected the comments the deletion falsified rather than leaving them to mislead — the module header, the wire contract description of opencode:run-streaming, and serve-runner own header, which still announced itself as off by default. One difference worth stating: shutdown no longer kills anything. Turns run inside the serve, which is a separate process that survives us, so officer stops routing them and says so in the transcript. When a subprocess ran the turn, failing to kill it orphaned it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b79eca45fb |
send images on the serve path too, before anyone switches to it
runOpenCodeTurnOnServe ignored params.images entirely, which is defect B4 rebuilt on the new path: the image renders in your own bubble and the model never receives it, with nothing reporting a loss. Fixed before the path is switched on for anyone rather than after. data: URIs, not file://, and that is measured — the wrong choice is accepted with a 200 and then dies inside the turn with "Anthropic Messages media must contain valid base64". The data URI round-trips and the model describes the image. Strictly better than the subprocess path here: no temp file to spill and nothing to clean up, because the bytes travel in the request. Both prompt paths carry them — an ordinary send and a mid-turn injection. Verified end to end through the chat socket with the serve engine on: a red png came back "**Red**", with deltas streaming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a058cbb3fd |
inject a mid-turn message instead of replacing the turn
Phase C server half, and a real flaw in phase B. On the subprocess path a second message could only supersede — kill the process, start again, lose the turn — because opencode run has no input channel. The serve takes another prompt into the running turn, so a message arriving mid-turn is handed over with delivery steer and the existing turn is left exactly as it is. Keeping the same turn object is the load-bearing part. Phase B retired it and registered a replacement, which stops officer routing events the serve is still producing while the serve carries on regardless: output goes nowhere and the turn looks hung. Verified end to end through the chat socket — sent a count to 50, injected a change of plan eight seconds in, and BANANA INJECTED came back inside the same turn with deltas streaming throughout. No client change was needed. Officer composer already sends while generating; the difference is only what the sidecar does with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a06422bd4c |
phase B: run a turn through the serve, behind a switch
OPENCODE_TURNS=serve picks the new engine; unset keeps the subprocess, which is the default and stays the default until this has been lived with. A bad evening should cost one restart, not a revert. Claude is a different sidecar and is untouched. Verified end to end through the real chat socket: session:init -> tool:start(bash) -> tool:result -> assistant:delta x3 -> assistant:text -> result, cost in=304 out=73 Those deltas are the first token streaming an opencode turn has ever produced in officer. Stop is now an INTERRUPT: the turn ends and the session survives — verified by sending a second prompt to the same session afterwards and getting an answer, which killing a subprocess could never do. Reads the LIVE global stream rather than the durable per-session one, because it is a strict superset — same tool.called, tool.success, step.ended, text.ended, plus the deltas that are the whole point. Global means one socket carries every session, so everything filters on sessionID; one subscription is shared for the process rather than one per turn. A turn ends on step.ended with finish != tool-calls. tool-calls is a step boundary MID-turn, and treating it as terminal would cut every tool-using conversation in half. delivery is stated explicitly as queue because it DEFAULTS to steer, which injects into a running turn — wrong for an ordinary send, where two quick messages would merge into one. Wiring steer to the button that means it is phase C. What phase B does not do: read the durable stream. The sidecar still commits every event to chat_session_events as it arrives, so durability is unchanged, but recovering a turn this process never saw needs the ?after= cursor and is its own change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
feb9010097 |
phase A: map the serve event stream, routed nowhere
The mapping half of the serve migration, written and pinned before anything depends on it,
so the switch-over is not also the moment the parsing turns out to be wrong. Nothing routes
through this — turns are still opencode run subprocesses, and the claude path is untouched.
The finding that matters: the serve publishes each turn TWICE, and reading the wrong one
makes it look like it cannot stream at all.
/api/session/{id}/event?after= durable, per session, replayable, durable.seq on every
event, whole values only, NO deltas
/api/event live, GLOBAL, ephemeral, carries text.delta and
tool.input.delta, no cursor
Same turn: 13 events durable, 21 live, the difference being 3 text.delta and 5
tool.input.delta. I probed the per-session one first and nearly recorded "no streaming" as
a fact — it would have removed the main reason to migrate. The split maps exactly onto what
officer already does for claude: durable to chat_session_events, live to UI deltas. The cost
is that the live stream is global, so a consumer must filter on sessionID.
tool:start is emitted on tool.called, not tool.input.started, because only tool.called has
the resolved input object — the input arrives as JSON fragments ({"comman) and a tool row
rendered with half-parsed arguments is worse than one that appears a moment later.
step.ended with finish tool-calls is a step boundary MID-turn, not the end of the turn, so
nothing terminal is emitted for it. Treating it as the end would cut every tool-using
conversation in half.
Fixtures are verbatim captures from 1.18.16. Replaying both real streams through the mapper
reconstructs the turn identically from each, with the reassembled deltas exactly equal to
the committed text and identical cost, and zero unrecognised events.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|