4a206086affac11938eaa4f58bd241ffa61530ce
241
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
887749722c |
fofos's capabilities failure was ownership, and one stat proved it
Three deploys reported the same two errors and neither report could explain them: dubious ownership in repository at '~/officerdev/capabilities' EACCES: permission denied, mkdir '~/officerdev/capabilities/affordances/officerdev' They read as two problems — git refusing to operate, and Node refusing to create a directory, at different steps of the deploy — and they were one sentence in two dialects: this path is not yours. Fifty paths under that checkout were owned by root, .git and affordances among them, all stamped 19 Aug 21:43. Something ran with sudo and left them. Mode 755 is what made the shape confusing. Everything was READABLE, so git could see the repo and refused it on ownership, while the plugin could see affordances and could not write into it. Two tools, two messages, one inode owner. The general point, which is why this is in servers.md rather than only in a commit: stat prints the owner instead of inferring it, so it either proves ownership is the cause or eliminates it — one command, binary, no theory. Three reports had guessed; none had looked. The deploy commander's refusal to add a safe.directory entry was right and is recorded as such. It would have silenced the git message and left the EACCES exactly as it was, spending the obvious explanation on half the problem. Fixed by the owner with chown -R; verified after by a mkdir into the directory that used to refuse one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aac8af5e1f |
one attempt per release, and the rule is code rather than a paragraph
The first watcher had an unbounded retry in it. A release whose deploy failed left no .done file, so the next poll two minutes later spawned a fresh headless agent, and the one after that, all night, each one costing money. The loop was careful and its logging was honest; the defect was structural. It is structural because the loop is written FRESH by an agent every time one arms. A rule that lives only in the prompt gets re-implemented from scratch each time, and the wrong implementation is the one that looks right: test the .done file. That file only exists on success, so testing it is exactly the retry storm. So scripts/deploy-claim.sh owns it. `claim <version>` exits 0 once and 1 for ever after, whatever became of the attempt that held it — a failed release is not retried, it is looked at. Handing the claim back is `rm -rf` by a person, on purpose; a loop cannot undo its own claim, which is what makes the storm impossible rather than unlikely. Three choices in it worth keeping: - `mkdir` is the claim. One atomic syscall that both tests and takes, with no window between checking and acting — two pollers a second apart is not hypothetical when a watcher gets restarted. - Bash, not bun. The thing that stops a retry storm must not need a healthy checkout; a deploy that dies on `bun install` is precisely when it has to work. - A version is digits and dots or it is refused. It arrives off a git tag and is concatenated into a path. A pre-existing .done with no claim beside it still counts as deployed, so deploy/2026.08.21.01 — rolled out by hand before any of this existed — is not re-attempted. docs/servers.md corrected from the commander's own report, which was right and specific: on macbookpro APPEND /opt/homebrew/bin for bun rather than replacing the PATH (this file said "do not help the PATH", which is right for pm2 and is what made plugins:update fail there), `ls` is aliased to an eza that is not installed, plugins:update bounces every sidecar before the plan's own restart step, and fofos cannot publish affordances for what is probably the same ownership problem as its capabilities sync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8662f94b3b |
seven platforms, and the login is per host
deadpool-rezio-bridge joins the list: 100.64.0.21, checkout /home/br/officerdev/platform, deployed by
hand and verified — 7/7 processes online, username NOT NULL, uq_users_username_lower present, HEAD at
|
||
|
|
31e858bcb7 |
docs: a running list of servers, and the fleet updater it feeds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bcf8df9d32 |
design: notifications from every server one app is signed into
The good news survives checking. An APNs auth key belongs to the developer account, not a machine, and a device token belongs to the app, not a server — so N installs holding the same .p8 all push to one phone with no coordination, and the app posting its token per server is something the multi-server manager does anyway. One field is missing: the payload carries no origin. That breaks the deep link (a session id is meaningful only on the server that issued it), and threading, and — least obviously and most expensively — `mv`. That is a content hash of THAT server's catalogue, and the client's rule is `mv !== version → resync`. One stored catalogue and five servers means every push from B invalidates what was synced from A, forever. It would present as a battery and data problem rather than a bug. Where the identifier comes from is deliberately left to the sign-on design: the multi-server manager already needs a key per server, and notifications should adopt it rather than invent a second identity that drifts the first time a host is renamed. Records what stays per server on purpose — devices, per-chat mute preferences, the catalogue itself — because the app is what joins them, which is the same shape as the sign-on work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3fb439fabf |
plan: nightly visual regression on a second server
Capture every route of a JWT-authenticated dashboard each night, diff against a promoted baseline, and have an agent look only at what changed. The blocker is auth, and it fails silently. iris seeds a capture with Cookies, Cookies-wal, Login Data and Local State — a JWT lives in localStorage, which Chrome keeps in Default/Local Storage/leveldb and iris does not copy. So an authenticated capture renders the login screen: a real screenshot of a real page that is the wrong page. Three ways out, with a recommendation. Copying leveldb works and drags every site's localStorage into a temp profile. A token in the URL needs no profile at all and removes the desktop plugin from the plan entirely — that is the one to try first. Seeding through the relay works and needs a browser paired at 3am, which is the constraint iris exists to avoid. The rest is deliberate about where the model is spent: pixel diff first, mask what is known to move rather than raising the threshold, and only changed pairs reach an agent — with "if you cannot tell, say so" in the prompt, because a confident wrong verdict on night four is how this stops being trusted. Baselines are promoted by a person, never by a run succeeding, or a regression becomes the baseline on night two. Also records what Officer lacks: the queue has retries and no schedules, so this is cron. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4efc2552d0 |
os-deps: a dependency declares who has to be able to run it
The probe asked the owner's login shell where a binary was, and the install reported that as a fact about the machine. For a confined plugin the work happens as the member's own Linux user, in their own home, where the owner's ~/.local/bin is neither on PATH nor reliably reachable. On alpha it worked — because /home/pastilhas happens to be mode 751, world-traversable, so an absolute path resolved. At 750, the more usual default, every member would have lost every binary with nothing reporting it: the install would still have said satisfied, having only ever asked the owner. Nobody chose 751, and that is the part worth fixing. `scope: 'owner' | 'shared'` on an osDependency, defaulting to owner, an unrecognised value being a manifest error rather than a silent fall back to the permissive one. Shared changes three things. It is searched for on system directories only — asking the owner's shell would find the copy in the owner's home and call it satisfied, the exact false pass this exists to stop. Reachability is checked as world-executable AND every parent traversable to the root, because a binary at 755 inside a home at 700 is unreachable and stat on the file alone says it is fine. And a release binary goes to /usr/local/bin through sudo -n install, staged in the owner's space first so the download itself never runs as root. An unreachable shared dependency gets its own state and refuses the install ahead of every other check, because no package manager fixes it: the binary is installed and works, and what is wrong is where it lives — reinstalling would report success and change nothing. Not a security boundary, and docs/plugin-dependency-scope.md says so: anything in /usr/local/bin is runnable by any account with a shell, grant or no grant. That is already true of ffmpeg. The plugin's data is gated; its executable is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
10feb76880 |
permissions: a plugin can mark part of its surface owner-only
A plugin worth granting to a Member can also hold the credential the rest of it runs on. Jellyfin's /_config stores a server's admin token and drives account provisioning; the rest of the plugin is exactly what a Member should have. The choice was all of it or none of it. `ownerOnly` on a plugin permission takes sub-paths relative to the mount prefix. The permission stays fully grantable — this is not the ownerOnly PERMISSION that manifest.ts refuses, and that refusal still stands: a permission nobody can delegate is a contradiction. This says a few paths administer the plugin rather than use it. Declared by the plugin, enforced by the platform. A plugin could check this itself and the first one would; the tenth would add a route and forget, with no boot failure and no warning — the shape assertPermissionTotality exists to prevent. In the gate it is enforced once, on the door they all pass through, and a route added later by someone who never read the manifest is covered by default. Checked before selfService and before kind. A path that is somehow both self-service and owner-only is a contradiction, and the safe reading has to win. A write grant does not unlock it either — level is not the question this answers. Also forwards X-Officer-Owner and X-Officer-Level to a sidecar whose proxy names its permission key, for the finer decisions the platform cannot make for it: whose rows a read returns, which fields a level may see. Facts, never the role name — a plugin branching on "Super Admin" would give every plugin repository an opinion about the platform's role vocabulary, and renaming a role would change authorization in code the platform does not own. These are safe to trust only because the proxy copies a three-item allowlist off the inbound request; that is now written where it would be changed. The owner's level is reported as write rather than the absent 'none': they hold everything implicitly and appear in no grants map, so the raw lookup inverts the meaning for the one account that can do anything. No plugin declares one yet. Jellyfin cannot until connections stop being per-user — locking /_config today leaves a Member with a plugin that only shows a connect form they may not submit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1915a407a2 |
write down the plugin route authorization design, agreed and deferred
A plugin's settings routes should be owner-only while the plugin itself stays grantable — jellyfin's /_config holds a server's admin token and drives provisioning, and a Member granted the plugin can reach it today. Agreed: the platform says who is asking, the plugin decides what that means inside its own surface. That is not new — manifest.ts already refuses an ownerOnly permission on the grounds that finer visibility is the plugin's business. Two refinements recorded rather than assumed. Pass isOwner and the effective level, never the role name: roles are the platform's vocabulary and a plugin branching on "Super Admin" gives every plugin repository an opinion about it, so renaming a role would change authorization in code the platform does not own. And nothing may be trusted from the caller — the proxy's three-item header allowlist is what makes X-Officer-User safe, and a new header inherits that only while it stays an allowlist. One disagreement, recorded as a proposal rather than settled: pure plugin-side enforcement is the shape assertPermissionTotality exists to prevent. Jellyfin will check because we are about to write the check; the next plugin's author forgets a route and nothing notices. So: declare owner-only path prefixes in the manifest, enforce them in the proxy, and pass the facts to the sidecar as well. The real prerequisite is not the guard. Connections are per-user, so locking /_config leaves a Member with a plugin that can only show them a connect form they are forbidden to submit. The fix that fits what exists is the owner's server URL with the member's own token from jellyfin_accounts — which is arguably why provisioning mints those tokens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aa5bacec46 |
email recall: the design, and which answers can be trusted
the use case is someone mid-phone-call who needs a fact from eight years of correspondence now. everything in here is in service of that moment. the split that came out of the discussion, and it is the whole document: counting needs EXHAUSTION, retrieval needs SPEED, and they are different machinery. an agent asked "how many times did she ask X" will search, find some, and answer with a number bounded by the phrasings it happened to think of — "i found four" is indistinguishable from "there were nine", and nothing in the output says which. vector search does not fix that; top-k misses too, just differently. the only honest count is to judge every message once and store the verdict. which makes the compile half the one that answers the questions that prompted this, and it needs no vectors at all. embeddings still earn their place for the other row: keyword search finds "school run" and never finds it from "the pickup arrangement", and on a live call thirty seconds of searching is a lost moment. the stronger argument is whatsapp and transcripts sharing no vocabulary with email — but we do not have those, so they shape exactly one decision (the store is not email-shaped) and nothing else. two constraints stated as constraints rather than preferences: the embedding model runs LOCALLY, because an api means uploading eight years of private correspondence to a third party, and there is no ollama or embedding code on any machine here — that is the real new dependency, not the database. and it is sqlite-vec in the plugin's own file, not a vector database: 19,097 rows is small, and a separate service would be operational surface bought for nothing. the hard part is neither. it is quoted reply chains putting the same paragraph in forty messages of one thread, which naive embedding turns into forty identical results. step 1 of the build order is chunking with no embeddings at all, because if that is wrong nothing after it can be right. a stored verdict is a judgement a model made about someone's divorce, so it records what it was asked, what it read and when. a number with no provenance is worse than none, because it will be quoted. |
||
|
|
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. |
||
|
|
5a26cdbbcb |
find my device: the design, and what it can never do
sketched the same day push landed, because push is the piece that makes it possible at all — there is no other way to reach a sleeping phone. more of it exists than expected: the wake channel, a working maplibre map with clustering in the photos plugin, and headscale already reporting online and lastSeen per node, which covers laptops without an app at all. what is missing is a table, an endpoint, some app code and a panel. what it can never do is stated up front rather than discovered later. apple's offline finding works because a billion strangers' devices relay a lost phone's bluetooth beacon. there is no self-hosted version of a crowd. off-grid gets you last-known-position; powered-off gets you nothing. two things i expect someone to get wrong, so they are called out above the schema: - do NOT key location on the push token. apns tokens rotate, the registration path is already an upsert because of it, and a history keyed to one silently forks into two devices. this needs a stable on-device uuid and a `devices` table that is not push_devices — which is worth building anyway, since the push registry currently has no stable device identity at all. - store accuracy and draw the circle. ios returns 3km fixes when it is being cheap, and rendering one as a confident pin sends someone to the wrong street. the sharpest open question is not technical: the owner bypasses every permission check by design, so on a family server the owner can see a member's location structurally, unless location is given an explicit exception. that falls out of the architecture rather than out of anyone's intent, so it should be decided deliberately. retention likewise. a location table is a complete movement record, apple deliberately cannot build one, and self-hosting means choosing to. the rule belongs in the design, not in a settings screen added afterwards. |
||
|
|
c68c734431 |
docs: what an affordance is, and the test that catches the bad ones
the substance was in plugins/CLAUDE.md marked "DESIGN, NOT BUILT", which stopped being true the same day it was written — load.ts reads them, publish.ts copies a plugin's in on install, and claude-manager compiles them into every session. moved to docs/affordances.md and corrected; plugins/CLAUDE.md keeps the plugin-specific part and points here. the new part is a third test, earned today by nearly failing it twice: CAN IT BE FIXED INSTEAD? an affordance is for something irreducible about the surface, and a defect is not irreducible. both candidates drafted today failed it. "links open in the same tab" was one missing line in MD_COMPONENTS. "AskUserQuestion never returns an answer" was true when the code comment was written and already fixed by the time i read it — that one would have shipped a permanent instruction to work around a bug that no longer existed, in a file whose whole purpose is to be believed without checking. names the failure mode: an affordance describing a defect calcifies it. once there is a document explaining the workaround, the defect has a rationale, and the rationale outlives anyone's memory that it was a bug. also records two things that are not built and read as if they were: nothing is scoped, so every affordance is in every conversation, and affordanceBlock() has exactly one caller — an opencode turn gets none at all. |
||
|
|
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. |
||
|
|
2cefa3d378 |
apns credentials belong in the secret store, and cannot go there yet
env is the interim. the secret store's own reasoning applies almost verbatim: bun auto-loads .env into every pm2 process, so the .p8 is readable from /proc/<pid>/environ of twenty of them. officer-music has no business being able to read a key that can push to the owner's apps forever and never expires. the obstacle is not a missing purpose string, which is what it looks like from the outside. getKey(purpose) GENERATES a key and returns it — there is no setter, because everything in the store so far is something the platform minted itself. the .p8, the key id, the team id and the fcm service account all come from outside, so this needs putSecret and a decision about who may call it. same for DISCORD_WEBHOOK_URL and every plugin credential still in env. recorded in TODO.md and in the design doc rather than left as an intention. |
||
|
|
e598a5dcba |
notify: codes replace the type enum, and android goes data-only
the sidecar now sends what the design describes.
producer contract: { code, userId, n?, id? }. validated on the SHAPE of the
code, never against the catalogue — a producer firing a code the mapping has
not caught up with is sending a real event, and dropping it here would trade a
degraded notification for none when the device already has a defined fallback.
the shape check still catches a typo at the producer instead of on a phone.
`ok` is gone. a failed job is its own code, officer/core:job-failed, which is
the thing the five-value enum could not express without a boolean bolted on.
apns: mutable-content: 1, alert.title fixed to the generic, and officer:{c,n,id,mv}
beside it. deliberately NOT the catalogue's title — sending the real text would
hand apple the category vocabulary in plaintext, which is what the code exists
to withhold.
fcm: data-only, no notification block. a notification block is rendered by the
system, which would put the code on the lock screen and give the app no chance
to resolve it. priority high is load-bearing — a normal-priority data message
can sit in doze indefinitely.
discord renders server-side from the catalogue, because it has no app to
resolve anything. safe: catalogue titles are templates and carry no user
content.
the catalogue is loaded ONCE per dispatch, not per channel or per device, so
every copy of one notification carries the same mv. two channels reading disk
either side of a plugin install would otherwise tell two devices different
things about the same event.
40 tests. verified live: a malformed code is refused, officer/core:test is
accepted and dispatches to zero configured channels.
|
||
|
|
55d6eba511 |
notification catalogue: GET /api/notify/mapping
the piece that makes a new notification kind a server change rather than an app store release. a push carries a code and integers; this is what the code means, synced to the device instead of compiled into it. deliberately the same shape as affordances rather than a second mechanism: a `notifications/` directory of yaml in the plugin repository, copied to capabilities/notifications/<publisher>/<appName>/ on install, removed on uninstall. publish.ts mirrors affordances/publish.ts closely enough to diff. the namespace is DERIVED from where the file lands, never from what it declares — same rule as mountPrefix. a plugin writing `officer/core:test` as a key gets `<publisher>/<plugin>:officer/core:test` and the real core entry is untouched. that is the security property of the whole scheme and it is pinned by test. version is a content hash, not a counter. a counter needs somewhere to live and is then wrong after a restore, a rollback, or two servers built from the same install; a hash needs no storage and is right in all three. so the client compares mv !== version, and both docs now say so — i had written "higher". core entries ship in code rather than on disk, because capabilities/ is installed rather than committed and `test` has to exist on a machine that has installed nothing — it is what first light is proved with. read from disk per request, not memoised: installing a plugin has to change the answer without a restart, or it presents as "the new plugin's notifications don't work", which is the hardest kind of bug to place. verified live: 401 through the platform, plugin catalogue appearing and disappearing with no restart, version returning to its prior value when the plugin is removed. 10 catalogue tests, 100% coverage of catalogue.ts. |
||
|
|
98960d2827 |
app spec: the answers from the app repo, and alpha caught up
three questions answered from monorepo-mobile, and two of my assumptions were wrong. bundle ids exist — dev.officer.mobile and dev.officer.mobile.dev. two of them needs nothing special: push_devices is unique on (token, bundleId), so the variants are separate rows on one phone, and the dev variant is exactly the sandbox/production distinction the doc warned about. concrete now rather than derived from build config by guesswork. scheme is officer:// and officer-dev://, both from app.config.ts. expo prebuild, not bare — and a config plugin that creates an ios app extension target ALREADY EXISTS there, withVaultAutofillTarget.js for the autofill extension. the nse follows it. that removes what i had called the longest pole in phase 3. corrected: expo-notifications is not in apps/mobile at all, only apps/chat. the real blocker is narrower than the doc implied and is not code. today's signed build has no aps-environment entitlement, and adding one fails while xcode has no account signed in — same failure as the network extension on 2026-08-05. one manual sign-in, team RGHC4NJC77, then automatic again. alpha: /api/notify was 404 all day because that server predated the work. pulled, db:push, gen:ecosystem, officer-notify started, officer restarted. verified 401 unauthenticated and push_devices with its 10 columns. |
||
|
|
e57d6cc1cc |
push notifications: the app-side spec
the 2026-07-31 design promised this document to `officer-suite/COMMS/`, a repo path that has never existed. writing it where the work is instead, to be handed to whoever builds the client. exact contracts rather than prose: the three device routes with real request and response shapes read off devices.ts, what is required versus defaulted, and that the server takes the user from the session and ignores a userId in the body. most of it is the failure modes that cost a day each and are invisible from the client — a send with no credentials configured returns success and delivers nothing; a sandbox token registered as production fails at apple with no user-visible symptom; the notification extension must not touch the network or it will pass every test at a desk and fail on cellular with the vpn off. leaves three questions for the owner: whether apps/mobile has a bundle id and a provisioning profile at all, its url scheme, and expo managed versus bare — that last one decides how the extension gets built and is probably the longest pole in phase 3. |
||
|
|
347e679712 |
push notifications: the hub design
second design, superseding 2026-07-31. the reversals are recorded rather than overwritten, because the first one called its payload rule "not optional" and this changes it. one app on the device receives everything and renders it. the payload is a namespaced code plus integers; the code's meaning lives in a server-owned mapping synced to the device ahead of time, so a new notification kind is a server change and never an app build. plugins declare their own codes on the affordances pattern, which already does exactly this shape. what moved: privacy relaxed but not abandoned — codes keep content off apple and google while dropping the rigid category enum that made the doorbell painful. appSlug inverts from "deliver to this app" to "deep-link to this app". extensibility becomes the binding constraint. the piece that makes it work is that the ios extension does a LOCAL lookup against a synced mapping, not a fetch on arrival — so it does not depend on the tailnet being up when the push lands, and it costs one build ever rather than one per kind. records two things it does not solve: the code vocabulary is still a fingerprint to apple and google, and the grapheneos/fcm contradiction between apps/mobile/PUSH_NOTIFICATIONS.md and the platform design is still unresolved. |
||
|
|
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. |
||
|
|
f77a41d26a |
command hq: delete a commander, from the grid card and the detail bar
DELETE /command-hq/commanders/:sessionId — deletes the transcript itself via deleteClaudeSession (the same function DELETE /chat/sessions/:id calls, so a /clear chain goes with it, every part), then the command_hq_sessions row via deleteCommandHqSession (existing, exported, never previously called from a route — its docstring said as much: "no product path to this yet"). Deliberately real deletion, not an unfile-back-to-/chat path — there still isn't one of those, and this doesn't create one. Documented next to "The /chat <-> /command-hq relationship" so it doesn't read as contradicting the "a session never moves the other direction" rule directly above it. Frontend: both the grid card and the detail bar's meta row get an inline confirm (trash icon -> check/cancel), same shape as ChatHistory's own SessionRow delete. The grid card's delete control is a sibling of the card's <Link>, not nested inside it, per docs/navigation-audit.md — the card is a stretched full-cover Link (absolute inset-0, default stacking) with the delete control in its own stacking context (z-10) on top of it, so the card stays click-anywhere except where the trash icon sits. Deleting the commander currently open in the detail panel navigates back to the bare list rather than leaving it pointed at a 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a335fad9a6 |
background jobs: tell the agent not to discard command output
Traced "(no output yet)" on a live commander to the actual running process on this machine rather than guessing: the tray's log-tail was working correctly (it's a general /chat feature, nothing to fix there) — the task's .output file was genuinely empty because the agent had written `timeout 8 ping -c 1 -W 5 google.com >/dev/null 2>&1`, throwing away ping's own output and only echoing on a failed ping. Correct for "let me know if it goes down," but it means a healthy watcher shows nothing at all, indistin- guishable at a glance from a stuck one. buildBackgroundJobPrompt gains an unconditional rule — every job shape, not only watchers — telling the agent not to redirect a command's own output to /dev/null or similar: whatever it would print in a terminal is what the owner wants to see live in the job's log. Landed at rule 8 for watchers (after the existing fire rule), 5 for a bare command, 4 for a bare prompt — whichever number falls after the rules already in that branch, so rule 7's existing identity (referenced by number in tests, docs and COMMS) is untouched. Also lightly reworded rule 7's "stay silent" line: it was already only about the agent's own narration, not the underlying command's output, but the distinction wasn't explicit and reads exactly like the instinct that produced the >/dev/null in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
963c965056 |
command hq: auto-name a commander from its watcher's own task description
The initial title (the mechanics preamble, truncated, when a background job is
the very first message) is expected — that's just Claude Code naming the
session from its first message, same as always. But by the time the watcher's
task actually starts, the harness has already handed it a real description
("Watch google.com reachability" for a ping watcher), and that reads far
better than the preamble ever will.
Hooked into the exact moment pendingArmWatcher is consumed — the FIRST arm,
never a self-re-arm, which the watcher-loop code already had to distinguish
for its own reasons. Resolves a fresh ChatIdentity via resolveHomeDir and
calls renameClaudeSession with msg.description. Best-effort: a failure is
logged and does not touch watcherArmed.
Known gap, noted in the doc rather than fixed: the detail panel's title bar
is fetched once on mount and won't live-refresh when this fires while it's
open. The grid's cards don't have this problem since GET /commanders already
polls every 15s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a9be3a92c9 |
command hq: web frontend v1 — nav, grid, detail
Builds the Workspace/Panel screen the design doc's "Layout, v1" described but nothing had implemented yet: /command-hq and /command-hq/:sessionId, three always-mounted panels (officerdev/src/apps/CommandHQ/) — a flat category nav filtering the grid via ?category=, a commander grid reading GET /commanders, and a detail pane that is either an empty state, the new-commander composer, or the selected commander's resumed transcript. Follows the frontend route conventions in CLAUDE.md throughout: Workspace/Panel framework (locked, three fixed panels, no standalone screen), category and session both addressable in the URL rather than a channel, real <Link>s for every row and card, a route pair (/command-hq + /command-hq/:sessionId) the same shape as /chat's. The composer reuses the exact mechanism /chat already uses to create a commander — an ordinary turn with context: 'command-hq' via useChat/ EmbeddableChat — rather than the mobile-only POST /commanders/new HTTP route. Once the harness reports a real transcript id the URL is replaced onto it (useChat's own permalink writes /chat/<id>, which is the wrong screen here, so that's done by hand). fileAsCommander always files a new commander uncategorised — it has no way to know a category was selected in the composer — so if one was, a client-side PATCH sets it after the fact, the same race POST /commanders/new already resolves server-side for mobile, just resolved here on the client instead. The detail panel's transcript resume mirrors ChatPanelWrapper's fetch-then- mount pattern (GET /chat/sessions/:id first, then useChat with resumeSessionId + initialMessages + paginate) rather than reusing it directly — Command HQ's version is simpler (no dashboard/agent-panel concerns) and scopes rename/category/disarm through Command HQ's own PATCH route rather than /chat's, since a commander lives in a different directory than an ordinary chat session. Card status is the three-state read COMMS/command-hq/mobile-4.md proposed and the design record adopted: green running, amber armed-but-not-running (the watcherArmed && !running pair that makes a silently-dead watcher visible), grey neither. Registered in AppRegistry.tsx, re-exported from officerdev's barrel (route helpers + types, per CLAUDE.md's rule for anything an officer-web screen needs), a CORE_DOCK_ITEMS tile (not in DEFAULT_DOCK_PATHS — opt-in via the Dock settings screen, same as Jobs/Monitor/Activity), and a usePageTitle rule. bunx tsgo clean, suite at its 7 pre-existing baseline failures, none of them this feature's. NOT yet clicked through against a running server — the composer's redirect-on-real-id, the disarm button, the category-race PATCH have all typechecked and nothing more. That's the next real test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
7229ce021d |
watcher loop: the prompt/dialog half, and stopped no longer reads as failed
owner's call, confirmed 2026-08-18: implement watcher-loop-proposal.md
exactly as written, before the first real tests — this governs agent
behavior generally, not just command hq. items 1-2 of the proposal's own
suggested order, done; items 3-4 (harness-owned re-arm, arming state on the
commander row) are the harder half, next.
FireBehavior replaces the stopOnFire boolean: 'stop' | 'handoff' |
'keep-going'. handoff carries the proposal's one-sentence contract verbatim
("the harness re-arms you — do not re-arm yourself, and do not build your
own polling loop on top of this one") and rewrites the closing IF IT EXITS
block so it stops contradicting rule 7 (the old universal "do not restart
it, i will start it again" directly lied about what handoff jobs actually
do).
isCommander gates the option to where the promise can be kept. the proposal
frames this entirely in terms of commanders — arming state's only home is
the commander row — so outside one there's nowhere to persist "keep
re-arming this," and offering the option there would make a promise nothing
fulfills. not wired to true anywhere yet: command hq's own composer, which
would set it, doesn't exist. every existing /chat caller is unaffected —
two options, stop default, unchanged from before tonight.
stopped-vs-died (§5): a job officer had just SIGTERM'd came back
status:'failed' (exit code 144) from the harness's own classification,
which this codebase does not control. fixed client-side — the tray already
knows the instant stop is clicked, so it now also marks that, and
useChat's task:notification handler overrides the displayed status
regardless of what the harness reported. known gap, stated in both the code
and the doc: in-memory, so it doesn't survive a refresh in the narrow window
between clicking stop and the notification landing.
docs/command-hq.md gains "the watcher loop" section: what's built, what
isn't, the proposal's own suggested order followed as given.
bunx tsgo clean. suite: 1038 pass (+9 new tests), same 7 pre-existing
baseline failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f810bdc48e |
command hq: displayText, caught by the mobile side before either of us ran this
mobile-2.md found it: a commander created to become a background job sends buildBackgroundJobPrompt's full mechanics as `prompt` — a page of preamble, the owner's actual sentence only at the end — and command_hq_sessions has no name column, so the title comes straight from `(displayText ?? prompt).slice(0,100)`. without it every such commander titles itself with the mechanics text, and the grid becomes a column of identical unreadable cards. same precedence the websocket path already uses on both clients (sendWithHiddenPrefix on web, useSessionView.ts on mobile) — now the same on this endpoint too. fixed before either side had run this against a real server, so there's nothing to clean up on a live grid. also confirmed rather than assumed, per mobile's other note: cwd cannot be talked into a different one on this path. resolveChatCwd's command-hq branch ignores msg.cwd unconditionally and this endpoint never sets it. replied in COMMS/command-hq/platform.md. also read watcher-loop-proposal.md (exit/act/drain/re-arm as harness infrastructure, "Andre's call" to build it before first tests) — not starting it off a COMMS note alone, taking it to the owner directly since it's a real scope addition beyond what tonight's command hq build was scoped to. bunx tsgo clean, suite at baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7ad64445ee |
command hq: POST /commanders/new, for a client that cannot open the websocket
mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.
the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.
getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.
the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.
said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.
docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e9b1ce5e94 |
command hq: the web creation path, and a member could not have used it
`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.
found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.
the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.
pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).
docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.
bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
eb94af87f4 |
command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission (personal: ['/'], no grant to hold — every query is scoped to the caller's own user_id at the sql layer regardless). registered in the permission registry and PROTECTED_MOUNTS together, so totality holds by construction. GET /commanders is the grid endpoint: everything /chat/sessions already returns per session, plus categoryId/tagIds/running. `running` is derived live off collectRunningTasks (now exported from websocket.ts) folded over chat_session_events — not a stored column, on purpose, matching the design doc's stance that a status field would go stale exactly when a sidecar restart silently kills a watcher. GET /commanders/:sessionId deliberately does NOT duplicate the transcript — that stays GET /chat/sessions/:id, unchanged. a client calls both. /chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds, .catch()'d so a hiccup on this feature can't take the whole chat list down) — the other half of the one predicate this whole design turns on. there is deliberately no POST /commanders/new: creation is an ordinary chat turn in the commanders directory, which is the next commit. docs/command-hq.md gains an API reference table for the mobile dev, and a status line at the top saying plainly what is real and what is not yet: needs `bun db:push` before any route returns real data, frontend not started. bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all registry/totality drift and the pty socket test — none touch this router). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
0f93262a8c |
usage panel: verified for one account, not for two
the owner confirmed it live the same day. what that leaves untested is the half the cache exists for — a second login, the tabs, a stored row surviving a switch — so the docs now say which half is proven instead of claiming neither is. also records the bug the first deploy found, since the doc had asserted the degradation that turned out to be broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19f558077e |
document the usage panel and what testing it would mean
it has never run: db:push has not been applied and neither process has been restarted. docs/ai-usage-panel.md records where every number comes from and why officer cannot derive any of them itself, the two things the live payload settled (every model-scoped bucket is null while limits[] carries a real fable window; the window is named by kind, never by the model name), the measured costs, and the staleness rules — which are the part most likely to be wrong, because they assert things about data they did not measure. sixteen ordered tests in five groups. group C is the actual feature. test 10 is the one that would break silently: a refresh must not overwrite an account it could not measure. also records what the scan measurement found, since it decides the deferred work — 74.7% of message ids appear in more than one transcript file, so per-file aggregates cannot be summed and an incremental scan is not the one-liner it looks like. TODO.md gains an entry under "waiting on the owner to run it", beside plan mode, which is also still unexercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f31d464be |
plugins: declare unauthenticated paths, validated; mounting designed and measured
half of the primitive kiosk needs. the manifest field and its validator are in;
the mounting is documented and not wired, so nothing serves anything publicly
today.
paths are relative to the plugin's own mount and refused rather than corrected:
no leading-slash-missing, no .., no //, no query or fragment, wildcard only as a
trailing /*, and never the bare / or /*. the plugin is trusted with what those
paths DO, never with which paths.
the mounting was measured rather than reasoned about, and two obvious designs
are wrong:
mounting the public router at the plugin prefix gives correct fall-through but
lets the router serve any route it defines - the manifest becomes documentation
rather than enforcement.
adding a use('*') guard blocks that leak and BREAKS the plugin's authenticated
api: a use('*') middleware always matches, so the parent never continues.
measured - an authenticated route 404'd with a valid token.
what works is registering only the declared patterns, delegating into a
pre-mounted app. undeclared paths never register a handler, so they fall through
naturally. enforcement by registration rather than by checking, which is why a
plugin cannot widen it.
docs/plugin-unauthenticated-paths.md has the probe results and the six
remaining steps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f6658bd092 | docs: kiosk replaces the marketplace, and the shape it takes | ||
|
|
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> |
||
|
|
4655d374ec | email extraction: plugin side built, platform untouched | ||
|
|
c5ed6cc32c | plan of record for extracting email into a plugin | ||
|
|
43fb2952fa |
a test plan for plan mode, for a day that is not today
the owner has no time to test any of it, so the checklist has to work for someone with none of today's context: eleven ordered tests, what each should show, and what its failure looks like. test 4 is called out as the one most likely to be wrong - approving ExitPlanMode drops the harness into default rather than back into bypassPermissions, which is the finding that changed the design and therefore the thing the implementation is most likely to have got wrong. also separates what was verified from what was not: the probe runs against a real claude, the turn-stream unit tests, and then everything end to end, which is nothing. the inline 'what to watch' list in chat-plan-mode.md is replaced by a pointer so there is one source of truth, and TODO.md now says live-but-unexercised rather than built-not-run - the restarts are done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
004f702747 |
no rotation needed: nobody else has ever used this install
the owner confirmed the additional accounts are test rows - none has ever signed in, no member has opened a shell or a chat. so the proxy secret was readable by accounts that do not exist in practice, and there is nothing to rotate. the mode fix stands: it is about the install that gets used, not this one. dropping the TODO and recording the reasoning instead, because a rotation item left sitting there reads as outstanding work forever, and the next person to find a world-readable secret should ask who uses the box before reaching for the alarm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
4125949468 | record plan mode as built, and what to watch on the first run | ||
|
|
9be484a105 | verify plan mode is reachable on a bypassPermissions session | ||
|
|
a4907fdfdb |
plan of record for plan mode, and mark the sessions list built
opusplan is in the picker and does nothing on this install: it is a per-MODE model switch (Opus while in
plan mode, Sonnet at rest — the CLI's own strings call them the upgrade and resting models), and
claude-manager hardcodes permissionMode: 'bypassPermissions', so plan mode is never entered. Not
orchestration, which is what the name suggests and what we both assumed.
Making the mode reachable is the fix. setPermissionMode is a control request on the live query, same channel
as the setModel that landed in
|
||
|
|
fc67973876 |
record a resumed session's model when it switches, not when the turn ends
`createMessageHandler` is built per turn, so `transcriptId` started null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was nothing to key the row on. A resumed conversation therefore recorded its model only on the closing
`result`, and a turn that died took the switch with it.
Observed rather than reasoned: the sidecar log said `claude-code/fable -> claude-code/opus[1m]` while the
stored row still said `claude-code/opus`.
Seeded from the resume id, which the Claude path already has in scope for the spawn. A brand-new
conversation still waits for `session:claude` — it has no id yet by definition — but resumed is the case
where switching actually happens.
Also lands docs/chat-sessions-list.md: the plan of record for the sessions-list revamp, agreed with the owner
today. Six numbered targets, four decisions with their reasoning, and the landmines — chiefly that merging
the two left-column panels strands `chat-live` in every persisted layout, which is the same stale-appType
shape that made /offscale render three server pickers this morning (
|
||
|
|
27aa72d923 |
finish the offscale sweep: two real remnants and eleven stale names
Asked for a thorough check that nothing of offscale is left in the platform. The extraction was sound — no router, no permission entry, no app-store entry, no screen, no panel app, no env var anything reads. Two things were genuinely wrong, and both were in setup rather than in the server. ── The secret store minted a key for a plugin ── `officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a key for a PLUGIN, under a name the rename had already replaced, on a machine where the plugin is not installed and nothing would ever ask for it. offscale encrypts under `offscale`, and `getKey` creates on demand — which is precisely what lets core know nothing about plugins. Now core bootstraps `jwt` alone. ── `.env.example` documented variables for a deleted router ── HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving `/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them since. Left as a note saying where the tailnet's credentials live now, because somebody with an older `.env` will otherwise wonder why setting them does nothing. ── Names that outlived the thing ── Eleven comments referred to the plugin's route, panels, console or server registry as "headscale": three sidecar config files describing their own shape by pointing at it, two screens citing `/headscale` as the example of a Workspace/Panel route, the terminal's note about the Console, and the photos types. Also `catalogue.ts`, which still claimed headscale "is CORE now", and `working-on-officer.md`, which told the reader a core install runs six processes including `officer-headscale`. Upstream references are deliberately untouched: "Headscale's API", "the Headscale admin credential", "is this a Headscale server?" all name the thing the plugin drives, and renaming those would make the sentences false. `docs/secret-store.md` argued headscale must be core because the tailnet is the perimeter. Marked superseded rather than deleted, with the distinction that settles it: the perimeter is the tailnet EXISTING, which machine-setup still arranges on a core install — not the platform shipping the screen that administers it. Verified by counting: live code, setup provisioning, env references, permission entries, app-store entries, hono mounts, dock defaults, screens and panel apps all zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the bundled catalogue entry, test fixtures using "offscale" as an example app name, and history. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c0e61a4cba |
the trap keeps its evidence, and says where the procedure is
This bug will not be reproduced on demand. It will arrive as somebody's report, hours later, from a browser whose console is long gone — so a `console.warn` is seen by nobody, and "what did you see?" gets an answer built from memory and a screenshot. So the trap now persists. Up to five occurrences in `localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment of the edit, the first 200 characters that arrived, how many, which file, and when. The question becomes "paste this key", and the answer is complete. Recording a bug must never become one: private mode, a full quota and a disabled storage API all throw at that write, and none of them is worth breaking an edit over. The console line survives either way. `docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four candidates already eliminated and why, the one command to ask a reporter for, how to read each field, what an EMPTY key would mean (either the editor was focused, making it a paste, or the insertion came in chunks under the threshold), and the instruction to delete every piece of this once the cause is known. Linked from TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it without grepping for it. Stated there rather than left implicit: a fix invented without knowing the cause would hide the symptom on the one screen where it is visible while the mechanism carried on doing whatever else it does. The corruption is never saved and reopening the tab clears it, so waiting is cheap and a wrong fix is not. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |