121ddb99645b2466c2a6e30e66fd95b2be61e589
263
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6d673a1321 |
plugins: a plugin's tasks are read where they live
the last piece of docs/plugin-capabilities.md. skills, affordances and notification catalogues already loaded from each installed and enabled plugin's own directory; tasks did not, so the transcribe plugin shipped a task nothing could see — right-clicking an .m4a offered no Run Task at all. it turned out to be one function rather than several. every path that runs a task resolves it through getTaskByDirName and then reads implementation off the record, so the sources go in there and in listTasks and a plugin's task is runnable everywhere at once, identity unchanged: still a directory name, still what pipeline_jobs stores. the record carries `plugin` and that one field does the rest. updateTask and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the plugin, and the capability screen shows whose it is instead of an edit and a delete that cannot work. without the delete guard it was quietly a no-op: rm -rf on a path in the owner's store that does not exist, then ok: true, and the task still in the menu. owner's store first, so a plugin cannot take over a name already in use. getTaskByDirName now refuses a name containing a separator. it always joined a caller-supplied string onto a path — from a websocket message and a job row — and searching several roots is the moment that stops being one directory nobody could escape. capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and carries the provenance, with capabilityRoots the thin map over it that the loaders that only want paths still use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
b6d6de7e53 |
commanders: a commander is a directory, not a session
Implements phase 1 of docs/commander-directory.md. The row owns its own identity
and its own directory; session_id becomes a nullable pointer to whichever
conversation is current.
The ordering was the bug. POST /command-hq/commanders took a sessionId the caller
already held, so a chat existed first and was filed as a commander second — and
the working directory is fixed when that session spawns, before any id exists. It
now takes {name, referencePath}, creates the row, its directory and its CLAUDE.md,
and returns the cwd to start the chat in. Nothing waits on the CLI.
commander-block.ts is deleted. Claude Code reads a CLAUDE.md from the working
directory, so what it injected per turn is written once at creation and the
commander extends it as the job is agreed. resolveChatCwd no longer trusts
msg.cwd at all: it looks the row up server-side. The containment reasoning that
made a client-chosen path safe still holds — it just no longer applies, because
the client has nothing left to choose.
Four things found in review, none of which the build had.
Ownership. confineUserTree chowns commanders/ once, at provisioning, so anything
created afterwards belongs to the service user — a MEMBER's commander directory
would be one their own claude cannot write into, failing as an agent that dies on
its first command in a directory it can see. Invisible because it works perfectly
for the owner, who has no osUser. Handed over on creation, best effort, and the
error is returned as well as logged.
getCommanderDir is guarded. Two callers rm -rf what it returns, and createCommander
inserts dir_name as '' before fixing it up from the serial id — an empty name
resolves to the commanders ROOT and would take every commander the account has.
The transaction closes that window; the guard means it does not have to.
The fallback in resolveChatCwd was silent. A commander whose row is not found runs
in the account's home with no CLAUDE.md, which reads as an agent that forgot its
job rather than a lookup that missed. It says so now.
And sv-SE is renamed alongside the other five locales. It did not exist when this
work was written.
Verified: SQL applied against a scratch database first — idempotent, several
commanders can hold a null pointer at once, one-per-transcript still enforced.
Then here: db:push emits no DDL and does not prompt, officer restarts clean, and
POST /commanders returns a real directory holding state/ and a CLAUDE.md.
1428 pass, tsgo clean.
The single existing commander was deleted rather than migrated, per the design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
24f3b4bdef |
commanders: the script gathers, the agent judges
The two kinds of work in a commander have opposite economics. A script runs per event and costs nothing, so volume has no ceiling. An agent runs per batch on material a script assembled, and costs a turn each time. So the rule for building any commander is to push as much as possible into the script and let the agent see only what needs judgement. The tiers are not fixed, and that is the part that matters. The commander owns its scripts — they are files in its own directory — so every correction moves work downward: "this one was missed, it had eight digits" turns a judgement into a rule. The agent tier thins as the free tier improves. A commander that runs for six months should cost less per event than it did in week one, not more. That is the opposite of how automation usually ages, and it follows from two decisions rather than from hope: the agent owns its scripts, and corrections arrive through dialogue rather than a ticket to somebody else. Nothing improves a system it cannot edit. Records the two requirements that make it safe. A verdict is reproduced rather than logged, because the input is still on disk and the script is deterministic. And a change is re-run over the existing corpus before it ships, or the fix for eight-digit codes quietly starts flagging every order confirmation, unattended, at three in the morning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
3adc88963b |
commanders: the connection is the platform's, not the commander's
Framed this as a credential-safety rule; it is a boundary. A commander does not hold a Whisper URL, does not name one and has no parameter for it — it knows the platform can transcribe and asks the platform. So there is usually nothing sensitive in a commander to leak, and sharing is simple for a reason rather than by luck. The credential rule survives for the case where a commander genuinely needs a secret of its own. Also records a scope decision. Whether the platform is configured at all is a system administration problem, not something to solve with a capability registry — and the commander skill already covers it: do the first run by hand, then arm. An unconfigured endpoint fails in front of the person doing it, and the same rule catches somebody forking the commander onto a machine that lacks it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6d06acc111 |
commanders: definition versus state, and phase 2 sharing
The directory holds two kinds of thing and they must not mix. The definition — CLAUDE.md, parameters, procedures, scripts — is what travels. The instance state — what it has handled, what it spawned, its loop's pid — must never, or a recipient's commander starts up believing it already handled somebody else's recordings and silently does nothing. Structural from the first commit rather than sorted out later, for the same reason plugin capabilities are read in place: a directory boundary beats a convention, because a convention is what a tired person violates. Parameters separate from procedure, too. With a path baked into a script, a fork is a debugging session; declared in one place, a fork is a conversation — the new owner is asked where their memos land and the commander rewrites its own file. Phase 2, recorded and NOT being built: a commander turns itself into a git repository when asked, in conversation. No button — it git inits, ignores state/ and commits, which is the same shape as everything else here. What it needs to know goes in the commander SKILL rather than an affordance, because an affordance's body is charged to every turn of every commander forever and this earns that approximately never. Two rules that belong in the design now because both are awkward later. A credential is definition-shaped — it looks like configuration — so gitignoring state/ does not protect it; commanders name what must be configured and the platform holds the value. And a shared commander declares what it requires, or it lands on an install without a Whisper server, arms cleanly and fails silently at 3am, which is the worst failure this system has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
ba64bcd2e6 |
commanders: no migration, delete and re-arm
The one commander here is unarmed and has never fired, so a migration would be a code path that runs once and never again. Deleting it and re-arming on the new system also exercises the flow the design exists to fix, which a migration would route around. Notes the shelf life: free while nothing depends on a commander, not free after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
27d85f934c |
design: a commander is a directory, not a session
Its identity is borrowed — command_hq_sessions.session_id is Claude's transcript id, minted by the CLI as the first turn begins. Verified: the id on the live row has a transcript file on disk. Three problems fall out of that. The working directory is fixed when the session spawns, before the id exists, so there is no point in the current flow where a commander can start in a directory named after itself. The first-turn interlock is a side effect being used as a signal — commanderBlock cannot resolve its paths on turn one, and the skill turns that emptiness into "you must be on your first turn". And losing the conversation loses the commander, because its state lives in the transcript and in a second tree keyed on the same borrowed id. So the platform owns the id, creates the directory first, writes CLAUDE.md into it, and spawns the session there. The transcript id becomes a pointer on the row rather than the thing the commander IS. Nothing waits on the CLI, so the ordering problem stops existing. Two things it buys beyond that. Claude Code reads a CLAUDE.md from the working directory, so the living document needs no mechanism at all and commanderBlock's per-turn injection goes away. And a commander survives its conversation: a dead session can be replaced in the same directory, with its scripts, procedures and record of what it has handled intact. The API does not change. The server just gets to do things in an order that works. Flags the consequence nobody would notice until it bit: the skill's "turn one is orientation, read where you are" assumes cwd is the target. Under this design the commander's own directory is nearly empty and the interesting thing is elsewhere, so that guidance moves with the code or every new commander orients on nothing. One commander exists on this machine and it is not armed, so the migration is cheap now and gets dearer with each one started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6572fa90f0 |
design: plugin capabilities are loaded in place, not copied
capabilities/ is meant to be forked — core's work plus the owner's, and nothing else. Four publishers copy plugin files into it at install, so a fork accumulates files it did not author with nothing on disk saying which. On this machine skills/email-db/ and extensions/ are sitting untracked in that repository right now, and there is no good answer: commit them and the fork carries another plugin's work, leave them and git status is permanently noisy. Copying is wrong a second way that has nothing to do with git. A copy goes stale — plugins:update pulls new plugin code while the copied capability keeps describing the old one — and a copy survives plugins:disable, so a disabled plugin's skill stays in the agent's index. So the platform reads them where they are: the capabilities directory, plus each installed and enabled plugin's own. Nothing is copied, and nothing needs a namespace to stay apart because separate directories already do that structurally rather than by convention. Four bugs stop existing: forks stay clean, capabilities update with the plugin, disable actually disables, and uninstall needs no cleanup — so there is no list of filenames to get wrong and delete the owner's work with. Deletes all four publishers when built, including api/tasks/publish.ts from earlier today, superseded before it shipped anywhere. app-store/assets stays — those are images served over HTTP, and the copy is what makes them reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0fc43ef6d0 |
plan: filesystem quotas, the half the platform cannot do
Per-user and per-team storage limits need the kernel, not the platform. Members hold terminal at write and have their own Linux accounts, so a quota counted through the file browser is decorative — they open a shell and write past it. The plan is usrquota and grpquota, deliberately not project quotas: every case here is "this user" or "this team", both already identities the kernel knows, and a team is already a Linux group. Skipping prjquota also skips the inode-size trap, since project quotas need 256-byte inodes and cannot be added to a filesystem that has 128. Includes the pre-flight that decides whether the procedure applies at all, the rescue-boot steps, and a throwaway-account test that proves enforcement — because enabling quotas and setting none looks exactly like success. The easiest thing to get wrong is recorded prominently: a team folder that is not setgid charges its files to each member's own group, so the team limit counts nothing. For edge-pertento, which is the first real multi-user host and already has one account. officer-dev will not be multi-user and alpha is the owner's own; neither needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
5c6aea4ffc |
teams: the four open questions are answered
A team owns a shared folder. Any Admin may create one. A team may grant a capability the member's role lacks, capped so the creator cannot attach anything they do not hold themselves. Deleting a role is refused while anyone holds it in a team, same as globally. Dashboards deferred deliberately: they need editor-versus-viewer, which is a permission shape nothing here has, since every grant today is read-or-write over a whole capability. Also records two things phase 4 must not get wrong. The folder boundary is a Linux group, not the grant — every role already holds files at write, so the capability was never what kept teams apart. And the grant cache is keyed on role today; teams make it per-user, which is easy to lose silently. Plus the quota model and its prerequisite: enforcement is the kernel's, because members have terminal and can write past anything the platform counts. Neither officer-dev nor alpha can carry filesystem quotas today and neither can be fixed while running. edge-pertento is where it matters — the first real multi-user host — and it should be checked before it gains accounts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a6509f6456 |
docs: account creation exists, and has since 69a31051
Three files said there was no way to create a second account. There is: POST /api/users, owner-gated, with a form under Settings -> User Management, plus role change, password reset, Linux provisioning and delete. The plan went further and sequenced a whole phase around building it. It cost something today. A recommendation to "do phase 5 first, it unblocks everything" was made from this text, and withdrawn only because opening api/users/ to write the spec found the route already there. Worth naming the shape rather than just fixing the sentences: docs:check compares numbers, so the counts stay honest while the prose rots. Both times this has bitten, the stale claim was a paragraph nothing could verify. A phase marked "not built" is worth checking against the tree before planning around it — a plan is written once, the code moves daily. The root CLAUDE.md carries the same correction plus the roles-as-a-table state; it is not in a git repo, so it is fixed on disk only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
084acecd22 |
localization: "panel plus file names" was the wrong test for content
Phase 3 guidance said to expect roughly half of FileBrowser to be content that
stays English. useFileBrowserApp.ts, the largest file in the phase, was 100%
convertible — not one hit was content.
The distinction that actually holds: a panel LISTING things renders content, and a
file name in a row is the user's. A panel REPORTING about those things owns the
sentence — `Renamed to "{{name}}"` is ours and the file name is a parameter, which
is where content belongs. So the question is whether a string is a frame around
content or the content itself, not whether the panel touches file names.
It matters for plurals too: six hand-rolled ones in that file became real entries
because Portuguese reorders them, which a frame with a count spliced in cannot
express.
Recorded because the wrong version of this rule came from me, and left alone it
would have had somebody decline conversions that were correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
44ee444cac |
the owner is an identity, not a role
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which was right while roles were a hardcoded union and nothing could write a new one. It stopped being right the moment roles became a table: from then on "add a role" and "become the owner" were the same feature, reachable from whatever UI manages roles. So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and `isSuperAdmin` delegates to it, so no call site changes who it admits — widening any "owner-only" route to "owner or Super Admin" is a separate decision per route, not a side effect of this. Still a database read rather than a bare comparison: a deleted or blocked account must not remain the owner because its id happens to match. `getOwnerUser` becomes a primary-key lookup. It used to select on the role with `order by id` as a tiebreak, because two holders would otherwise make "who owns this server" whatever Postgres returned first — an answer that decides the agent sidecar's identity, vault access and origin scoping. That tiebreak, the write-path refusals and the CHECK all protected an answer that is now a primary key. Several Super Admins are allowed. What the role carries is real and belongs in the UI rather than a refusal here: it provisions an OS account with sudo, which puts the holder outside Officer's permission system and cannot be undone by revoking the role afterwards. That is the operator's decision, not this endpoint's. Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning user 1's role is KEPT — redundant for ownership, but it still forbids the owner being set to Member, which is a nonsense state that costs nothing to prevent. And promotion to Super Admin is allowed while creating an account directly as one is not: promotion happens to an account somebody has looked at, creation is a form filled once, and the role that grants sudo should not be a dropdown default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
2f8a2b60d5 |
localization: the 'what exists today' table described a world three phases ago
Every row was wrong — bare en.json, 46 keys, three components, 5% deployed — and
it is the first thing whoever picks up phase 3 reads. platform-59 flagged it and
left it alone rather than colliding with me mid-edit, which was the right call.
Kept as a before/now table rather than replaced. The shape of the gap is the
argument for the phases, and deleting the 'before' column would leave them looking
arbitrary.
Also records two things the plan did not anticipate, both found by doing the work:
TranslationKeys walked into a plural object so t('key', {count}) — the whole point
of D7 — would not compile while the workaround did, which no test could have caught
because phase 1 had no plurals. And the typed wrappers rebuilt t every render,
which is the trap CLAUDE.md already documents against useClient().
And the shape this catalogue format cannot express: a sentence with an inline link
splits into a key whose Portuguese value is '.'. Flagged rather than solved, since
a node-level interpolation helper is a design decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
91b9bec926 |
localization: a label quoted from another product's UI is an identifier
Sonnet stopped phase 2 on GoogleOAuthConfig.tsx, where 55 of 67 strings are quoted from Google's own console — 'Click Enable', 'set App name'. It called it an editorial choice and asked. It is not a choice; it is already covered by the principle the never-translate list is built on, and the list just did not say so. Those labels read like prose and behave like identifiers: their whole job is to match what the reader sees on Google's screen, and Google localises by the reader's Google account language rather than ours. Translating them sends somebody hunting for 'Ativar' in a console that says 'Enable'. So: translate the prose around them, leave the label in English, and do not key it with identical values on both sides — a key whose two halves can never legitimately differ is a maintenance cost bought for nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d8709c05a |
plan: roles as data, teams as scopes, and the owner as an identity
Design for tomorrow, written down so the work starts from a spec. Phase 0 is already built; everything after it is plan. The load-bearing decision is the smallest one. Ownership is answered today by users.role === 'Super Admin', which was fine while roles were a hardcoded union and nothing could write a new one — and becomes a privilege-escalation path the moment roles are editable data, because then adding a role and becoming the owner are the same feature. So phase 1 is just that: the owner is OWNER_USER_ID, every existing call site keeps meaning owner, and nothing downstream starts until it lands. Teams union rather than restrict, which is the only coherent resolution and has a consequence worth stating: a team can only ever grant. Deny rules are named under what must not be built, along with a role that confers ownership and team-scoped execution capabilities. And the thing a reader would otherwise discover at the end of phase 4: there is still no way to create a second account, so this is a permission model for accounts that cannot yet exist. Sequenced last because it is orthogonal, flagged because it may be worth doing first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
99a78f3c7f |
note the provider requirement shared components now carry, under open question 3
Found while converting them in phase 1: they resolve against whatever provider is above them and render dot-paths with none. Safe today only because the second React root imports nothing from components/. Written where the plugin-contract decision will be made rather than solved here. |
||
|
|
3dd37d053b |
localization: every locale ships a complete catalog, no regional overlays
D2 had pt-BR inherit from pt so a base carried the shared bulk and a regional file carried the differences. Conventional, and rejected by the owner for a product reason that outranks the engineering one. The failure mode of an overlay is a missing European Portuguese key falling through to Brazilian — and the two differ most in exactly the technical vocabulary this UI is full of. The platform sells into European Portuguese first, so that failure is a reader getting a neighbouring variant's words on whichever strings nobody remembered to override, silently. So every locale is a whole file, translated, or it does not ship. The drift objection is answered by promoting the key-parity test from advisory to load-bearing: a locale missing a key `en` has fails the build, so an incomplete catalog cannot ship at all. Completeness is enforced rather than papered over. It is also less code — resolveKey looks in one catalog and then English, with no chain to walk and no merge order — and it removes the question of which file a given string lives in. Consequence for the work: a new locale is a full machine-translated pass reviewed on its own, not a diff against a base. pt-PT and pt-BR get one each, separately, because the whole point is that neither borrows from the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
510fa3f524 |
deploy the tag, and stop spending a minute on a host that is known to be down
Two things the 2026.08.23.01 rollout showed. `edge-pertento-bridge` is down and staying down for some days, and the deploy agent had no way to know: it spent four attempts and up to sixty seconds apiece before reporting it unreachable. Correct behaviour against a list that had not been told, so `docs/servers.md` now has a Deliberately skipped section, with the host's row kept verbatim inside it so restoring it is one move. A host there is named as skipped in the report and costs nothing else. And the procedure said `git pull --ff-only`, which follows `master` rather than the tag being deployed. A docs commit landed mid-rollout, so every host came up on `e6f1388a` — one past `deploy/2026.08.23.01` — and "deployed the tag" was true of none of them. Harmless this time, and it need not have been: it is now fetch plus `merge --ff-only <tag>`. A host that is AHEAD of the tag is reported, never reset. Also lands the report itself, which the agent wrote and could not push. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e6f1388aa6 |
plan: localizing the web UI, and the scanner that sizes it
not built. an execution plan written to be handed to an agent who did not have the conversation, so it carries the reasons and not just the steps. what it found: there is already a hand-rolled i18n package with typed keys, two catalogs and 46 keys — and exactly three components using it. against 884 distinct hardcoded strings across 436 files. so the system is not missing, it is 5% deployed, and that gap is the job. the decisions worth arguing with are D1 and D6. D1: the server should never emit user-facing prose, it returns a stable code and the UI owns every word — with the trap that a code must not leak more than the prose did, or a deliberately vague 403 becomes an enumeration oracle. D6: catalogs per workspace rather than one file, because one 900-key en.json is a merge conflict on every parallel edit. the scanner reproduces the numbers in the doc and doubles as the guard once an area reaches zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8b4a5b3b0b |
docs: SendOff — files land in the user's tree, a push says where
Spec from the mobile side, v2. One endpoint (POST /api/sendoff/send — the one sanctioned door into another user's filesystem, sender identity from the token), a push category whose route deep-links the Files apps, and a minimal devices registry for the send-to pickers. The mobile half is already shipped against this and degrades silently until it exists. Lives here rather than in COMMS because it is a spec to implement, not a message: the repo is where it can be corrected as the implementation teaches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3837af657d |
live sessions default to agents only, and you ask by name for a commander
`?kind=` was opt-in, so a client that said nothing got everything — which is the wrong default for the one caller that cannot cope. The phone swipes between live chats one at a time, has no room for tabs and no way to skip a row, so a commander in that list is a commander you are stuck on. Defaulting to `agent` means forgetting the parameter can only ever under-report. The two callers that want both now say `kind=all` explicitly, and that is a fair price: they are few, they are on screens built to show both kinds, and they are the ones positioned to notice when the answer is wrong. `counts` still describes every live session whatever the filter, so a filtered caller renders both tab numbers from one request rather than two. Also corrects useLiveSessions' comment, which said `isCommander` was derived from the cwd. It stopped being true when a commander gained the right to choose its own working directory — it comes from the `command_hq_sessions` row, and the directory answers nothing. Unrelated but caught by docs:check on the way past: the registry gained a nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which also left the generated platform skill two lines stale. Both corrected; the check is green again. The minesweeper feature itself is half-landed and not mine — `rfb.ts` fails typecheck and its registry test fails on a websocket that is not served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|